Skip to content

Commit 866d49e

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

13 files changed

Lines changed: 443 additions & 39 deletions

docs/chatgpt-coding-workflow.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ 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. The conversation binding is persisted so reconnecting
26+
the MCP transport or restarting DevSpace does not create another managed worktree
27+
for the same ChatGPT conversation and target.
28+
2029
Do not reopen the same folder unless:
2130

2231
- 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/db/migrations.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ const migrations: Migration[] = [
2222
name: "local-agent-sessions",
2323
up: migrateLocalAgentSessions,
2424
},
25+
{
26+
version: 4,
27+
name: "workspace-conversation-bindings",
28+
up: migrateWorkspaceConversationBindings,
29+
},
2530
];
2631

2732
export function migrateDatabase(sqlite: Database.Database): void {
@@ -174,6 +179,25 @@ function migrateLocalAgentSessions(sqlite: Database.Database): void {
174179
addColumnIfMissing(sqlite, "local_agent_sessions", "thinking", "text");
175180
}
176181

182+
function migrateWorkspaceConversationBindings(sqlite: Database.Database): void {
183+
sqlite.exec(`
184+
create table if not exists workspace_conversation_bindings (
185+
conversation_scope_hash text not null,
186+
target_key text not null,
187+
workspace_session_id text not null,
188+
created_at text not null,
189+
last_used_at text not null,
190+
primary key (conversation_scope_hash, target_key),
191+
foreign key (workspace_session_id)
192+
references workspace_sessions(id)
193+
on delete cascade
194+
);
195+
196+
create index if not exists workspace_conversation_bindings_workspace_idx
197+
on workspace_conversation_bindings(workspace_session_id);
198+
`);
199+
}
200+
177201
function addColumnIfMissing(
178202
sqlite: Database.Database,
179203
table: "workspace_sessions" | "local_agent_sessions",

src/db/schema.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,23 @@ export const loadedAgentFiles = sqliteTable(
3838
],
3939
);
4040

41+
export const workspaceConversationBindings = sqliteTable(
42+
"workspace_conversation_bindings",
43+
{
44+
conversationScopeHash: text("conversation_scope_hash").notNull(),
45+
targetKey: text("target_key").notNull(),
46+
workspaceSessionId: text("workspace_session_id")
47+
.notNull()
48+
.references(() => workspaceSessions.id, { onDelete: "cascade" }),
49+
createdAt: text("created_at").notNull(),
50+
lastUsedAt: text("last_used_at").notNull(),
51+
},
52+
(table) => [
53+
primaryKey({ columns: [table.conversationScopeHash, table.targetKey] }),
54+
index("workspace_conversation_bindings_workspace_idx").on(table.workspaceSessionId),
55+
],
56+
);
57+
4158
export const oauthClients = sqliteTable(
4259
"oauth_clients",
4360
{
@@ -101,5 +118,7 @@ export type WorkspaceSessionRow = typeof workspaceSessions.$inferSelect;
101118
export type NewWorkspaceSessionRow = typeof workspaceSessions.$inferInsert;
102119
export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect;
103120
export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$inferInsert;
121+
export type WorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferSelect;
122+
export type NewWorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferInsert;
104123
export type LocalAgentSessionRow = typeof localAgentSessions.$inferSelect;
105124
export type NewLocalAgentSessionRow = typeof localAgentSessions.$inferInsert;

src/oauth-store.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise<void> {
4444
{ version: 1, name: "workspace-state" },
4545
{ version: 2, name: "oauth-state" },
4646
{ version: 3, name: "local-agent-sessions" },
47+
{ version: 4, name: "workspace-conversation-bindings" },
4748
]);
4849
} finally {
4950
database.close();

src/request-meta.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import assert from "node:assert/strict";
2+
import { openAiConversationScopeHash } from "./request-meta.js";
3+
4+
assert.equal(openAiConversationScopeHash(undefined), undefined);
5+
assert.equal(openAiConversationScopeHash({}), undefined);
6+
assert.equal(openAiConversationScopeHash({ "openai/session": "" }), undefined);
7+
8+
const sessionOnly = openAiConversationScopeHash({ "openai/session": "chat-1" });
9+
assert.match(sessionOnly ?? "", /^[a-f0-9]{64}$/);
10+
assert.equal(
11+
sessionOnly,
12+
openAiConversationScopeHash({ "openai/session": "chat-1" }),
13+
);
14+
assert.notEqual(
15+
sessionOnly,
16+
openAiConversationScopeHash({ "openai/session": "chat-2" }),
17+
);
18+
19+
assert.equal(
20+
sessionOnly,
21+
openAiConversationScopeHash({
22+
"openai/session": "chat-1",
23+
"openai/subject": "user-1",
24+
"openai/organization": "org-1",
25+
}),
26+
);

src/request-meta.ts

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

src/server.ts

Lines changed: 49 additions & 29 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 { openAiConversationScopeHash } 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 return the existing workspaceId and omit bootstrap details already returned. 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()
@@ -784,60 +785,74 @@ function createMcpServer(
784785
managed: z.boolean(),
785786
})
786787
.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()),
788+
agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(),
789+
availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema).optional(),
790+
skills: z.array(workspaceSkillOutputSchema).optional(),
791+
agentProviders: z.array(workspaceLocalAgentProviderOutputSchema).optional(),
792+
agents: z.array(workspaceLocalAgentOutputSchema).optional(),
793+
skillDiagnostics: z.array(z.unknown()).optional(),
793794
instruction: z.string(),
794795
},
795796
...toolWidgetDescriptorMeta(config, "workspace"),
796797
annotations: { readOnlyHint: true },
797798
},
798-
async ({ path, mode, baseRef }) => {
799+
async ({ path, mode, baseRef }, { _meta }) => {
799800
const startedAt = performance.now();
800-
const { workspace, agentsFiles, availableAgentsFiles } = await workspaces.openWorkspace({ path, mode, baseRef });
801-
if (config.widgets === "changes") {
801+
const {
802+
workspace,
803+
agentsFiles,
804+
availableAgentsFiles,
805+
includeBootstrapContext,
806+
} = await workspaces.openWorkspace(
807+
{ path, mode, baseRef },
808+
{ conversationScopeHash: openAiConversationScopeHash(_meta) },
809+
);
810+
const reused = !includeBootstrapContext;
811+
if (config.widgets === "changes" && includeBootstrapContext) {
802812
void reviewCheckpoints.initializeWorkspace({
803813
workspaceId: workspace.id,
804814
root: workspace.root,
805815
});
806816
}
807-
const visibleSkills = workspace.skills
817+
const visibleSkills = includeBootstrapContext ? workspace.skills
808818
.filter((skill) => !skill.disableModelInvocation)
809819
.map((skill) => ({
810820
name: skill.name,
811821
description: skill.description,
812822
path: formatPathForPrompt(skill.filePath),
813-
}));
814-
const visibleAgentProviders = config.subagents ? localAgentProviders : [];
815-
const visibleAgents = workspace.agentProfiles.map((profile) => {
823+
})) : [];
824+
const visibleAgentProviders = includeBootstrapContext && config.subagents ? localAgentProviders : [];
825+
const visibleAgents = includeBootstrapContext ? workspace.agentProfiles.map((profile) => {
816826
const summary = summarizeLocalAgentProfile(profile);
817827
const availability = visibleAgentProviders.find((provider) => provider.name === summary.provider);
818828
return {
819829
...summary,
820830
providerAvailable: availability?.available,
821831
providerUnavailableReason: availability?.reason,
822832
};
823-
});
824-
const loadedAgentsFiles = agentsFiles.map((file) => ({
833+
}) : [];
834+
const loadedAgentsFiles = includeBootstrapContext ? agentsFiles.map((file) => ({
825835
path: formatAgentsPath(file.path, workspace.root),
826836
content: file.content,
827-
}));
828-
const availableAgentsFileOutputs = availableAgentsFiles.map((file) => ({
837+
})) : [];
838+
const availableAgentsFileOutputs = includeBootstrapContext ? availableAgentsFiles.map((file) => ({
829839
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.";
840+
})) : [];
841+
const instruction = reused
842+
? "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."
843+
: config.skillsEnabled
844+
? "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."
845+
: "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.";
834846
const resultContent: ToolContent[] = [
835847
{
836848
type: "text" as const,
837849
text: [
838-
`Opened workspace ${workspace.id}`,
850+
`${reused ? "Workspace already open as" : "Opened workspace"} ${workspace.id}`,
839851
`Root: ${workspace.root}`,
840852
`Mode: ${workspace.mode}`,
853+
reused
854+
? "Bootstrap details omitted because they were already returned in this ChatGPT conversation."
855+
: undefined,
841856
loadedAgentsFiles.length > 0
842857
? `Loaded project instructions: ${loadedAgentsFiles.map((file) => file.path).join(", ")}`
843858
: undefined,
@@ -878,6 +893,7 @@ function createMcpServer(
878893
path: workspace.root,
879894
summary: {
880895
mode: workspace.mode,
896+
reused,
881897
agentsFiles: loadedAgentsFiles.length,
882898
availableAgentsFiles: availableAgentsFileOutputs.length,
883899
skills: visibleSkills.length,
@@ -893,12 +909,16 @@ function createMcpServer(
893909
mode: workspace.mode,
894910
sourceRoot: workspace.sourceRoot,
895911
worktree: workspace.worktree,
896-
agentsFiles: loadedAgentsFiles,
897-
availableAgentsFiles: availableAgentsFileOutputs,
898-
skills: visibleSkills,
899-
agentProviders: visibleAgentProviders,
900-
agents: visibleAgents,
901-
skillDiagnostics: workspace.skillDiagnostics,
912+
...(includeBootstrapContext
913+
? {
914+
agentsFiles: loadedAgentsFiles,
915+
availableAgentsFiles: availableAgentsFileOutputs,
916+
skills: visibleSkills,
917+
agentProviders: visibleAgentProviders,
918+
agents: visibleAgents,
919+
skillDiagnostics: workspace.skillDiagnostics,
920+
}
921+
: {}),
902922
instruction,
903923
},
904924
};

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));

0 commit comments

Comments
 (0)