Skip to content

Commit 2e2c471

Browse files
committed
feat(workspace): reuse opens within ChatGPT sessions
1 parent 8cd2fba commit 2e2c471

9 files changed

Lines changed: 219 additions & 34 deletions

docs/chatgpt-coding-workflow.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ ChatGPT should call `open_workspace` once for a project folder:
1717
The result includes a `workspaceId`. All later file, search, edit, show-changes,
1818
and shell calls should reuse that same `workspaceId`.
1919

20+
ChatGPT sends an anonymized conversation identifier in
21+
`_meta["openai/session"]`. DevSpace uses that value only as a correlation scope:
22+
if `open_workspace` is called again for the same path, mode, and base ref in the
23+
same ChatGPT conversation, DevSpace returns the existing `workspaceId` and omits
24+
the project instructions, skills, subagent metadata, and diagnostics already
25+
returned by the first call. Pass `reopen: true` only when the user explicitly
26+
asks for a fresh workspace or another managed worktree.
27+
2028
Do not reopen the same folder unless:
2129

2230
- the `workspaceId` is rejected as unknown

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"dev": "node scripts/dev-server.mjs",
2929
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
3030
"start": "node dist/cli.js serve",
31-
"test": "tsx src/config.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
31+
"test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
3232
"typecheck": "tsc -p tsconfig.json --noEmit"
3333
},
3434
"keywords": [],

src/request-meta.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import assert from "node:assert/strict";
2+
import { openAiConversationScope } from "./request-meta.js";
3+
4+
assert.equal(openAiConversationScope(undefined), undefined);
5+
assert.equal(openAiConversationScope({}), undefined);
6+
assert.equal(openAiConversationScope({ "openai/session": "" }), undefined);
7+
8+
assert.equal(
9+
openAiConversationScope({ "openai/session": "chat-1" }),
10+
JSON.stringify(["openai", null, null, "chat-1"]),
11+
);
12+
13+
assert.equal(
14+
openAiConversationScope({
15+
"openai/session": "chat-1",
16+
"openai/subject": "user-1",
17+
"openai/organization": "org-1",
18+
}),
19+
JSON.stringify(["openai", "org-1", "user-1", "chat-1"]),
20+
);

src/request-meta.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
function metadataString(
2+
meta: Record<string, unknown> | undefined,
3+
key: string,
4+
): string | undefined {
5+
const value = meta?.[key];
6+
return typeof value === "string" && value.length > 0 ? value : undefined;
7+
}
8+
9+
export function openAiConversationScope(
10+
meta: Record<string, unknown> | undefined,
11+
): string | undefined {
12+
const session = metadataString(meta, "openai/session");
13+
if (!session) return undefined;
14+
15+
return JSON.stringify([
16+
"openai",
17+
metadataString(meta, "openai/organization") ?? null,
18+
metadataString(meta, "openai/subject") ?? null,
19+
session,
20+
]);
21+
}

src/server.ts

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
} from "./mcp-sessions.js";
5151
import { ProcessSessionManager, type ProcessSnapshot } from "./process-sessions.js";
5252
import { createReviewCheckpointManager } from "./review-checkpoints.js";
53+
import { openAiConversationScope } from "./request-meta.js";
5354
import { shutdownHttpServer } from "./server-shutdown.js";
5455
import { formatPathForPrompt } from "./skills.js";
5556
import { createWorkspaceStore } from "./workspace-store.js";
@@ -751,7 +752,7 @@ function createMcpServer(
751752
{
752753
title: "Open workspace",
753754
description:
754-
"Open a local project directory as a coding workspace. Call this once per project folder or worktree before reading, editing, searching, writing, showing changes, or running commands. Reuse the returned workspaceId for later calls in the same folder; do not call open_workspace again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. By default this opens the actual checkout; set mode=\"worktree\" when the user asks for an isolated or parallel coding session. Returns a workspaceId, loaded root project instructions, and nested instruction file paths the model should read before working in those directories.",
755+
"Open a local project directory as a coding workspace. Call this once per project folder or worktree before reading, editing, searching, writing, showing changes, or running commands. Reuse the returned workspaceId for later calls in the same folder. In ChatGPT, repeated calls for the same target in one conversation reuse the existing workspace and omit bootstrap details already returned. Set reopen=true only when the user explicitly asks for a fresh workspace. By default this opens the actual checkout; set mode=\"worktree\" when the user asks for an isolated or parallel coding session.",
755756
inputSchema: {
756757
path: z
757758
.string()
@@ -768,11 +769,19 @@ function createMcpServer(
768769
.string()
769770
.optional()
770771
.describe("Git ref to base a worktree on. Only used with mode=\"worktree\". Defaults to HEAD."),
772+
reopen: z
773+
.boolean()
774+
.optional()
775+
.describe(
776+
"Bypass same-conversation workspace reuse and create a fresh workspace. Use only when the user explicitly asks to reopen or create another worktree.",
777+
),
771778
},
772779
outputSchema: {
773780
workspaceId: z.string(),
774781
root: z.string(),
775782
mode: z.enum(["checkout", "worktree"]),
783+
reused: z.boolean(),
784+
bootstrapIncluded: z.boolean(),
776785
sourceRoot: z.string().optional(),
777786
worktree: z
778787
.object({
@@ -784,60 +793,72 @@ function createMcpServer(
784793
managed: z.boolean(),
785794
})
786795
.optional(),
787-
agentsFiles: z.array(workspaceAgentsFileOutputSchema),
788-
availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema),
789-
skills: z.array(workspaceSkillOutputSchema),
790-
agentProviders: z.array(workspaceLocalAgentProviderOutputSchema),
791-
agents: z.array(workspaceLocalAgentOutputSchema),
792-
skillDiagnostics: z.array(z.unknown()),
796+
agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(),
797+
availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema).optional(),
798+
skills: z.array(workspaceSkillOutputSchema).optional(),
799+
agentProviders: z.array(workspaceLocalAgentProviderOutputSchema).optional(),
800+
agents: z.array(workspaceLocalAgentOutputSchema).optional(),
801+
skillDiagnostics: z.array(z.unknown()).optional(),
793802
instruction: z.string(),
794803
},
795804
...toolWidgetDescriptorMeta(config, "workspace"),
796805
annotations: { readOnlyHint: true },
797806
},
798-
async ({ path, mode, baseRef }) => {
807+
async ({ path, mode, baseRef, reopen }, { _meta }) => {
799808
const startedAt = performance.now();
800-
const { workspace, agentsFiles, availableAgentsFiles } = await workspaces.openWorkspace({ path, mode, baseRef });
809+
const { workspace, agentsFiles, availableAgentsFiles, reused } = await workspaces.openWorkspace(
810+
{ path, mode, baseRef },
811+
{
812+
reuseScope: openAiConversationScope(_meta),
813+
forceNew: reopen,
814+
},
815+
);
816+
const bootstrapIncluded = !reused;
801817
if (config.widgets === "changes") {
802818
void reviewCheckpoints.initializeWorkspace({
803819
workspaceId: workspace.id,
804820
root: workspace.root,
805821
});
806822
}
807-
const visibleSkills = workspace.skills
823+
const visibleSkills = bootstrapIncluded ? workspace.skills
808824
.filter((skill) => !skill.disableModelInvocation)
809825
.map((skill) => ({
810826
name: skill.name,
811827
description: skill.description,
812828
path: formatPathForPrompt(skill.filePath),
813-
}));
814-
const visibleAgentProviders = config.subagents ? localAgentProviders : [];
815-
const visibleAgents = workspace.agentProfiles.map((profile) => {
829+
})) : [];
830+
const visibleAgentProviders = bootstrapIncluded && config.subagents ? localAgentProviders : [];
831+
const visibleAgents = bootstrapIncluded ? workspace.agentProfiles.map((profile) => {
816832
const summary = summarizeLocalAgentProfile(profile);
817833
const availability = visibleAgentProviders.find((provider) => provider.name === summary.provider);
818834
return {
819835
...summary,
820836
providerAvailable: availability?.available,
821837
providerUnavailableReason: availability?.reason,
822838
};
823-
});
824-
const loadedAgentsFiles = agentsFiles.map((file) => ({
839+
}) : [];
840+
const loadedAgentsFiles = bootstrapIncluded ? agentsFiles.map((file) => ({
825841
path: formatAgentsPath(file.path, workspace.root),
826842
content: file.content,
827-
}));
828-
const availableAgentsFileOutputs = availableAgentsFiles.map((file) => ({
843+
})) : [];
844+
const availableAgentsFileOutputs = bootstrapIncluded ? availableAgentsFiles.map((file) => ({
829845
path: formatAgentsPath(file.path, workspace.root),
830-
}));
831-
const instruction = config.skillsEnabled
832-
? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding."
833-
: "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file.";
846+
})) : [];
847+
const instruction = reused
848+
? "Reuse this workspaceId for subsequent tool calls. Workspace instructions, nested instruction paths, skills, subagent metadata, and diagnostics were already returned earlier in this ChatGPT conversation and are intentionally omitted here."
849+
: config.skillsEnabled
850+
? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding."
851+
: "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file.";
834852
const resultContent: ToolContent[] = [
835853
{
836854
type: "text" as const,
837855
text: [
838-
`Opened workspace ${workspace.id}`,
856+
`${reused ? "Reused" : "Opened"} workspace ${workspace.id}`,
839857
`Root: ${workspace.root}`,
840858
`Mode: ${workspace.mode}`,
859+
reused
860+
? "Bootstrap details omitted because they were already returned in this ChatGPT conversation."
861+
: undefined,
841862
loadedAgentsFiles.length > 0
842863
? `Loaded project instructions: ${loadedAgentsFiles.map((file) => file.path).join(", ")}`
843864
: undefined,
@@ -878,6 +899,7 @@ function createMcpServer(
878899
path: workspace.root,
879900
summary: {
880901
mode: workspace.mode,
902+
reused,
881903
agentsFiles: loadedAgentsFiles.length,
882904
availableAgentsFiles: availableAgentsFileOutputs.length,
883905
skills: visibleSkills.length,
@@ -891,14 +913,20 @@ function createMcpServer(
891913
workspaceId: workspace.id,
892914
root: workspace.root,
893915
mode: workspace.mode,
916+
reused,
917+
bootstrapIncluded,
894918
sourceRoot: workspace.sourceRoot,
895919
worktree: workspace.worktree,
896-
agentsFiles: loadedAgentsFiles,
897-
availableAgentsFiles: availableAgentsFileOutputs,
898-
skills: visibleSkills,
899-
agentProviders: visibleAgentProviders,
900-
agents: visibleAgents,
901-
skillDiagnostics: workspace.skillDiagnostics,
920+
...(bootstrapIncluded
921+
? {
922+
agentsFiles: loadedAgentsFiles,
923+
availableAgentsFiles: availableAgentsFileOutputs,
924+
skills: visibleSkills,
925+
agentProviders: visibleAgentProviders,
926+
agents: visibleAgents,
927+
skillDiagnostics: workspace.skillDiagnostics,
928+
}
929+
: {}),
902930
instruction,
903931
},
904932
};

src/ui/tool-display.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ for (const [card, expected] of displayCases) {
2525
}
2626

2727
assert.equal(getToolDisplay({ tool: "open_workspace", root: "/tmp/project" }).label, "/tmp/project");
28+
assert.equal(
29+
getToolDisplay({ tool: "open_workspace", root: "/tmp/project", summary: { reused: true } }).title,
30+
"Reused workspace",
31+
);
2832
assert.equal(
2933
getToolDisplay({ tool: "grep", summary: { pattern: "needle", scope: "src" } }).label,
3034
"needle in src",
@@ -95,6 +99,13 @@ assert.deepEqual(
9599
}),
96100
{ kind: "text", text: "worktree · 1 instruction · 4 skills" },
97101
);
102+
assert.deepEqual(
103+
getToolHeaderSummary({
104+
tool: "open_workspace",
105+
summary: { mode: "worktree", reused: true },
106+
}),
107+
{ kind: "text", text: "worktree · reused" },
108+
);
98109

99110
assert.deepEqual(
100111
getToolHeaderSummary({ tool: "exec_command", summary: { lines: 3, wallTimeMs: 1_500 } }),

src/ui/tool-display.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function getToolDisplay(card: ToolResultCard): ToolDisplay {
2727
case "open_workspace":
2828
return {
2929
icon: toolIcons.folderOpen,
30-
title: "Opened workspace",
30+
title: card.summary?.reused === true ? "Reused workspace" : "Opened workspace",
3131
label: card.root ?? card.path,
3232
tone: "workspace",
3333
};
@@ -123,6 +123,7 @@ export function getToolHeaderSummary(card: ToolResultCard): ToolHeaderSummary {
123123
if (card.tool === "open_workspace") {
124124
const parts = [
125125
typeof summary.mode === "string" ? summary.mode : undefined,
126+
summary.reused === true ? "reused" : undefined,
126127
countLabel(summaryNumber(summary, "agentsFiles"), "instruction"),
127128
countLabel(summaryNumber(summary, "skills"), "skill"),
128129
].filter((part): part is string => Boolean(part));

src/workspaces.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,21 @@ try {
5959
agentsFiles.map((file) => file.content),
6060
["global instructions\n", "root instructions\n"],
6161
);
62+
63+
const scopedCheckout = await registry.openWorkspace(root, { reuseScope: "chat-1" });
64+
const reusedCheckout = await registry.openWorkspace(root, { reuseScope: "chat-1" });
65+
assert.equal(scopedCheckout.reused, false);
66+
assert.equal(reusedCheckout.reused, true);
67+
assert.equal(reusedCheckout.workspace.id, scopedCheckout.workspace.id);
68+
assert.deepEqual(reusedCheckout.agentsFiles, []);
69+
assert.deepEqual(reusedCheckout.availableAgentsFiles, []);
70+
71+
const reopenedCheckout = await registry.openWorkspace(root, {
72+
reuseScope: "chat-1",
73+
forceNew: true,
74+
});
75+
assert.equal(reopenedCheckout.reused, false);
76+
assert.notEqual(reopenedCheckout.workspace.id, scopedCheckout.workspace.id);
6277
assert.deepEqual(
6378
availableAgentsFiles.map((file) => file.path),
6479
[join(root, "nested", "AGENTS.md")],
@@ -141,7 +156,7 @@ try {
141156
const worktreeWorkspace = await registry.openWorkspace({
142157
path: gitRoot,
143158
mode: "worktree",
144-
});
159+
}, { reuseScope: "chat-worktree" });
145160
assert.equal(worktreeWorkspace.workspace.mode, "worktree");
146161
assert.notEqual(worktreeWorkspace.workspace.root, gitRoot);
147162
assert.match(worktreeWorkspace.workspace.root, /git-project-[a-f0-9]{8}$/);
@@ -153,6 +168,16 @@ try {
153168
assert.match(worktreeWorkspace.agentsFiles.map((file) => file.content).join("\n"), /global instructions/);
154169
assert.match(worktreeWorkspace.agentsFiles.map((file) => file.content).join("\n"), /git root instructions/);
155170

171+
const reusedWorktreeWorkspace = await registry.openWorkspace({
172+
path: gitRoot,
173+
mode: "worktree",
174+
}, { reuseScope: "chat-worktree" });
175+
assert.equal(reusedWorktreeWorkspace.reused, true);
176+
assert.equal(reusedWorktreeWorkspace.workspace.id, worktreeWorkspace.workspace.id);
177+
assert.equal(reusedWorktreeWorkspace.workspace.root, worktreeWorkspace.workspace.root);
178+
assert.deepEqual(reusedWorktreeWorkspace.agentsFiles, []);
179+
assert.deepEqual(reusedWorktreeWorkspace.availableAgentsFiles, []);
180+
156181
const worktreeReadmePath = registry.resolvePath(worktreeWorkspace.workspace, "README.md");
157182
assert.equal(worktreeReadmePath.startsWith(worktreeWorkspace.workspace.root), true);
158183

0 commit comments

Comments
 (0)