From 866d49e35348bb5e45ce98abb90998b381ef247f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 13:38:24 +0530 Subject: [PATCH 1/9] feat(workspace): reuse opens within ChatGPT sessions --- docs/chatgpt-coding-workflow.md | 9 +++ package.json | 2 +- src/db/migrations.ts | 24 +++++++ src/db/schema.ts | 19 ++++++ src/oauth-store.test.ts | 1 + src/request-meta.test.ts | 26 ++++++++ src/request-meta.ts | 20 ++++++ src/server.ts | 78 ++++++++++++++--------- src/ui/tool-display.test.ts | 11 ++++ src/ui/tool-display.ts | 3 +- src/workspace-store.ts | 108 +++++++++++++++++++++++++++++++- src/workspaces.test.ts | 80 +++++++++++++++++++++-- src/workspaces.ts | 101 ++++++++++++++++++++++++++++- 13 files changed, 443 insertions(+), 39 deletions(-) create mode 100644 src/request-meta.test.ts create mode 100644 src/request-meta.ts diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 66062621..c068b325 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -17,6 +17,15 @@ ChatGPT should call `open_workspace` once for a project folder: The result includes a `workspaceId`. All later file, search, edit, show-changes, and shell calls should reuse that same `workspaceId`. +ChatGPT sends an anonymized conversation identifier in +`_meta["openai/session"]`. DevSpace uses that value only as a correlation scope: +if `open_workspace` is called again for the same path, mode, and base ref in the +same ChatGPT conversation, DevSpace returns the existing `workspaceId` and omits +the project instructions, skills, subagent metadata, and diagnostics already +returned by the first call. The conversation binding is persisted so reconnecting +the MCP transport or restarting DevSpace does not create another managed worktree +for the same ChatGPT conversation and target. + Do not reopen the same folder unless: - the `workspaceId` is rejected as unknown diff --git a/package.json b/package.json index 4489360e..cb8f4618 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "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", + "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", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 2bda20c2..24fb891c 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -22,6 +22,11 @@ const migrations: Migration[] = [ name: "local-agent-sessions", up: migrateLocalAgentSessions, }, + { + version: 4, + name: "workspace-conversation-bindings", + up: migrateWorkspaceConversationBindings, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -174,6 +179,25 @@ function migrateLocalAgentSessions(sqlite: Database.Database): void { addColumnIfMissing(sqlite, "local_agent_sessions", "thinking", "text"); } +function migrateWorkspaceConversationBindings(sqlite: Database.Database): void { + sqlite.exec(` + create table if not exists workspace_conversation_bindings ( + conversation_scope_hash text not null, + target_key text not null, + workspace_session_id text not null, + created_at text not null, + last_used_at text not null, + primary key (conversation_scope_hash, target_key), + foreign key (workspace_session_id) + references workspace_sessions(id) + on delete cascade + ); + + create index if not exists workspace_conversation_bindings_workspace_idx + on workspace_conversation_bindings(workspace_session_id); + `); +} + function addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions" | "local_agent_sessions", diff --git a/src/db/schema.ts b/src/db/schema.ts index 01d13fac..06a8b844 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -38,6 +38,23 @@ export const loadedAgentFiles = sqliteTable( ], ); +export const workspaceConversationBindings = sqliteTable( + "workspace_conversation_bindings", + { + conversationScopeHash: text("conversation_scope_hash").notNull(), + targetKey: text("target_key").notNull(), + workspaceSessionId: text("workspace_session_id") + .notNull() + .references(() => workspaceSessions.id, { onDelete: "cascade" }), + createdAt: text("created_at").notNull(), + lastUsedAt: text("last_used_at").notNull(), + }, + (table) => [ + primaryKey({ columns: [table.conversationScopeHash, table.targetKey] }), + index("workspace_conversation_bindings_workspace_idx").on(table.workspaceSessionId), + ], +); + export const oauthClients = sqliteTable( "oauth_clients", { @@ -101,5 +118,7 @@ export type WorkspaceSessionRow = typeof workspaceSessions.$inferSelect; export type NewWorkspaceSessionRow = typeof workspaceSessions.$inferInsert; export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect; export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$inferInsert; +export type WorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferSelect; +export type NewWorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferInsert; export type LocalAgentSessionRow = typeof localAgentSessions.$inferSelect; export type NewLocalAgentSessionRow = typeof localAgentSessions.$inferInsert; diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index e1c00338..e47f8121 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -44,6 +44,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 1, name: "workspace-state" }, { version: 2, name: "oauth-state" }, { version: 3, name: "local-agent-sessions" }, + { version: 4, name: "workspace-conversation-bindings" }, ]); } finally { database.close(); diff --git a/src/request-meta.test.ts b/src/request-meta.test.ts new file mode 100644 index 00000000..de63e61f --- /dev/null +++ b/src/request-meta.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { openAiConversationScopeHash } from "./request-meta.js"; + +assert.equal(openAiConversationScopeHash(undefined), undefined); +assert.equal(openAiConversationScopeHash({}), undefined); +assert.equal(openAiConversationScopeHash({ "openai/session": "" }), undefined); + +const sessionOnly = openAiConversationScopeHash({ "openai/session": "chat-1" }); +assert.match(sessionOnly ?? "", /^[a-f0-9]{64}$/); +assert.equal( + sessionOnly, + openAiConversationScopeHash({ "openai/session": "chat-1" }), +); +assert.notEqual( + sessionOnly, + openAiConversationScopeHash({ "openai/session": "chat-2" }), +); + +assert.equal( + sessionOnly, + openAiConversationScopeHash({ + "openai/session": "chat-1", + "openai/subject": "user-1", + "openai/organization": "org-1", + }), +); diff --git a/src/request-meta.ts b/src/request-meta.ts new file mode 100644 index 00000000..4b17f6c2 --- /dev/null +++ b/src/request-meta.ts @@ -0,0 +1,20 @@ +import { createHash } from "node:crypto"; + +function metadataString( + meta: Record | undefined, + key: string, +): string | undefined { + const value = meta?.[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function openAiConversationScopeHash( + meta: Record | undefined, +): string | undefined { + const session = metadataString(meta, "openai/session"); + if (!session) return undefined; + + return createHash("sha256") + .update(JSON.stringify(["openai", session])) + .digest("hex"); +} diff --git a/src/server.ts b/src/server.ts index 91f5df61..a2a6a11b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -50,6 +50,7 @@ import { } from "./mcp-sessions.js"; import { ProcessSessionManager, type ProcessSnapshot } from "./process-sessions.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; +import { openAiConversationScopeHash } from "./request-meta.js"; import { shutdownHttpServer } from "./server-shutdown.js"; import { formatPathForPrompt } from "./skills.js"; import { createWorkspaceStore } from "./workspace-store.js"; @@ -751,7 +752,7 @@ function createMcpServer( { title: "Open workspace", description: - "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.", + "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.", inputSchema: { path: z .string() @@ -784,35 +785,44 @@ function createMcpServer( managed: z.boolean(), }) .optional(), - agentsFiles: z.array(workspaceAgentsFileOutputSchema), - availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema), - skills: z.array(workspaceSkillOutputSchema), - agentProviders: z.array(workspaceLocalAgentProviderOutputSchema), - agents: z.array(workspaceLocalAgentOutputSchema), - skillDiagnostics: z.array(z.unknown()), + agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(), + availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema).optional(), + skills: z.array(workspaceSkillOutputSchema).optional(), + agentProviders: z.array(workspaceLocalAgentProviderOutputSchema).optional(), + agents: z.array(workspaceLocalAgentOutputSchema).optional(), + skillDiagnostics: z.array(z.unknown()).optional(), instruction: z.string(), }, ...toolWidgetDescriptorMeta(config, "workspace"), annotations: { readOnlyHint: true }, }, - async ({ path, mode, baseRef }) => { + async ({ path, mode, baseRef }, { _meta }) => { const startedAt = performance.now(); - const { workspace, agentsFiles, availableAgentsFiles } = await workspaces.openWorkspace({ path, mode, baseRef }); - if (config.widgets === "changes") { + const { + workspace, + agentsFiles, + availableAgentsFiles, + includeBootstrapContext, + } = await workspaces.openWorkspace( + { path, mode, baseRef }, + { conversationScopeHash: openAiConversationScopeHash(_meta) }, + ); + const reused = !includeBootstrapContext; + if (config.widgets === "changes" && includeBootstrapContext) { void reviewCheckpoints.initializeWorkspace({ workspaceId: workspace.id, root: workspace.root, }); } - const visibleSkills = workspace.skills + const visibleSkills = includeBootstrapContext ? workspace.skills .filter((skill) => !skill.disableModelInvocation) .map((skill) => ({ name: skill.name, description: skill.description, path: formatPathForPrompt(skill.filePath), - })); - const visibleAgentProviders = config.subagents ? localAgentProviders : []; - const visibleAgents = workspace.agentProfiles.map((profile) => { + })) : []; + const visibleAgentProviders = includeBootstrapContext && config.subagents ? localAgentProviders : []; + const visibleAgents = includeBootstrapContext ? workspace.agentProfiles.map((profile) => { const summary = summarizeLocalAgentProfile(profile); const availability = visibleAgentProviders.find((provider) => provider.name === summary.provider); return { @@ -820,24 +830,29 @@ function createMcpServer( providerAvailable: availability?.available, providerUnavailableReason: availability?.reason, }; - }); - const loadedAgentsFiles = agentsFiles.map((file) => ({ + }) : []; + const loadedAgentsFiles = includeBootstrapContext ? agentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), content: file.content, - })); - const availableAgentsFileOutputs = availableAgentsFiles.map((file) => ({ + })) : []; + const availableAgentsFileOutputs = includeBootstrapContext ? availableAgentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), - })); - const instruction = config.skillsEnabled - ? "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." - : "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."; + })) : []; + const instruction = reused + ? "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." + : config.skillsEnabled + ? "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." + : "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."; const resultContent: ToolContent[] = [ { type: "text" as const, text: [ - `Opened workspace ${workspace.id}`, + `${reused ? "Workspace already open as" : "Opened workspace"} ${workspace.id}`, `Root: ${workspace.root}`, `Mode: ${workspace.mode}`, + reused + ? "Bootstrap details omitted because they were already returned in this ChatGPT conversation." + : undefined, loadedAgentsFiles.length > 0 ? `Loaded project instructions: ${loadedAgentsFiles.map((file) => file.path).join(", ")}` : undefined, @@ -878,6 +893,7 @@ function createMcpServer( path: workspace.root, summary: { mode: workspace.mode, + reused, agentsFiles: loadedAgentsFiles.length, availableAgentsFiles: availableAgentsFileOutputs.length, skills: visibleSkills.length, @@ -893,12 +909,16 @@ function createMcpServer( mode: workspace.mode, sourceRoot: workspace.sourceRoot, worktree: workspace.worktree, - agentsFiles: loadedAgentsFiles, - availableAgentsFiles: availableAgentsFileOutputs, - skills: visibleSkills, - agentProviders: visibleAgentProviders, - agents: visibleAgents, - skillDiagnostics: workspace.skillDiagnostics, + ...(includeBootstrapContext + ? { + agentsFiles: loadedAgentsFiles, + availableAgentsFiles: availableAgentsFileOutputs, + skills: visibleSkills, + agentProviders: visibleAgentProviders, + agents: visibleAgents, + skillDiagnostics: workspace.skillDiagnostics, + } + : {}), instruction, }, }; diff --git a/src/ui/tool-display.test.ts b/src/ui/tool-display.test.ts index b9977ac8..dd471795 100644 --- a/src/ui/tool-display.test.ts +++ b/src/ui/tool-display.test.ts @@ -25,6 +25,10 @@ for (const [card, expected] of displayCases) { } assert.equal(getToolDisplay({ tool: "open_workspace", root: "/tmp/project" }).label, "/tmp/project"); +assert.equal( + getToolDisplay({ tool: "open_workspace", root: "/tmp/project", summary: { reused: true } }).title, + "Reused workspace", +); assert.equal( getToolDisplay({ tool: "grep", summary: { pattern: "needle", scope: "src" } }).label, "needle in src", @@ -95,6 +99,13 @@ assert.deepEqual( }), { kind: "text", text: "worktree · 1 instruction · 4 skills" }, ); +assert.deepEqual( + getToolHeaderSummary({ + tool: "open_workspace", + summary: { mode: "worktree", reused: true }, + }), + { kind: "text", text: "worktree · reused" }, +); assert.deepEqual( getToolHeaderSummary({ tool: "exec_command", summary: { lines: 3, wallTimeMs: 1_500 } }), diff --git a/src/ui/tool-display.ts b/src/ui/tool-display.ts index 7a847631..a9837580 100644 --- a/src/ui/tool-display.ts +++ b/src/ui/tool-display.ts @@ -27,7 +27,7 @@ export function getToolDisplay(card: ToolResultCard): ToolDisplay { case "open_workspace": return { icon: toolIcons.folderOpen, - title: "Opened workspace", + title: card.summary?.reused === true ? "Reused workspace" : "Opened workspace", label: card.root ?? card.path, tone: "workspace", }; @@ -123,6 +123,7 @@ export function getToolHeaderSummary(card: ToolResultCard): ToolHeaderSummary { if (card.tool === "open_workspace") { const parts = [ typeof summary.mode === "string" ? summary.mode : undefined, + summary.reused === true ? "reused" : undefined, countLabel(summaryNumber(summary, "agentsFiles"), "instruction"), countLabel(summaryNumber(summary, "skills"), "skill"), ].filter((part): part is string => Boolean(part)); diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 39c2ed09..95bd282e 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -1,7 +1,9 @@ -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { + workspaceConversationBindings, workspaceSessions, + type WorkspaceConversationBindingRow, type WorkspaceSessionRow, } from "./db/schema.js"; @@ -20,6 +22,14 @@ export interface WorkspaceSession { lastUsedAt: string; } +export interface WorkspaceConversationBinding { + conversationScopeHash: string; + targetKey: string; + workspaceSessionId: string; + createdAt: string; + lastUsedAt: string; +} + export interface WorkspaceStore { createSession(input: { id: string; @@ -32,6 +42,17 @@ export interface WorkspaceStore { }): WorkspaceSession; getSession(id: string): WorkspaceSession | undefined; touchSession(id: string): void; + getConversationBinding( + conversationScopeHash: string, + targetKey: string, + ): WorkspaceConversationBinding | undefined; + setConversationBinding(input: { + conversationScopeHash: string; + targetKey: string; + workspaceSessionId: string; + }): WorkspaceConversationBinding; + touchConversationBinding(conversationScopeHash: string, targetKey: string): void; + deleteConversationBinding(conversationScopeHash: string, targetKey: string): void; close?(): void; } @@ -102,6 +123,79 @@ export class SqliteWorkspaceStore implements WorkspaceStore { .run(); } + getConversationBinding( + conversationScopeHash: string, + targetKey: string, + ): WorkspaceConversationBinding | undefined { + const row = this.database.db + .select() + .from(workspaceConversationBindings) + .where( + and( + eq(workspaceConversationBindings.conversationScopeHash, conversationScopeHash), + eq(workspaceConversationBindings.targetKey, targetKey), + ), + ) + .get(); + + return row ? rowToWorkspaceConversationBinding(row) : undefined; + } + + setConversationBinding(input: { + conversationScopeHash: string; + targetKey: string; + workspaceSessionId: string; + }): WorkspaceConversationBinding { + const now = new Date().toISOString(); + this.database.db + .insert(workspaceConversationBindings) + .values({ + conversationScopeHash: input.conversationScopeHash, + targetKey: input.targetKey, + workspaceSessionId: input.workspaceSessionId, + createdAt: now, + lastUsedAt: now, + }) + .onConflictDoUpdate({ + target: [ + workspaceConversationBindings.conversationScopeHash, + workspaceConversationBindings.targetKey, + ], + set: { + workspaceSessionId: input.workspaceSessionId, + lastUsedAt: now, + }, + }) + .run(); + + return this.getConversationBinding(input.conversationScopeHash, input.targetKey)!; + } + + touchConversationBinding(conversationScopeHash: string, targetKey: string): void { + this.database.db + .update(workspaceConversationBindings) + .set({ lastUsedAt: new Date().toISOString() }) + .where( + and( + eq(workspaceConversationBindings.conversationScopeHash, conversationScopeHash), + eq(workspaceConversationBindings.targetKey, targetKey), + ), + ) + .run(); + } + + deleteConversationBinding(conversationScopeHash: string, targetKey: string): void { + this.database.db + .delete(workspaceConversationBindings) + .where( + and( + eq(workspaceConversationBindings.conversationScopeHash, conversationScopeHash), + eq(workspaceConversationBindings.targetKey, targetKey), + ), + ) + .run(); + } + close(): void { this.database.close(); } @@ -126,3 +220,15 @@ function rowToWorkspaceSession(row: WorkspaceSessionRow): WorkspaceSession { lastUsedAt: row.lastUsedAt, }; } + +function rowToWorkspaceConversationBinding( + row: WorkspaceConversationBindingRow, +): WorkspaceConversationBinding { + return { + conversationScopeHash: row.conversationScopeHash, + targetKey: row.targetKey, + workspaceSessionId: row.workspaceSessionId, + createdAt: row.createdAt, + lastUsedAt: row.lastUsedAt, + }; +} diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 4f3eb769..22f316b7 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -59,6 +59,7 @@ try { agentsFiles.map((file) => file.content), ["global instructions\n", "root instructions\n"], ); + assert.deepEqual( availableAgentsFiles.map((file) => file.path), [join(root, "nested", "AGENTS.md")], @@ -159,11 +160,52 @@ try { const stateDir = join(root, ".state"); const firstStore = new SqliteWorkspaceStore(stateDir); const persistentRegistry = new WorkspaceRegistry(config, firstStore); - const persistentWorkspace = await persistentRegistry.openWorkspace(root); - const persistentWorktree = await persistentRegistry.openWorkspace({ - path: gitRoot, - mode: "worktree", + const persistentWorkspace = await persistentRegistry.openWorkspace(root, { + conversationScopeHash: "chat-checkout", + }); + const reusedPersistentWorkspace = await persistentRegistry.openWorkspace(root, { + conversationScopeHash: "chat-checkout", + }); + assert.equal(persistentWorkspace.includeBootstrapContext, true); + assert.equal(reusedPersistentWorkspace.includeBootstrapContext, false); + assert.equal(reusedPersistentWorkspace.workspace.id, persistentWorkspace.workspace.id); + assert.deepEqual(reusedPersistentWorkspace.agentsFiles, []); + assert.deepEqual(reusedPersistentWorkspace.availableAgentsFiles, []); + + const otherConversationWorkspace = await persistentRegistry.openWorkspace(root, { + conversationScopeHash: "chat-checkout-other", + }); + assert.equal(otherConversationWorkspace.includeBootstrapContext, true); + assert.notEqual(otherConversationWorkspace.workspace.id, persistentWorkspace.workspace.id); + + const staleWorkspaceRoot = join(root, "stale-conversation-workspace"); + await mkdir(staleWorkspaceRoot); + const staleWorkspace = await persistentRegistry.openWorkspace(staleWorkspaceRoot, { + conversationScopeHash: "chat-stale", }); + await rm(staleWorkspaceRoot, { recursive: true, force: true }); + const replacementWorkspace = await persistentRegistry.openWorkspace(staleWorkspaceRoot, { + conversationScopeHash: "chat-stale", + }); + assert.equal(replacementWorkspace.includeBootstrapContext, true); + assert.notEqual(replacementWorkspace.workspace.id, staleWorkspace.workspace.id); + assert.equal((await stat(staleWorkspaceRoot)).isDirectory(), true); + + const worktreeInput = { path: gitRoot, mode: "worktree" as const }; + const [persistentWorktree, concurrentWorktree] = await Promise.all([ + persistentRegistry.openWorkspace(worktreeInput, { + conversationScopeHash: "chat-worktree", + }), + persistentRegistry.openWorkspace(worktreeInput, { + conversationScopeHash: "chat-worktree", + }), + ]); + assert.equal(persistentWorktree.includeBootstrapContext, true); + assert.equal(concurrentWorktree.includeBootstrapContext, false); + assert.equal(concurrentWorktree.workspace.id, persistentWorktree.workspace.id); + assert.equal(concurrentWorktree.workspace.root, persistentWorktree.workspace.root); + assert.deepEqual(concurrentWorktree.agentsFiles, []); + assert.deepEqual(concurrentWorktree.availableAgentsFiles, []); firstStore.close(); const secondStore = new SqliteWorkspaceStore(stateDir); @@ -172,16 +214,46 @@ try { assert.equal(restoredWorkspace.root, root); assert.equal(restoredWorkspace.mode, "checkout"); + const reboundWorkspace = await restoredRegistry.openWorkspace(root, { + conversationScopeHash: "chat-checkout", + }); + assert.equal(reboundWorkspace.includeBootstrapContext, false); + assert.equal(reboundWorkspace.workspace.id, persistentWorkspace.workspace.id); + const restoredWorktree = restoredRegistry.getWorkspace(persistentWorktree.workspace.id); assert.equal(restoredWorktree.mode, "worktree"); assert.equal(restoredWorktree.sourceRoot, gitRoot); assert.equal(restoredWorktree.root, persistentWorktree.workspace.root); assert.equal(restoredWorktree.worktree?.managed, true); + + const reboundWorktree = await restoredRegistry.openWorkspace(worktreeInput, { + conversationScopeHash: "chat-worktree", + }); + assert.equal(reboundWorktree.includeBootstrapContext, false); + assert.equal(reboundWorktree.workspace.id, persistentWorktree.workspace.id); + assert.equal(reboundWorktree.workspace.root, persistentWorktree.workspace.root); secondStore.close(); if (platform() !== "win32") { const aliasRoot = join(root, "alias-root"); await symlink(root, aliasRoot, "dir"); + + const aliasStateDir = join(root, ".alias-state"); + const aliasStore = new SqliteWorkspaceStore(aliasStateDir); + const aliasRegistry = new WorkspaceRegistry(config, aliasStore); + const directConversationWorkspace = await aliasRegistry.openWorkspace(root, { + conversationScopeHash: "chat-alias", + }); + const aliasedConversationWorkspace = await aliasRegistry.openWorkspace(aliasRoot, { + conversationScopeHash: "chat-alias", + }); + assert.equal(aliasedConversationWorkspace.includeBootstrapContext, false); + assert.equal( + aliasedConversationWorkspace.workspace.id, + directConversationWorkspace.workspace.id, + ); + aliasStore.close(); + const aliasConfig = loadConfig({ DEVSPACE_ALLOWED_ROOTS: aliasRoot, DEVSPACE_WORKTREE_ROOT: join(aliasRoot, ".devspace", "alias-worktrees"), diff --git a/src/workspaces.ts b/src/workspaces.ts index e3c252f4..0d2ab5c6 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -53,6 +53,7 @@ export interface WorkspaceContext { workspace: Workspace; agentsFiles: LoadedAgentsFile[]; availableAgentsFiles: AvailableAgentsFile[]; + includeBootstrapContext: boolean; } export interface WorkspaceReadPath { @@ -67,6 +68,10 @@ export interface OpenWorkspaceInput { baseRef?: string; } +export interface OpenWorkspaceOptions { + conversationScopeHash?: string; +} + type PathStats = Stats; type DirectoryOps = { stat: (path: string) => Promise; @@ -75,14 +80,48 @@ type DirectoryOps = { export class WorkspaceRegistry { private readonly workspaces = new Map(); + private readonly pendingConversationOpens = new Map>(); constructor( private readonly config: ServerConfig, private readonly store?: WorkspaceStore, ) {} - async openWorkspace(input: string | OpenWorkspaceInput): Promise { - const options = typeof input === "string" ? { path: input } : input; + async openWorkspace( + input: string | OpenWorkspaceInput, + openOptions: OpenWorkspaceOptions = {}, + ): Promise { + const workspaceInput = typeof input === "string" ? { path: input } : input; + const conversationScopeHash = openOptions.conversationScopeHash; + if (!conversationScopeHash || !this.store) { + return this.openNewWorkspace(workspaceInput); + } + + const targetKey = await this.conversationTargetKey(workspaceInput); + const operationKey = JSON.stringify([conversationScopeHash, targetKey]); + const pending = this.pendingConversationOpens.get(operationKey); + if (pending) { + const context = await pending; + return this.reusedWorkspaceContext(context.workspace); + } + + const open = this.openConversationWorkspace( + workspaceInput, + conversationScopeHash, + targetKey, + ); + this.pendingConversationOpens.set(operationKey, open); + + try { + return await open; + } finally { + if (this.pendingConversationOpens.get(operationKey) === open) { + this.pendingConversationOpens.delete(operationKey); + } + } + } + + private async openNewWorkspace(options: OpenWorkspaceInput): Promise { const mode = options.mode ?? "checkout"; if (mode === "worktree") { @@ -92,6 +131,57 @@ export class WorkspaceRegistry { return this.openCheckoutWorkspace(options.path); } + private async openConversationWorkspace( + input: OpenWorkspaceInput, + conversationScopeHash: string, + targetKey: string, + ): Promise { + const binding = this.store?.getConversationBinding(conversationScopeHash, targetKey); + if (binding) { + try { + const workspace = this.getWorkspace(binding.workspaceSessionId); + const workspaceStats = await stat(workspace.root); + if (workspaceStats.isDirectory()) { + this.store?.touchConversationBinding(conversationScopeHash, targetKey); + return this.reusedWorkspaceContext(workspace); + } + } catch { + // The persisted workspace is no longer usable; replace its binding below. + } + + this.workspaces.delete(binding.workspaceSessionId); + this.store?.deleteConversationBinding(conversationScopeHash, targetKey); + } + + const context = await this.openNewWorkspace(input); + this.store?.setConversationBinding({ + conversationScopeHash, + targetKey, + workspaceSessionId: context.workspace.id, + }); + return context; + } + + private async conversationTargetKey(input: OpenWorkspaceInput): Promise { + const mode = input.mode ?? "checkout"; + const path = assertAllowedPath(input.path, this.config.allowedRoots); + const canonicalPath = await realpath(path).catch(() => path); + return JSON.stringify([ + mode, + canonicalPath, + mode === "worktree" ? input.baseRef ?? "HEAD" : null, + ]); + } + + private reusedWorkspaceContext(workspace: Workspace): WorkspaceContext { + return { + workspace, + agentsFiles: [], + availableAgentsFiles: [], + includeBootstrapContext: false, + }; + } + getWorkspace(workspaceId: string): Workspace { const workspace = this.workspaces.get(workspaceId); if (workspace) { @@ -228,7 +318,12 @@ export class WorkspaceRegistry { const agentsFiles = await this.loadInitialAgentsFiles(workspace.root); const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles); - return { workspace, agentsFiles, availableAgentsFiles }; + return { + workspace, + agentsFiles, + availableAgentsFiles, + includeBootstrapContext: true, + }; } private loadSkillsForWorkspace(root: string): Pick { From ff9c337fd2dba7ea86431e82bb2933e02723df7a Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 15:49:55 +0530 Subject: [PATCH 2/9] fix(workspace): keep reused cards visually stable --- docs/chatgpt-coding-workflow.md | 4 ++- src/server.ts | 53 +++++++++++++++++++++------------ src/ui/card-types.ts | 5 ++++ src/ui/tool-display.test.ts | 6 ++-- src/ui/tool-display.ts | 3 +- src/workspaces.test.ts | 24 ++++++++++++--- src/workspaces.ts | 17 +++++++---- 7 files changed, 78 insertions(+), 34 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index c068b325..5b77e782 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -24,7 +24,9 @@ same ChatGPT conversation, DevSpace returns the existing `workspaceId` and omits the project instructions, skills, subagent metadata, and diagnostics already returned by the first call. The conversation binding is persisted so reconnecting the MCP transport or restarting DevSpace does not create another managed worktree -for the same ChatGPT conversation and target. +for the same ChatGPT conversation and target. The workspace card still receives +the complete hidden display payload, so first and repeated calls render the same +workspace details without adding those fields to the model transcript again. Do not reopen the same folder unless: diff --git a/src/server.ts b/src/server.ts index a2a6a11b..e7751edc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -814,35 +814,41 @@ function createMcpServer( root: workspace.root, }); } - const visibleSkills = includeBootstrapContext ? workspace.skills + const cardSkills = workspace.skills .filter((skill) => !skill.disableModelInvocation) .map((skill) => ({ name: skill.name, description: skill.description, path: formatPathForPrompt(skill.filePath), - })) : []; - const visibleAgentProviders = includeBootstrapContext && config.subagents ? localAgentProviders : []; - const visibleAgents = includeBootstrapContext ? workspace.agentProfiles.map((profile) => { + })); + const cardAgentProviders = config.subagents ? localAgentProviders : []; + const cardAgents = workspace.agentProfiles.map((profile) => { const summary = summarizeLocalAgentProfile(profile); - const availability = visibleAgentProviders.find((provider) => provider.name === summary.provider); + const availability = cardAgentProviders.find((provider) => provider.name === summary.provider); return { ...summary, providerAvailable: availability?.available, providerUnavailableReason: availability?.reason, }; - }) : []; - const loadedAgentsFiles = includeBootstrapContext ? agentsFiles.map((file) => ({ + }); + const cardAgentsFiles = agentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), content: file.content, - })) : []; - const availableAgentsFileOutputs = includeBootstrapContext ? availableAgentsFiles.map((file) => ({ + })); + const cardAvailableAgentsFiles = availableAgentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), - })) : []; + })); + const visibleSkills = includeBootstrapContext ? cardSkills : []; + const visibleAgentProviders = includeBootstrapContext ? cardAgentProviders : []; + const visibleAgents = includeBootstrapContext ? cardAgents : []; + const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : []; + const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : []; + const cardInstruction = config.skillsEnabled + ? "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." + : "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."; const instruction = reused ? "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." - : config.skillsEnabled - ? "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." - : "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."; + : cardInstruction; const resultContent: ToolContent[] = [ { type: "text" as const, @@ -891,14 +897,23 @@ function createMcpServer( workspaceId: workspace.id, root: workspace.root, path: workspace.root, + mode: workspace.mode, + sourceRoot: workspace.sourceRoot, + worktree: workspace.worktree, + agentsFiles: cardAgentsFiles, + availableAgentsFiles: cardAvailableAgentsFiles, + skills: cardSkills, + agentProviders: cardAgentProviders, + agents: cardAgents, + skillDiagnostics: workspace.skillDiagnostics, + instruction: cardInstruction, summary: { mode: workspace.mode, - reused, - agentsFiles: loadedAgentsFiles.length, - availableAgentsFiles: availableAgentsFileOutputs.length, - skills: visibleSkills.length, - agentProviders: visibleAgentProviders.length, - agents: visibleAgents.length, + agentsFiles: cardAgentsFiles.length, + availableAgentsFiles: cardAvailableAgentsFiles.length, + skills: cardSkills.length, + agentProviders: cardAgentProviders.length, + agents: cardAgents.length, skillDiagnostics: workspace.skillDiagnostics.length, }, }, diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 1e3c9409..d50a21a7 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -23,6 +23,9 @@ export interface ToolResultCard { workspaceId?: string; path?: string; root?: string; + mode?: "checkout" | "worktree"; + sourceRoot?: string; + worktree?: Record; status?: string; summary?: Record; files?: Array<{ @@ -46,6 +49,8 @@ export interface ToolResultCard { description?: string; path?: string; }>; + agentProviders?: Array>; + agents?: Array>; skillDiagnostics?: unknown[]; instruction?: string; } diff --git a/src/ui/tool-display.test.ts b/src/ui/tool-display.test.ts index dd471795..4a401bb4 100644 --- a/src/ui/tool-display.test.ts +++ b/src/ui/tool-display.test.ts @@ -27,7 +27,7 @@ for (const [card, expected] of displayCases) { assert.equal(getToolDisplay({ tool: "open_workspace", root: "/tmp/project" }).label, "/tmp/project"); assert.equal( getToolDisplay({ tool: "open_workspace", root: "/tmp/project", summary: { reused: true } }).title, - "Reused workspace", + "Opened workspace", ); assert.equal( getToolDisplay({ tool: "grep", summary: { pattern: "needle", scope: "src" } }).label, @@ -102,9 +102,9 @@ assert.deepEqual( assert.deepEqual( getToolHeaderSummary({ tool: "open_workspace", - summary: { mode: "worktree", reused: true }, + summary: { mode: "worktree", reused: true, agentsFiles: 1, skills: 4 }, }), - { kind: "text", text: "worktree · reused" }, + { kind: "text", text: "worktree · 1 instruction · 4 skills" }, ); assert.deepEqual( diff --git a/src/ui/tool-display.ts b/src/ui/tool-display.ts index a9837580..7a847631 100644 --- a/src/ui/tool-display.ts +++ b/src/ui/tool-display.ts @@ -27,7 +27,7 @@ export function getToolDisplay(card: ToolResultCard): ToolDisplay { case "open_workspace": return { icon: toolIcons.folderOpen, - title: card.summary?.reused === true ? "Reused workspace" : "Opened workspace", + title: "Opened workspace", label: card.root ?? card.path, tone: "workspace", }; @@ -123,7 +123,6 @@ export function getToolHeaderSummary(card: ToolResultCard): ToolHeaderSummary { if (card.tool === "open_workspace") { const parts = [ typeof summary.mode === "string" ? summary.mode : undefined, - summary.reused === true ? "reused" : undefined, countLabel(summaryNumber(summary, "agentsFiles"), "instruction"), countLabel(summaryNumber(summary, "skills"), "skill"), ].filter((part): part is string => Boolean(part)); diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 22f316b7..a44d7d88 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -169,8 +169,14 @@ try { assert.equal(persistentWorkspace.includeBootstrapContext, true); assert.equal(reusedPersistentWorkspace.includeBootstrapContext, false); assert.equal(reusedPersistentWorkspace.workspace.id, persistentWorkspace.workspace.id); - assert.deepEqual(reusedPersistentWorkspace.agentsFiles, []); - assert.deepEqual(reusedPersistentWorkspace.availableAgentsFiles, []); + assert.deepEqual( + reusedPersistentWorkspace.agentsFiles.map((file) => file.content), + persistentWorkspace.agentsFiles.map((file) => file.content), + ); + assert.deepEqual( + reusedPersistentWorkspace.availableAgentsFiles, + persistentWorkspace.availableAgentsFiles, + ); const otherConversationWorkspace = await persistentRegistry.openWorkspace(root, { conversationScopeHash: "chat-checkout-other", @@ -204,8 +210,8 @@ try { assert.equal(concurrentWorktree.includeBootstrapContext, false); assert.equal(concurrentWorktree.workspace.id, persistentWorktree.workspace.id); assert.equal(concurrentWorktree.workspace.root, persistentWorktree.workspace.root); - assert.deepEqual(concurrentWorktree.agentsFiles, []); - assert.deepEqual(concurrentWorktree.availableAgentsFiles, []); + assert.deepEqual(concurrentWorktree.agentsFiles, persistentWorktree.agentsFiles); + assert.deepEqual(concurrentWorktree.availableAgentsFiles, persistentWorktree.availableAgentsFiles); firstStore.close(); const secondStore = new SqliteWorkspaceStore(stateDir); @@ -219,6 +225,15 @@ try { }); assert.equal(reboundWorkspace.includeBootstrapContext, false); assert.equal(reboundWorkspace.workspace.id, persistentWorkspace.workspace.id); + assert.deepEqual( + reboundWorkspace.agentsFiles.map((file) => file.content), + persistentWorkspace.agentsFiles.map((file) => file.content), + ); + assert.deepEqual(reboundWorkspace.availableAgentsFiles, persistentWorkspace.availableAgentsFiles); + assert.deepEqual( + reboundWorkspace.workspace.agentProfiles.map((profile) => profile.name), + persistentWorkspace.workspace.agentProfiles.map((profile) => profile.name), + ); const restoredWorktree = restoredRegistry.getWorkspace(persistentWorktree.workspace.id); assert.equal(restoredWorktree.mode, "worktree"); @@ -232,6 +247,7 @@ try { assert.equal(reboundWorktree.includeBootstrapContext, false); assert.equal(reboundWorktree.workspace.id, persistentWorktree.workspace.id); assert.equal(reboundWorktree.workspace.root, persistentWorktree.workspace.root); + assert.deepEqual(reboundWorktree.agentsFiles, persistentWorktree.agentsFiles); secondStore.close(); if (platform() !== "win32") { diff --git a/src/workspaces.ts b/src/workspaces.ts index 0d2ab5c6..77f98e41 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -102,7 +102,10 @@ export class WorkspaceRegistry { const pending = this.pendingConversationOpens.get(operationKey); if (pending) { const context = await pending; - return this.reusedWorkspaceContext(context.workspace); + return { + ...context, + includeBootstrapContext: false, + }; } const open = this.openConversationWorkspace( @@ -143,7 +146,7 @@ export class WorkspaceRegistry { const workspaceStats = await stat(workspace.root); if (workspaceStats.isDirectory()) { this.store?.touchConversationBinding(conversationScopeHash, targetKey); - return this.reusedWorkspaceContext(workspace); + return await this.reusedWorkspaceContext(workspace); } } catch { // The persisted workspace is no longer usable; replace its binding below. @@ -173,11 +176,15 @@ export class WorkspaceRegistry { ]); } - private reusedWorkspaceContext(workspace: Workspace): WorkspaceContext { + private async reusedWorkspaceContext(workspace: Workspace): Promise { + workspace.agentProfiles = await loadLocalAgentProfiles(this.config, workspace.root); + const agentsFiles = await this.loadInitialAgentsFiles(workspace.root); + const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles); + return { workspace, - agentsFiles: [], - availableAgentsFiles: [], + agentsFiles, + availableAgentsFiles, includeBootstrapContext: false, }; } From 8ea171f606f69a14dd70bc30f2c6c3e9dc104049 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 16:22:56 +0530 Subject: [PATCH 3/9] fix(review): restore checkpoints after restart --- src/review-checkpoints.test.ts | 10 +++++ src/review-checkpoints.ts | 78 ++++++++++++++++++++++++++++------ src/server.ts | 4 +- 3 files changed, 76 insertions(+), 16 deletions(-) diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index 3ec4676a..227edd0d 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -40,6 +40,16 @@ try { assert.equal(firstReview.files.some((file) => file.path === "new.txt"), true); assert.match(firstReview.patch, /world/); + const restartedManager = createReviewCheckpointManager(); + await restartedManager.initializeWorkspace({ workspaceId: "ws_review", root }); + const afterRestart = await restartedManager.reviewChanges({ + workspaceId: "ws_review", + root, + markReviewed: false, + }); + assert.equal(afterRestart.summary.files, 2); + assert.match(afterRestart.patch, /world/); + const stillUnreviewed = await manager.reviewChanges({ workspaceId: "ws_review", root, diff --git a/src/review-checkpoints.ts b/src/review-checkpoints.ts index eaa04dfa..07ef212f 100644 --- a/src/review-checkpoints.ts +++ b/src/review-checkpoints.ts @@ -48,26 +48,32 @@ const REVIEW_REF_PREFIX = "refs/devspace/review"; export function createReviewCheckpointManager(): ReviewCheckpointManager { const states = new Map(); + const initializations = new Map>(); return { async initializeWorkspace({ workspaceId, root }) { - const refs = reviewRefs(workspaceId); - const state: WorkspaceReviewState = { root, ...refs }; - states.set(workspaceId, state); + const existingState = states.get(workspaceId); + if ( + existingState?.root === root && + (existingState.gitRoot !== undefined || existingState.diagnostic !== undefined) + ) { + return; + } + + const pending = initializations.get(workspaceId); + if (pending) { + await pending; + return; + } + const initialize = initializeWorkspaceState(states, workspaceId, root); + initializations.set(workspaceId, initialize); try { - const eligibility = await getGitEligibility(root); - if (!eligibility.ok || !eligibility.gitRoot) { - state.diagnostic = eligibility.message ?? "show_changes requires a Git workspace in this version."; - return; + await initialize; + } finally { + if (initializations.get(workspaceId) === initialize) { + initializations.delete(workspaceId); } - - state.gitRoot = eligibility.gitRoot; - const commit = await createWorkingTreeSnapshot(eligibility.gitRoot); - await git(eligibility.gitRoot, ["update-ref", state.openRef, commit]); - await git(eligibility.gitRoot, ["update-ref", state.baselineRef, commit]); - } catch (error) { - state.diagnostic = error instanceof Error ? error.message : String(error); } }, @@ -111,6 +117,50 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { }; } +async function initializeWorkspaceState( + states: Map, + workspaceId: string, + root: string, +): Promise { + const refs = reviewRefs(workspaceId); + const state: WorkspaceReviewState = { root, ...refs }; + states.set(workspaceId, state); + + try { + const eligibility = await getGitEligibility(root); + if (!eligibility.ok || !eligibility.gitRoot) { + state.diagnostic = eligibility.message ?? "show_changes requires a Git workspace in this version."; + return; + } + + state.gitRoot = eligibility.gitRoot; + const [hasOpenRef, hasBaselineRef] = await Promise.all([ + hasCommitRef(eligibility.gitRoot, state.openRef), + hasCommitRef(eligibility.gitRoot, state.baselineRef), + ]); + if (hasOpenRef && hasBaselineRef) return; + + const commit = await createWorkingTreeSnapshot(eligibility.gitRoot); + if (!hasOpenRef) { + await git(eligibility.gitRoot, ["update-ref", state.openRef, commit]); + } + if (!hasBaselineRef) { + await git(eligibility.gitRoot, ["update-ref", state.baselineRef, commit]); + } + } catch (error) { + state.diagnostic = error instanceof Error ? error.message : String(error); + } +} + +async function hasCommitRef(gitRoot: string, ref: string): Promise { + try { + await git(gitRoot, ["rev-parse", "--verify", `${ref}^{commit}`]); + return true; + } catch { + return false; + } +} + function reviewRefs(workspaceId: string): Pick { const segment = safeWorkspaceRefSegment(workspaceId); return { diff --git a/src/server.ts b/src/server.ts index e7751edc..737724a0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -808,8 +808,8 @@ function createMcpServer( { conversationScopeHash: openAiConversationScopeHash(_meta) }, ); const reused = !includeBootstrapContext; - if (config.widgets === "changes" && includeBootstrapContext) { - void reviewCheckpoints.initializeWorkspace({ + if (config.widgets === "changes") { + await reviewCheckpoints.initializeWorkspace({ workspaceId: workspace.id, root: workspace.root, }); From e2121b44bf16f60c50c3079c4a5fc0a8e8341572 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 16:25:06 +0530 Subject: [PATCH 4/9] fix(ui): expose workspace card metadata --- src/ui/card-types.test.ts | 15 +++++++++++ src/ui/card-types.ts | 31 ++++++++++++++++++--- src/ui/workspace-app.tsx | 57 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/src/ui/card-types.test.ts b/src/ui/card-types.test.ts index eb47e9a0..4e0f2263 100644 --- a/src/ui/card-types.test.ts +++ b/src/ui/card-types.test.ts @@ -23,3 +23,18 @@ assert.equal( true, ); assert.equal(isExpandableCard({ tool: "apply_patch" }), false); + +assert.equal( + isExpandableCard({ + tool: "open_workspace", + agentProviders: [{ name: "codex", available: true }], + }), + true, +); +assert.equal( + isExpandableCard({ + tool: "open_workspace", + agents: [{ name: "reviewer", provider: "codex" }], + }), + true, +); diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index d50a21a7..cb3ab0fa 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -25,7 +25,14 @@ export interface ToolResultCard { root?: string; mode?: "checkout" | "worktree"; sourceRoot?: string; - worktree?: Record; + worktree?: { + path?: string; + baseRef?: string; + baseSha?: string; + dirtySource?: boolean; + detached?: boolean; + managed?: boolean; + }; status?: string; summary?: Record; files?: Array<{ @@ -49,8 +56,20 @@ export interface ToolResultCard { description?: string; path?: string; }>; - agentProviders?: Array>; - agents?: Array>; + agentProviders?: Array<{ + name?: string; + available?: boolean; + reason?: string; + }>; + agents?: Array<{ + name?: string; + description?: string; + provider?: string; + model?: string; + thinking?: string; + providerAvailable?: boolean; + providerUnavailableReason?: string; + }>; skillDiagnostics?: unknown[]; instruction?: string; } @@ -142,10 +161,16 @@ export function isExpandableCard(card: ToolResultCard): boolean { return ( Number(card.summary?.agentsFiles ?? 0) > 0 || Number(card.summary?.skills ?? 0) > 0 || + Number(card.summary?.agentProviders ?? 0) > 0 || + Number(card.summary?.agents ?? 0) > 0 || Number(card.summary?.skillDiagnostics ?? 0) > 0 || Boolean(card.agentsFiles?.length) || Boolean(card.availableAgentsFiles?.length) || Boolean(card.skills?.length) || + Boolean(card.agentProviders?.length) || + Boolean(card.agents?.length) || + Boolean(card.worktree) || + Boolean(card.instruction) || Boolean(card.skillDiagnostics?.length) ); } diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 78723864..cfeec817 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -449,23 +449,80 @@ function workspacePayloadText(card: ToolResultCard): string { const agentsFiles = card.agentsFiles ?? []; const availableAgentsFiles = card.availableAgentsFiles ?? []; const skills = card.skills ?? []; + const agentProviders = card.agentProviders ?? []; + const agents = card.agents ?? []; + const diagnostics = card.skillDiagnostics ?? []; const lines = [ card.workspaceId ? `Workspace: ${card.workspaceId}` : undefined, card.root ? `Root: ${card.root}` : undefined, + card.mode ? `Mode: ${card.mode}` : undefined, + card.sourceRoot ? `Source root: ${card.sourceRoot}` : undefined, + card.worktree ? formatWorktree(card.worktree) : undefined, skills.length > 0 ? `Skills: ${skills.map((skill) => skill.name ?? skill.path ?? "unnamed").join(", ")}` : "Skills: none", + agentProviders.length > 0 + ? `Agent providers: ${agentProviders.map(formatAgentProvider).join(", ")}` + : undefined, + agents.length > 0 + ? `Agents: ${agents.map(formatAgent).join(", ")}` + : undefined, + diagnostics.length > 0 + ? `Skill diagnostics: ${diagnostics.map(formatDiagnostic).join("; ")}` + : undefined, availableAgentsFiles.length > 0 ? `Nested instructions: ${availableAgentsFiles.map((file) => file.path ?? "unknown").join(", ")}` : undefined, agentsFiles.length > 0 ? `\n${formatAgentsFilesForPayload(agentsFiles)}` : "\nAGENTS.md: none loaded", + card.instruction ? `\nInstruction: ${card.instruction}` : undefined, ].filter((line): line is string => typeof line === "string"); return lines.join("\n"); } +function formatWorktree(worktree: NonNullable): string { + const details = [ + worktree.baseRef ? `base ${worktree.baseRef}` : undefined, + worktree.baseSha ? `at ${worktree.baseSha.slice(0, 12)}` : undefined, + worktree.managed === true ? "managed" : undefined, + worktree.detached === true ? "detached" : undefined, + worktree.dirtySource === true ? "dirty source" : undefined, + ].filter((detail): detail is string => Boolean(detail)); + return `Worktree: ${worktree.path ?? "unknown"}${details.length > 0 ? ` (${details.join(", ")})` : ""}`; +} + +function formatAgentProvider( + provider: NonNullable[number], +): string { + const name = provider.name ?? "unknown"; + if (provider.available !== false) return name; + return provider.reason ? `${name} unavailable: ${provider.reason}` : `${name} unavailable`; +} + +function formatAgent(agent: NonNullable[number]): string { + const details = [ + agent.provider, + agent.model, + agent.thinking ? `thinking ${agent.thinking}` : undefined, + agent.providerAvailable === false + ? agent.providerUnavailableReason ?? "provider unavailable" + : undefined, + ].filter((detail): detail is string => Boolean(detail)); + return `${agent.name ?? "unnamed"}${details.length > 0 ? ` (${details.join(", ")})` : ""}`; +} + +function formatDiagnostic(diagnostic: unknown): string { + if (typeof diagnostic === "string") return diagnostic; + if (diagnostic instanceof Error) return diagnostic.message; + try { + return JSON.stringify(diagnostic); + } catch { + return String(diagnostic); + } +} + function formatAgentsFilesForPayload( agentsFiles: NonNullable, ): string { From 44c096969d1d1ab8145ba389e3c60c524a3ff1f3 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 16:26:15 +0530 Subject: [PATCH 5/9] test(workspace): make reuse assertions deterministic --- src/ui/tool-display.test.ts | 4 ++-- src/workspace-store.ts | 11 ++++++++--- src/workspaces.test.ts | 12 +++++++++--- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/ui/tool-display.test.ts b/src/ui/tool-display.test.ts index 4a401bb4..35780509 100644 --- a/src/ui/tool-display.test.ts +++ b/src/ui/tool-display.test.ts @@ -26,7 +26,7 @@ for (const [card, expected] of displayCases) { assert.equal(getToolDisplay({ tool: "open_workspace", root: "/tmp/project" }).label, "/tmp/project"); assert.equal( - getToolDisplay({ tool: "open_workspace", root: "/tmp/project", summary: { reused: true } }).title, + getToolDisplay({ tool: "open_workspace", root: "/tmp/project" }).title, "Opened workspace", ); assert.equal( @@ -102,7 +102,7 @@ assert.deepEqual( assert.deepEqual( getToolHeaderSummary({ tool: "open_workspace", - summary: { mode: "worktree", reused: true, agentsFiles: 1, skills: 4 }, + summary: { mode: "worktree", agentsFiles: 1, skills: 4 }, }), { kind: "text", text: "worktree · 1 instruction · 4 skills" }, ); diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 95bd282e..1d535f54 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -147,7 +147,7 @@ export class SqliteWorkspaceStore implements WorkspaceStore { workspaceSessionId: string; }): WorkspaceConversationBinding { const now = new Date().toISOString(); - this.database.db + const row = this.database.db .insert(workspaceConversationBindings) .values({ conversationScopeHash: input.conversationScopeHash, @@ -166,9 +166,14 @@ export class SqliteWorkspaceStore implements WorkspaceStore { lastUsedAt: now, }, }) - .run(); + .returning() + .get(); + + if (!row) { + throw new Error("Conversation workspace binding upsert returned no row."); + } - return this.getConversationBinding(input.conversationScopeHash, input.targetKey)!; + return rowToWorkspaceConversationBinding(row); } touchConversationBinding(conversationScopeHash: string, targetKey: string): void { diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index a44d7d88..ba05cdcb 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -206,12 +206,18 @@ try { conversationScopeHash: "chat-worktree", }), ]); - assert.equal(persistentWorktree.includeBootstrapContext, true); - assert.equal(concurrentWorktree.includeBootstrapContext, false); assert.equal(concurrentWorktree.workspace.id, persistentWorktree.workspace.id); assert.equal(concurrentWorktree.workspace.root, persistentWorktree.workspace.root); + const concurrentWorktreeOpens = [persistentWorktree, concurrentWorktree]; + assert.equal( + concurrentWorktreeOpens.filter((open) => open.includeBootstrapContext).length, + 1, + ); assert.deepEqual(concurrentWorktree.agentsFiles, persistentWorktree.agentsFiles); - assert.deepEqual(concurrentWorktree.availableAgentsFiles, persistentWorktree.availableAgentsFiles); + assert.deepEqual( + concurrentWorktree.availableAgentsFiles, + persistentWorktree.availableAgentsFiles, + ); firstStore.close(); const secondStore = new SqliteWorkspaceStore(stateDir); From f7fc11bbcaa7d2db1b5c665c8aea3f55d9dcaacb Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 16:34:35 +0530 Subject: [PATCH 6/9] test(ui): remove duplicate workspace assertions --- src/ui/tool-display.test.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/ui/tool-display.test.ts b/src/ui/tool-display.test.ts index 35780509..b9977ac8 100644 --- a/src/ui/tool-display.test.ts +++ b/src/ui/tool-display.test.ts @@ -25,10 +25,6 @@ for (const [card, expected] of displayCases) { } assert.equal(getToolDisplay({ tool: "open_workspace", root: "/tmp/project" }).label, "/tmp/project"); -assert.equal( - getToolDisplay({ tool: "open_workspace", root: "/tmp/project" }).title, - "Opened workspace", -); assert.equal( getToolDisplay({ tool: "grep", summary: { pattern: "needle", scope: "src" } }).label, "needle in src", @@ -92,13 +88,6 @@ assert.deepEqual( { kind: "diff", additions: 14, removals: 1 }, ); -assert.deepEqual( - getToolHeaderSummary({ - tool: "open_workspace", - summary: { mode: "worktree", agentsFiles: 1, skills: 4 }, - }), - { kind: "text", text: "worktree · 1 instruction · 4 skills" }, -); assert.deepEqual( getToolHeaderSummary({ tool: "open_workspace", From fef52508ec7cd495ecbea186f308ed14590e36d6 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 19:41:39 +0530 Subject: [PATCH 7/9] feat(workspace): track project bootstrap delivery --- src/db/migrations.ts | 56 +++++++++++++++++++++++++++++++++++++++++ src/db/schema.ts | 15 +++++++++++ src/oauth-store.test.ts | 1 + src/workspace-store.ts | 31 +++++++++++++++++++++++ 4 files changed, 103 insertions(+) diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 24fb891c..47887eb5 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -27,6 +27,11 @@ const migrations: Migration[] = [ name: "workspace-conversation-bindings", up: migrateWorkspaceConversationBindings, }, + { + version: 5, + name: "workspace-conversation-bootstraps", + up: migrateWorkspaceConversationBootstraps, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -198,6 +203,57 @@ function migrateWorkspaceConversationBindings(sqlite: Database.Database): void { `); } +function migrateWorkspaceConversationBootstraps(sqlite: Database.Database): void { + sqlite.exec(` + create table if not exists workspace_conversation_bootstraps ( + conversation_scope_hash text not null, + project_key text not null, + created_at text not null, + last_used_at text not null, + primary key (conversation_scope_hash, project_key) + ); + `); + + const bindings = sqlite.prepare(` + select conversation_scope_hash, target_key, created_at, last_used_at + from workspace_conversation_bindings + `).all() as Array<{ + conversation_scope_hash: string; + target_key: string; + created_at: string; + last_used_at: string; + }>; + const insertBootstrap = sqlite.prepare(` + insert or ignore into workspace_conversation_bootstraps ( + conversation_scope_hash, + project_key, + created_at, + last_used_at + ) values (?, ?, ?, ?) + `); + + for (const binding of bindings) { + const projectKey = projectKeyFromConversationTarget(binding.target_key); + if (!projectKey) continue; + insertBootstrap.run( + binding.conversation_scope_hash, + projectKey, + binding.created_at, + binding.last_used_at, + ); + } +} + +function projectKeyFromConversationTarget(targetKey: string): string | undefined { + try { + const parsed = JSON.parse(targetKey) as unknown; + if (!Array.isArray(parsed)) return undefined; + return typeof parsed[1] === "string" && parsed[1].length > 0 ? parsed[1] : undefined; + } catch { + return undefined; + } +} + function addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions" | "local_agent_sessions", diff --git a/src/db/schema.ts b/src/db/schema.ts index 06a8b844..7d557e97 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -55,6 +55,19 @@ export const workspaceConversationBindings = sqliteTable( ], ); +export const workspaceConversationBootstraps = sqliteTable( + "workspace_conversation_bootstraps", + { + conversationScopeHash: text("conversation_scope_hash").notNull(), + projectKey: text("project_key").notNull(), + createdAt: text("created_at").notNull(), + lastUsedAt: text("last_used_at").notNull(), + }, + (table) => [ + primaryKey({ columns: [table.conversationScopeHash, table.projectKey] }), + ], +); + export const oauthClients = sqliteTable( "oauth_clients", { @@ -120,5 +133,7 @@ export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect; export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$inferInsert; export type WorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferSelect; export type NewWorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferInsert; +export type WorkspaceConversationBootstrapRow = typeof workspaceConversationBootstraps.$inferSelect; +export type NewWorkspaceConversationBootstrapRow = typeof workspaceConversationBootstraps.$inferInsert; export type LocalAgentSessionRow = typeof localAgentSessions.$inferSelect; export type NewLocalAgentSessionRow = typeof localAgentSessions.$inferInsert; diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index e47f8121..fe69797f 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -45,6 +45,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 2, name: "oauth-state" }, { version: 3, name: "local-agent-sessions" }, { version: 4, name: "workspace-conversation-bindings" }, + { version: 5, name: "workspace-conversation-bootstraps" }, ]); } finally { database.close(); diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 1d535f54..5fb66169 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -1,6 +1,7 @@ import { and, eq } from "drizzle-orm"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { + workspaceConversationBootstraps, workspaceConversationBindings, workspaceSessions, type WorkspaceConversationBindingRow, @@ -53,6 +54,7 @@ export interface WorkspaceStore { }): WorkspaceConversationBinding; touchConversationBinding(conversationScopeHash: string, targetKey: string): void; deleteConversationBinding(conversationScopeHash: string, targetKey: string): void; + claimConversationBootstrap(conversationScopeHash: string, projectKey: string): boolean; close?(): void; } @@ -201,6 +203,35 @@ export class SqliteWorkspaceStore implements WorkspaceStore { .run(); } + claimConversationBootstrap(conversationScopeHash: string, projectKey: string): boolean { + const now = new Date().toISOString(); + const [inserted] = this.database.db + .insert(workspaceConversationBootstraps) + .values({ + conversationScopeHash, + projectKey, + createdAt: now, + lastUsedAt: now, + }) + .onConflictDoNothing() + .returning() + .all(); + + if (inserted) return true; + + this.database.db + .update(workspaceConversationBootstraps) + .set({ lastUsedAt: now }) + .where( + and( + eq(workspaceConversationBootstraps.conversationScopeHash, conversationScopeHash), + eq(workspaceConversationBootstraps.projectKey, projectKey), + ), + ) + .run(); + return false; + } + close(): void { this.database.close(); } From 540d51ce0dc8ad4bc607aae48ba84b4a19507222 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 19:45:14 +0530 Subject: [PATCH 8/9] fix(workspace): always create requested worktrees --- src/workspaces.test.ts | 63 ++++++++++++++++++++++++++++++-------- src/workspaces.ts | 69 +++++++++++++++++++++++++++++------------- 2 files changed, 99 insertions(+), 33 deletions(-) diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index ba05cdcb..6b3355cd 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -167,7 +167,9 @@ try { conversationScopeHash: "chat-checkout", }); assert.equal(persistentWorkspace.includeBootstrapContext, true); + assert.equal(persistentWorkspace.workspaceReused, false); assert.equal(reusedPersistentWorkspace.includeBootstrapContext, false); + assert.equal(reusedPersistentWorkspace.workspaceReused, true); assert.equal(reusedPersistentWorkspace.workspace.id, persistentWorkspace.workspace.id); assert.deepEqual( reusedPersistentWorkspace.agentsFiles.map((file) => file.content), @@ -182,6 +184,7 @@ try { conversationScopeHash: "chat-checkout-other", }); assert.equal(otherConversationWorkspace.includeBootstrapContext, true); + assert.equal(otherConversationWorkspace.workspaceReused, false); assert.notEqual(otherConversationWorkspace.workspace.id, persistentWorkspace.workspace.id); const staleWorkspaceRoot = join(root, "stale-conversation-workspace"); @@ -193,30 +196,61 @@ try { const replacementWorkspace = await persistentRegistry.openWorkspace(staleWorkspaceRoot, { conversationScopeHash: "chat-stale", }); - assert.equal(replacementWorkspace.includeBootstrapContext, true); + assert.equal(replacementWorkspace.includeBootstrapContext, false); + assert.equal(replacementWorkspace.workspaceReused, false); assert.notEqual(replacementWorkspace.workspace.id, staleWorkspace.workspace.id); assert.equal((await stat(staleWorkspaceRoot)).isDirectory(), true); const worktreeInput = { path: gitRoot, mode: "worktree" as const }; + const projectCheckout = await persistentRegistry.openWorkspace(gitRoot, { + conversationScopeHash: "chat-project-modes", + }); + const firstProjectWorktree = await persistentRegistry.openWorkspace(worktreeInput, { + conversationScopeHash: "chat-project-modes", + }); + const secondProjectWorktree = await persistentRegistry.openWorkspace(worktreeInput, { + conversationScopeHash: "chat-project-modes", + }); + const reusedProjectCheckout = await persistentRegistry.openWorkspace(gitRoot, { + conversationScopeHash: "chat-project-modes", + }); + assert.equal(projectCheckout.includeBootstrapContext, true); + assert.equal(projectCheckout.workspaceReused, false); + assert.equal(firstProjectWorktree.includeBootstrapContext, false); + assert.equal(firstProjectWorktree.workspaceReused, false); + assert.equal(secondProjectWorktree.includeBootstrapContext, false); + assert.equal(secondProjectWorktree.workspaceReused, false); + assert.notEqual(firstProjectWorktree.workspace.id, projectCheckout.workspace.id); + assert.notEqual(firstProjectWorktree.workspace.id, secondProjectWorktree.workspace.id); + assert.notEqual(firstProjectWorktree.workspace.root, secondProjectWorktree.workspace.root); + assert.equal(reusedProjectCheckout.workspace.id, projectCheckout.workspace.id); + assert.equal(reusedProjectCheckout.workspaceReused, true); + assert.equal(reusedProjectCheckout.includeBootstrapContext, false); + const [persistentWorktree, concurrentWorktree] = await Promise.all([ persistentRegistry.openWorkspace(worktreeInput, { - conversationScopeHash: "chat-worktree", + conversationScopeHash: "chat-worktree-concurrent", }), persistentRegistry.openWorkspace(worktreeInput, { - conversationScopeHash: "chat-worktree", + conversationScopeHash: "chat-worktree-concurrent", }), ]); - assert.equal(concurrentWorktree.workspace.id, persistentWorktree.workspace.id); - assert.equal(concurrentWorktree.workspace.root, persistentWorktree.workspace.root); + assert.notEqual(concurrentWorktree.workspace.id, persistentWorktree.workspace.id); + assert.notEqual(concurrentWorktree.workspace.root, persistentWorktree.workspace.root); + assert.equal(persistentWorktree.workspaceReused, false); + assert.equal(concurrentWorktree.workspaceReused, false); const concurrentWorktreeOpens = [persistentWorktree, concurrentWorktree]; assert.equal( concurrentWorktreeOpens.filter((open) => open.includeBootstrapContext).length, 1, ); - assert.deepEqual(concurrentWorktree.agentsFiles, persistentWorktree.agentsFiles); assert.deepEqual( - concurrentWorktree.availableAgentsFiles, - persistentWorktree.availableAgentsFiles, + concurrentWorktree.agentsFiles.map((file) => file.content), + persistentWorktree.agentsFiles.map((file) => file.content), + ); + assert.deepEqual( + concurrentWorktree.availableAgentsFiles.map((file) => file.path.replace(concurrentWorktree.workspace.root, "")), + persistentWorktree.availableAgentsFiles.map((file) => file.path.replace(persistentWorktree.workspace.root, "")), ); firstStore.close(); @@ -230,6 +264,7 @@ try { conversationScopeHash: "chat-checkout", }); assert.equal(reboundWorkspace.includeBootstrapContext, false); + assert.equal(reboundWorkspace.workspaceReused, true); assert.equal(reboundWorkspace.workspace.id, persistentWorkspace.workspace.id); assert.deepEqual( reboundWorkspace.agentsFiles.map((file) => file.content), @@ -248,12 +283,16 @@ try { assert.equal(restoredWorktree.worktree?.managed, true); const reboundWorktree = await restoredRegistry.openWorkspace(worktreeInput, { - conversationScopeHash: "chat-worktree", + conversationScopeHash: "chat-worktree-concurrent", }); assert.equal(reboundWorktree.includeBootstrapContext, false); - assert.equal(reboundWorktree.workspace.id, persistentWorktree.workspace.id); - assert.equal(reboundWorktree.workspace.root, persistentWorktree.workspace.root); - assert.deepEqual(reboundWorktree.agentsFiles, persistentWorktree.agentsFiles); + assert.equal(reboundWorktree.workspaceReused, false); + assert.notEqual(reboundWorktree.workspace.id, persistentWorktree.workspace.id); + assert.notEqual(reboundWorktree.workspace.root, persistentWorktree.workspace.root); + assert.deepEqual( + reboundWorktree.agentsFiles.map((file) => file.content), + persistentWorktree.agentsFiles.map((file) => file.content), + ); secondStore.close(); if (platform() !== "win32") { diff --git a/src/workspaces.ts b/src/workspaces.ts index 77f98e41..b68030c5 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -53,6 +53,7 @@ export interface WorkspaceContext { workspace: Workspace; agentsFiles: LoadedAgentsFile[]; availableAgentsFiles: AvailableAgentsFile[]; + workspaceReused: boolean; includeBootstrapContext: boolean; } @@ -80,7 +81,7 @@ type DirectoryOps = { export class WorkspaceRegistry { private readonly workspaces = new Map(); - private readonly pendingConversationOpens = new Map>(); + private readonly pendingCheckoutOpens = new Map>(); constructor( private readonly config: ServerConfig, @@ -97,29 +98,44 @@ export class WorkspaceRegistry { return this.openNewWorkspace(workspaceInput); } - const targetKey = await this.conversationTargetKey(workspaceInput); + const projectKey = await this.conversationProjectKey(workspaceInput); + const mode = workspaceInput.mode ?? "checkout"; + if (mode === "worktree") { + const context = await this.openWorktreeWorkspace(workspaceInput.path, workspaceInput.baseRef); + return { + ...context, + includeBootstrapContext: this.store.claimConversationBootstrap( + conversationScopeHash, + projectKey, + ), + }; + } + + const targetKey = this.conversationCheckoutTargetKey(projectKey); const operationKey = JSON.stringify([conversationScopeHash, targetKey]); - const pending = this.pendingConversationOpens.get(operationKey); + const pending = this.pendingCheckoutOpens.get(operationKey); if (pending) { const context = await pending; return { ...context, + workspaceReused: true, includeBootstrapContext: false, }; } - const open = this.openConversationWorkspace( + const open = this.openConversationCheckout( workspaceInput, conversationScopeHash, targetKey, + projectKey, ); - this.pendingConversationOpens.set(operationKey, open); + this.pendingCheckoutOpens.set(operationKey, open); try { return await open; } finally { - if (this.pendingConversationOpens.get(operationKey) === open) { - this.pendingConversationOpens.delete(operationKey); + if (this.pendingCheckoutOpens.get(operationKey) === open) { + this.pendingCheckoutOpens.delete(operationKey); } } } @@ -134,10 +150,11 @@ export class WorkspaceRegistry { return this.openCheckoutWorkspace(options.path); } - private async openConversationWorkspace( + private async openConversationCheckout( input: OpenWorkspaceInput, conversationScopeHash: string, targetKey: string, + projectKey: string, ): Promise { const binding = this.store?.getConversationBinding(conversationScopeHash, targetKey); if (binding) { @@ -146,7 +163,10 @@ export class WorkspaceRegistry { const workspaceStats = await stat(workspace.root); if (workspaceStats.isDirectory()) { this.store?.touchConversationBinding(conversationScopeHash, targetKey); - return await this.reusedWorkspaceContext(workspace); + return await this.reusedWorkspaceContext( + workspace, + this.store?.claimConversationBootstrap(conversationScopeHash, projectKey) ?? true, + ); } } catch { // The persisted workspace is no longer usable; replace its binding below. @@ -156,27 +176,32 @@ export class WorkspaceRegistry { this.store?.deleteConversationBinding(conversationScopeHash, targetKey); } - const context = await this.openNewWorkspace(input); + const context = await this.openCheckoutWorkspace(input.path); this.store?.setConversationBinding({ conversationScopeHash, targetKey, workspaceSessionId: context.workspace.id, }); - return context; + return { + ...context, + includeBootstrapContext: + this.store?.claimConversationBootstrap(conversationScopeHash, projectKey) ?? true, + }; } - private async conversationTargetKey(input: OpenWorkspaceInput): Promise { - const mode = input.mode ?? "checkout"; + private async conversationProjectKey(input: OpenWorkspaceInput): Promise { const path = assertAllowedPath(input.path, this.config.allowedRoots); - const canonicalPath = await realpath(path).catch(() => path); - return JSON.stringify([ - mode, - canonicalPath, - mode === "worktree" ? input.baseRef ?? "HEAD" : null, - ]); + return await realpath(path).catch(() => path); } - private async reusedWorkspaceContext(workspace: Workspace): Promise { + private conversationCheckoutTargetKey(projectKey: string): string { + return JSON.stringify(["checkout", projectKey, null]); + } + + private async reusedWorkspaceContext( + workspace: Workspace, + includeBootstrapContext: boolean, + ): Promise { workspace.agentProfiles = await loadLocalAgentProfiles(this.config, workspace.root); const agentsFiles = await this.loadInitialAgentsFiles(workspace.root); const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles); @@ -185,7 +210,8 @@ export class WorkspaceRegistry { workspace, agentsFiles, availableAgentsFiles, - includeBootstrapContext: false, + workspaceReused: true, + includeBootstrapContext, }; } @@ -329,6 +355,7 @@ export class WorkspaceRegistry { workspace, agentsFiles, availableAgentsFiles, + workspaceReused: false, includeBootstrapContext: true, }; } From f5ab680271e7febb22e5b651b61c425dcebc14e6 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 19:48:17 +0530 Subject: [PATCH 9/9] fix(server): separate workspace reuse from bootstrap --- docs/chatgpt-coding-workflow.md | 33 ++++++++++++++--------- src/oauth-store.test.ts | 47 +++++++++++++++++++++++++++++++++ src/server.ts | 21 ++++++++------- src/workspaces.test.ts | 19 +++++++++++++ 4 files changed, 99 insertions(+), 21 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 5b77e782..5b3c71d1 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -19,21 +19,25 @@ and shell calls should reuse that same `workspaceId`. ChatGPT sends an anonymized conversation identifier in `_meta["openai/session"]`. DevSpace uses that value only as a correlation scope: -if `open_workspace` is called again for the same path, mode, and base ref in the -same ChatGPT conversation, DevSpace returns the existing `workspaceId` and omits -the project instructions, skills, subagent metadata, and diagnostics already -returned by the first call. The conversation binding is persisted so reconnecting -the MCP transport or restarting DevSpace does not create another managed worktree -for the same ChatGPT conversation and target. The workspace card still receives -the complete hidden display payload, so first and repeated calls render the same -workspace details without adding those fields to the model transcript again. - -Do not reopen the same folder unless: +if checkout mode is called again for the same canonical project path in the same +ChatGPT conversation, DevSpace returns the existing checkout `workspaceId`. +Worktree mode is deliberately different: every call creates a new managed +worktree and a new workspace session, even for the same path and base ref. + +Project bootstrap delivery is tracked separately from workspace reuse. The first +open for a canonical project path in a ChatGPT conversation returns project +instructions, skills, subagent metadata, and diagnostics. Later checkout or +worktree opens for that project omit those fields from the model response, even +when a new worktree workspace is created. This state is persisted across MCP +reconnects and DevSpace restarts. The workspace card still receives the complete +hidden display payload, so every call renders full workspace details without +adding the bootstrap fields to the model transcript again. + +Do not reopen the same checkout folder unless: - the `workspaceId` is rejected as unknown - the user switches to another folder -- the user switches between checkout and worktree mode -- the user explicitly asks to reopen +- the user asks for a new isolated worktree ## Checkout Mode @@ -67,6 +71,11 @@ Managed worktrees are created under: Worktree mode requires a Git repository with at least one commit. It starts from `HEAD` unless `baseRef` is provided. +Each worktree-mode call creates a new managed worktree and returns a new +`workspaceId`. Reuse that ID for work inside that worktree; call +`open_workspace` in worktree mode again only when another isolated worktree is +actually required. + Uncommitted source checkout changes are not copied into the managed worktree. DevSpace reports when the source checkout was dirty so the model can decide how to proceed with the user. diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index fe69797f..2aadaf1e 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -21,6 +21,7 @@ const redirectUri = "https://chatgpt.com/connector_platform_oauth_redirect"; try { await testDatabaseConfiguration(join(root, "database-configuration")); + testConversationBootstrapMigration(join(root, "bootstrap-migration")); testPersistenceAndTokenHashing(join(root, "persistence")); testExpiredTokenCleanup(join(root, "expiration")); testTransactionalTokenRotation(join(root, "rotation")); @@ -29,6 +30,52 @@ try { await rm(root, { recursive: true, force: true }); } +function testConversationBootstrapMigration(stateDir: string): void { + const initial = openDatabase(stateDir); + try { + initial.sqlite.prepare(` + insert into workspace_sessions ( + id, root, status, mode, managed, created_at, last_used_at + ) values (?, ?, 'active', 'worktree', 'true', ?, ?) + `).run("ws_existing", "/tmp/project-worktree", "2026-01-01T00:00:00.000Z", "2026-01-02T00:00:00.000Z"); + initial.sqlite.prepare(` + insert into workspace_conversation_bindings ( + conversation_scope_hash, target_key, workspace_session_id, created_at, last_used_at + ) values (?, ?, ?, ?, ?) + `).run( + "chat-existing", + JSON.stringify(["worktree", "/tmp/project", "HEAD"]), + "ws_existing", + "2026-01-01T00:00:00.000Z", + "2026-01-02T00:00:00.000Z", + ); + initial.sqlite.exec(` + drop table workspace_conversation_bootstraps; + delete from devspace_schema_migrations where version = 5; + `); + } finally { + initial.close(); + } + + const migrated = openDatabase(stateDir); + try { + assert.deepEqual( + migrated.sqlite.prepare(` + select conversation_scope_hash, project_key, created_at, last_used_at + from workspace_conversation_bootstraps + `).all(), + [{ + conversation_scope_hash: "chat-existing", + project_key: "/tmp/project", + created_at: "2026-01-01T00:00:00.000Z", + last_used_at: "2026-01-02T00:00:00.000Z", + }], + ); + } finally { + migrated.close(); + } +} + async function testDatabaseConfiguration(stateDir: string): Promise { const database = openDatabase(stateDir); try { diff --git a/src/server.ts b/src/server.ts index 737724a0..f1930e82 100644 --- a/src/server.ts +++ b/src/server.ts @@ -752,7 +752,7 @@ function createMcpServer( { title: "Open workspace", description: - "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.", + "Open a local project directory as a coding workspace. Call this before reading, editing, searching, writing, showing changes, or running commands, then reuse the returned workspaceId. In ChatGPT, checkout mode reuses the existing checkout workspace for the same project and conversation. Every worktree-mode call creates a new managed worktree and workspace. After the first open for a project in one ChatGPT conversation, later opens omit bootstrap details already returned. By default this opens the actual checkout; set mode=\"worktree\" when the user asks for a new isolated or parallel coding session.", inputSchema: { path: z .string() @@ -763,7 +763,7 @@ function createMcpServer( .enum(["checkout", "worktree"]) .optional() .describe( - "Defaults to checkout. Use checkout to work in the actual directory. Use worktree to create an isolated managed Git worktree for parallel work.", + "Defaults to checkout. Use checkout to work in the actual directory. Each worktree-mode call creates a new isolated managed Git worktree and workspace for parallel work.", ), baseRef: z .string() @@ -802,12 +802,13 @@ function createMcpServer( workspace, agentsFiles, availableAgentsFiles, + workspaceReused, includeBootstrapContext, } = await workspaces.openWorkspace( { path, mode, baseRef }, { conversationScopeHash: openAiConversationScopeHash(_meta) }, ); - const reused = !includeBootstrapContext; + const bootstrapOmitted = !includeBootstrapContext; if (config.widgets === "changes") { await reviewCheckpoints.initializeWorkspace({ workspaceId: workspace.id, @@ -846,18 +847,20 @@ function createMcpServer( const cardInstruction = config.skillsEnabled ? "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." : "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."; - const instruction = reused - ? "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." - : cardInstruction; + const instruction = includeBootstrapContext + ? cardInstruction + : workspaceReused + ? "Reuse this workspaceId for subsequent tool calls. Project instructions, nested instruction paths, skills, subagent metadata, and diagnostics for this project were already returned earlier in this ChatGPT conversation and are intentionally omitted here." + : "Use this new workspaceId for subsequent tool calls. Project instructions, nested instruction paths, skills, subagent metadata, and diagnostics for this project were already returned earlier in this ChatGPT conversation and are intentionally omitted here."; const resultContent: ToolContent[] = [ { type: "text" as const, text: [ - `${reused ? "Workspace already open as" : "Opened workspace"} ${workspace.id}`, + `${workspaceReused ? "Workspace already open as" : "Opened workspace"} ${workspace.id}`, `Root: ${workspace.root}`, `Mode: ${workspace.mode}`, - reused - ? "Bootstrap details omitted because they were already returned in this ChatGPT conversation." + bootstrapOmitted + ? "Project bootstrap details omitted because they were already returned for this project in this ChatGPT conversation." : undefined, loadedAgentsFiles.length > 0 ? `Loaded project instructions: ${loadedAgentsFiles.map((file) => file.path).join(", ")}` diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 6b3355cd..2c4963fd 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -227,6 +227,25 @@ try { assert.equal(reusedProjectCheckout.workspaceReused, true); assert.equal(reusedProjectCheckout.includeBootstrapContext, false); + const worktreeFirst = await persistentRegistry.openWorkspace(worktreeInput, { + conversationScopeHash: "chat-worktree-first", + }); + const checkoutAfterWorktree = await persistentRegistry.openWorkspace(gitRoot, { + conversationScopeHash: "chat-worktree-first", + }); + const reusedCheckoutAfterWorktree = await persistentRegistry.openWorkspace(gitRoot, { + conversationScopeHash: "chat-worktree-first", + }); + assert.equal(worktreeFirst.includeBootstrapContext, true); + assert.equal(worktreeFirst.workspaceReused, false); + assert.equal(checkoutAfterWorktree.includeBootstrapContext, false); + assert.equal(checkoutAfterWorktree.workspaceReused, false); + assert.equal(checkoutAfterWorktree.workspace.mode, "checkout"); + assert.notEqual(checkoutAfterWorktree.workspace.id, worktreeFirst.workspace.id); + assert.equal(reusedCheckoutAfterWorktree.includeBootstrapContext, false); + assert.equal(reusedCheckoutAfterWorktree.workspaceReused, true); + assert.equal(reusedCheckoutAfterWorktree.workspace.id, checkoutAfterWorktree.workspace.id); + const [persistentWorktree, concurrentWorktree] = await Promise.all([ persistentRegistry.openWorkspace(worktreeInput, { conversationScopeHash: "chat-worktree-concurrent",