diff --git a/AGENTS.md b/AGENTS.md index fa13d508..fa934058 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ These ideas should stay true as the project evolves: - **Host** — the MCP client presenting the agent experience and coordinating work. - **Server** — the local DevSpace MCP server. - **Workspace** — one opened directory or worktree and its accumulated instruction context. -- **`workspaceId`** — the opaque handle returned by `open_workspace` and reused for calls in that workspace. +- **`workspace_id`** — the opaque handle returned by `open_workspace` and reused for calls in that workspace. - **Allowed root** — a configured filesystem boundary within which a workspace may be opened. It is not itself necessarily a workspace. - **Checkout mode** — operating on an existing checkout supplied by the user. - **Worktree mode** — operating in an isolated Git worktree. @@ -115,4 +115,4 @@ Start at the boundary named by the problem and follow the data. Keep policy in D - Preserve host and provider data unless DevSpace has a concrete reason to normalize it. - Add compatibility behavior only for an identified consumer with a real upgrade path. - Reuse glossary terms in schemas, types, documentation, and errors. -- Keep the execution layer small, reliable, and unsurprising. \ No newline at end of file +- Keep the execution layer small, reliable, and unsurprising. diff --git a/docs/artifact-exchange.md b/docs/artifact-exchange.md index f4728eb0..bdbf7087 100644 --- a/docs/artifact-exchange.md +++ b/docs/artifact-exchange.md @@ -8,19 +8,19 @@ directly into an open workspace. Enable the tool with ```text open_workspace - -> download_artifact({ file, workspaceId, path }) + -> download_artifact({ file, workspace_id, path }) -> { path } ``` 1. Open the project with `open_workspace`. -2. Pass the host-provided native `file`, the returned `workspaceId`, and an +2. Pass the host-provided native `file`, the returned `workspace_id`, and an unused workspace-relative `path` to `download_artifact`. 3. Use the returned path with the ordinary DevSpace filesystem tools. ```text download_artifact({ file: , - workspaceId: "ws_123", + workspace_id: "ws_123", path: "public/images/generated-image.png" }) ``` diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 7d53fa39..a78edb76 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -14,18 +14,18 @@ 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`. +The result includes a `workspace_id`. All later file, search, edit, show-changes, +and shell calls should reuse that same `workspace_id`. ChatGPT may support automatic checkout recovery through optional host conversation metadata. This is an OpenAI-host adapter detail, not a standard MCP conversation field. When that optional context is available, opening the same checkout project again in the same conversation can continue in the existing workspace, and the context already provided for that reused checkout is not -repeated. The portable workflow remains the same: keep using the `workspaceId` +repeated. The portable workflow remains the same: keep using the `workspace_id` returned by `open_workspace` for later operations. Hosts without supported conversation context receive a normal new workspace and continue with that -explicit `workspaceId` workflow. +explicit `workspace_id` workflow. The model receives actionable workspace instructions; automatic-reuse bookkeeping is not a model-facing choice. @@ -43,7 +43,7 @@ own context. Do not call `open_workspace` again for the same checkout folder unless: -- the `workspaceId` is rejected as unknown +- the `workspace_id` is rejected as unknown - work moves to a different project folder - work switches between checkout and worktree mode - the user asks for a new isolated worktree @@ -78,10 +78,10 @@ Managed worktrees are created under: ``` Worktree mode requires a Git repository with at least one commit. It starts from -`HEAD` unless `baseRef` is provided. +`HEAD` unless `base_ref` 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 +`workspace_id`. Reuse that ID for work inside that worktree; call `open_workspace` in worktree mode again only when another isolated worktree is actually required. diff --git a/docs/gotchas.md b/docs/gotchas.md index a43a59d6..7ef3b3d2 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -137,19 +137,19 @@ To regenerate setup: npx @waishnav/devspace init --force ``` -## Unknown `workspaceId` +## Unknown `workspace_id` -`workspaceId` values are session identifiers. If the server restarts and the +`workspace_id` values are session identifiers. If the server restarts and the client receives an unknown workspace error, call `open_workspace` again for that project. Workspace session metadata is persisted. ChatGPT may provide optional conversation metadata that lets DevSpace resume the same checkout workspace for -the same project in that conversation; repeated opens reuse the `workspaceId` +the same project in that conversation; repeated opens reuse the `workspace_id` and do not repeat context already provided for that reused checkout. Worktree mode always creates a new isolated workspace with its own complete context. Hosts without supported conversation metadata receive a normal new workspace. -In all cases, continue passing the `workspaceId` returned by `open_workspace` to +In all cases, continue passing the `workspace_id` returned by `open_workspace` to later tools. Other MCP hosts use this explicit workspace workflow as well. To review work, call `show_changes` once after the final related file change. It @@ -186,7 +186,7 @@ Worktree mode requires: - Git installed - the path is inside a Git repository - the repository has at least one commit -- the requested `baseRef` resolves to a commit +- the requested `base_ref` resolves to a commit For a new repository, create the first commit or use checkout mode. diff --git a/docs/security.md b/docs/security.md index 69bbc130..13efd7d0 100644 --- a/docs/security.md +++ b/docs/security.md @@ -96,7 +96,7 @@ sessions. Native file download is an opt-in, one-shot transfer into an already-open workspace. `download_artifact` accepts the MCP host's native file value, the -`workspaceId` returned by `open_workspace`, and an unused relative destination +`workspace_id` returned by `open_workspace`, and an unused relative destination path. It returns only the workspace-relative path and does not create a persistent artifact service or reusable artifact ID. diff --git a/src/artifact-download.test.ts b/src/artifact-download.test.ts index c49aad84..0e1fe443 100644 --- a/src/artifact-download.test.ts +++ b/src/artifact-download.test.ts @@ -74,7 +74,7 @@ function testOneToolContract(): void { const descriptor = registered.get("download_artifact")?.descriptor; assert.ok(descriptor); assert.deepEqual(descriptor._meta, { "openai/fileParams": ["file"] }); - assert.deepEqual(Object.keys(descriptor.inputSchema as object).sort(), ["file", "path", "workspaceId"]); + assert.deepEqual(Object.keys(descriptor.inputSchema as object).sort(), ["file", "path", "workspace_id"]); assert.deepEqual(Object.keys(descriptor.outputSchema as object), ["path"]); assert.equal((descriptor.annotations as { destructiveHint?: boolean }).destructiveHint, false); @@ -353,7 +353,7 @@ function testLogRedaction(): void { file_name: "generated.png", authorization: "Bearer log-secret", }, - workspaceId: "ws_secret", + workspace_id: "ws_secret", path: "private/generated.png", }); const serialized = JSON.stringify(fields); diff --git a/src/artifact-tools.ts b/src/artifact-tools.ts index e7fc35df..400f1626 100644 --- a/src/artifact-tools.ts +++ b/src/artifact-tools.ts @@ -103,8 +103,8 @@ export function registerArtifactTools( file: openAIFileReferenceInputSchema.describe( "Native file value authorized and supplied by the MCP host.", ), - workspaceId: z.string().min(1).describe( - "Workspace to use. Reuse the current project's workspaceId.", + workspace_id: z.string().min(1).describe( + "Workspace to use. Reuse the current project's workspace_id.", ), path: z.string().min(1).describe( "Relative destination path inside the selected workspace. The destination must not already exist.", @@ -117,7 +117,7 @@ export function registerArtifactTools( annotations: ARTIFACT_WRITE_ANNOTATIONS, }, async (input) => executeArtifactTool(config, input, async () => { - const workspace = workspaces.getWorkspace(input.workspaceId); + const workspace = workspaces.getWorkspace(input.workspace_id); const downloaded = await downloadIncomingArtifact({ registry: incomingRegistry, workspaceId: workspace.id, @@ -292,7 +292,7 @@ export function artifactToolLogFields( fileProvided: input.file !== undefined, fileReferenceShape: describeIncomingArtifactValue(input.file), downloadUrlHostname: incomingFileDownloadHostname(input.file), - workspaceId: input.workspaceId, + workspaceId: input.workspace_id, path: input.path, }; } diff --git a/src/server.test.ts b/src/server.test.ts index e31bad24..71243e34 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { access, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test, { type TestContext } from "node:test"; @@ -49,6 +49,100 @@ test("tool modes expose the expected host-facing tool surface", async (t) => { } }); +test("model-facing tool inputs use snake_case recursively", async (t) => { + for (const toolMode of ["claude", "codex"] as const) { + await t.test(toolMode, async (nested) => { + const context = await fixture(nested, { toolMode, uiEnabled: false }); + const tools = await context.client.listTools(); + const invalidPaths = tools.tools.flatMap((tool) => ( + schemaPropertyPaths(tool.inputSchema) + .filter(({ key }) => !/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/.test(key)) + .map(({ path }) => `${tool.name}.${path}`) + )); + + assert.deepEqual(invalidPaths, []); + }); + } +}); + +test("Codex process tools accept snake_case session and yield inputs", async (t) => { + const context = await fixture(t, { toolMode: "codex", uiEnabled: false }); + const workspaceId = structuredContent( + await callOpen(context.client, context.project, "snake-case-process"), + ).workspace_id; + assert.equal(typeof workspaceId, "string"); + + const started = structuredContent(await context.client.callTool({ + name: "exec_command", + arguments: { + workspace_id: workspaceId, + cmd: 'node -e "setTimeout(() => {}, 500)"', + yield_time_ms: 0, + }, + })); + assert.equal(started.running, true); + assert.equal(typeof started.session_id, "number"); + assert.equal("sessionId" in started, false); + + const finished = structuredContent(await context.client.callTool({ + name: "write_stdin", + arguments: { + workspace_id: workspaceId, + session_id: started.session_id, + yield_time_ms: 2_000, + }, + })); + assert.equal(finished.running, false); + assert.equal(finished.exitCode, 0); +}); + +test("open_workspace instructions use workspace_id", async (t) => { + const context = await fixture(t, { toolMode: "codex", uiEnabled: false }); + const first = structuredContent( + await callOpen(context.client, context.project, "snake-case-instructions"), + ); + const repeated = structuredContent( + await callOpen(context.client, context.project, "snake-case-instructions"), + ); + + assert.match(first.instruction as string, /workspace_id/); + assert.match(repeated.instruction as string, /workspace_id/); + assert.doesNotMatch(first.instruction as string, /workspaceId/); + assert.doesNotMatch(repeated.instruction as string, /workspaceId/); +}); + +test("Claude edit and bash tools accept snake_case runtime inputs", async (t) => { + const context = await fixture(t, { toolMode: "claude", uiEnabled: false }); + const workspaceId = structuredContent( + await callOpen(context.client, context.project, "snake-case-claude"), + ).workspace_id; + assert.equal(typeof workspaceId, "string"); + + await writeFile(join(context.project, "note.txt"), "before\n"); + await mkdir(join(context.project, "nested")); + + const edited = await context.client.callTool({ + name: "edit", + arguments: { + workspace_id: workspaceId, + path: "note.txt", + edits: [{ old_text: "before", new_text: "after" }], + }, + }); + assert.equal(edited.isError, undefined); + assert.equal(await readFile(join(context.project, "note.txt"), "utf8"), "after\n"); + + const shell = structuredContent(await context.client.callTool({ + name: "bash", + arguments: { + workspace_id: workspaceId, + command: "pwd", + working_directory: "nested", + }, + })); + assert.match(shell.result as string, /nested/i); +}); + test("UI metadata is limited to workspace and aggregate review", async (t) => { for (const uiEnabled of [true, false]) { await t.test(uiEnabled ? "enabled" : "disabled", async (nested) => { @@ -80,18 +174,19 @@ test("show_changes keeps model output compact and preserves the rich review card const opened = structuredContent( await callOpen(context.client, context.project, "review"), ); - const workspaceId = opened.workspaceId; + const workspaceId = opened.workspace_id; assert.equal(typeof workspaceId, "string"); await writeFile(join(context.project, "README.md"), "goodbye\n"); const review = await context.client.callTool({ name: "show_changes", - arguments: { workspaceId }, + arguments: { workspace_id: workspaceId }, }); const structured = structuredContent(review); assert.equal((review._meta as Record | undefined)?.tool, undefined); - assert.equal(structured.workspaceId, workspaceId); + assert.equal(structured.workspace_id, workspaceId); + assert.equal("workspaceId" in structured, false); assert.match(structured.reviewRef as string, /^[0-9a-f]{40,64}$/); assert.equal("summary" in structured, false); assert.equal("files" in structured, false); @@ -119,7 +214,8 @@ test("show_changes keeps model output compact and preserves the rich review card const tools = await context.client.listTools(); const outputProperties = tools.tools.find((tool) => tool.name === "show_changes") ?.outputSchema?.properties; - assert.ok(outputProperties && "workspaceId" in outputProperties); + assert.ok(outputProperties && "workspace_id" in outputProperties); + assert.equal(outputProperties && "workspaceId" in outputProperties, false); assert.ok(outputProperties && "reviewRef" in outputProperties); assert.equal(outputProperties && "summary" in outputProperties, false); assert.equal(outputProperties && "files" in outputProperties, false); @@ -133,13 +229,13 @@ test("show_changes can reopen a historical review without advancing the checkpoi const context = await fixture(t, { git: true }); const workspaceId = structuredContent( await callOpen(context.client, context.project, "review-history"), - ).workspaceId; + ).workspace_id; assert.equal(typeof workspaceId, "string"); await writeFile(join(context.project, "README.md"), "first\n"); const first = structuredContent(await context.client.callTool({ name: "show_changes", - arguments: { workspaceId }, + arguments: { workspace_id: workspaceId }, })); const reviewRef = first.reviewRef; assert.equal(typeof reviewRef, "string"); @@ -147,7 +243,7 @@ test("show_changes can reopen a historical review without advancing the checkpoi await writeFile(join(context.project, "README.md"), "second\n"); const reopened = await context.client.callTool({ name: "show_changes", - arguments: { workspaceId }, + arguments: { workspace_id: workspaceId }, _meta: { "devspace/reviewRef": reviewRef }, } as Parameters[0]); assert.equal(structuredContent(reopened).reviewRef, reviewRef); @@ -158,7 +254,7 @@ test("show_changes can reopen a historical review without advancing the checkpoi const current = await context.client.callTool({ name: "show_changes", - arguments: { workspaceId }, + arguments: { workspace_id: workspaceId }, }); assert.match( (((responseCard(current).payload as { patch?: string } | undefined)?.patch) ?? ""), @@ -179,6 +275,8 @@ test("open_workspace keeps lifecycle flags out of model output and preserves com const tools = await context.client.listTools(); const openTool = tools.tools.find((tool) => tool.name === "open_workspace"); const outputProperties = (openTool?.outputSchema as { properties?: Record } | undefined)?.properties; + assert.ok(outputProperties && "workspace_id" in outputProperties); + assert.equal(outputProperties && "workspaceId" in outputProperties, false); assert.equal(outputProperties && "workspaceReused" in outputProperties, false); assert.equal(outputProperties && "includeBootstrapContext" in outputProperties, false); const providerSchema = outputProperties?.agentProviders as { @@ -187,7 +285,9 @@ test("open_workspace keeps lifecycle flags out of model output and preserves com assert.ok(providerSchema?.items?.properties?.note); const firstStructured = structuredContent(first); - assert.equal(firstStructured.workspaceId, structuredContent(repeated).workspaceId); + assert.equal(typeof firstStructured.workspace_id, "string"); + assert.equal("workspaceId" in firstStructured, false); + assert.equal(firstStructured.workspace_id, structuredContent(repeated).workspace_id); assert.ok(Array.isArray(firstStructured.agentsFiles)); assert.ok(Array.isArray(firstStructured.availableAgentsFiles)); assert.ok(Array.isArray(firstStructured.skills)); @@ -308,10 +408,10 @@ test("open_workspace scopes checkout reuse to OpenAI session metadata", async (t const otherSession = await callOpen(context.client, context.project, "chat-2"); const unscoped = await callOpen(context.client, context.project); - assert.equal(structuredContent(repeated).workspaceId, structuredContent(first).workspaceId); + assert.equal(structuredContent(repeated).workspace_id, structuredContent(first).workspace_id); assert.equal(structuredContent(repeated).agentsFiles, undefined); - assert.notEqual(structuredContent(otherSession).workspaceId, structuredContent(first).workspaceId); - assert.notEqual(structuredContent(unscoped).workspaceId, structuredContent(first).workspaceId); + assert.notEqual(structuredContent(otherSession).workspace_id, structuredContent(first).workspace_id); + assert.notEqual(structuredContent(unscoped).workspace_id, structuredContent(first).workspace_id); assert.ok(Array.isArray(structuredContent(otherSession).agentsFiles)); assert.ok(Array.isArray(structuredContent(unscoped).agentsFiles)); }); @@ -366,9 +466,9 @@ test("HTTP endpoint serves modern MCP and stateless legacy clients", async (t) = ); assert.equal(called.status, 200, await called.clone().text()); const callBody = await called.json() as { - result?: { structuredContent?: { workspaceId?: string; agentsFiles?: unknown[] } }; + result?: { structuredContent?: { workspace_id?: string; agentsFiles?: unknown[] } }; }; - const workspaceId = callBody.result?.structuredContent?.workspaceId; + const workspaceId = callBody.result?.structuredContent?.workspace_id; assert.equal(typeof workspaceId, "string"); const repeated = await postModernMcp( @@ -383,9 +483,9 @@ test("HTTP endpoint serves modern MCP and stateless legacy clients", async (t) = ); assert.equal(repeated.status, 200, await repeated.clone().text()); const repeatedBody = await repeated.json() as { - result?: { structuredContent?: { workspaceId?: string; agentsFiles?: unknown[] } }; + result?: { structuredContent?: { workspace_id?: string; agentsFiles?: unknown[] } }; }; - assert.equal(repeatedBody.result?.structuredContent?.workspaceId, workspaceId); + assert.equal(repeatedBody.result?.structuredContent?.workspace_id, workspaceId); assert.equal(repeatedBody.result?.structuredContent?.agentsFiles, undefined); const legacy = await fetch(`${localBaseUrl}/mcp`, { @@ -445,9 +545,9 @@ test("server shutdown waits for an active MCP tool call", async (t) => { }, ); const openBody = await opened.json() as { - result?: { structuredContent?: { workspaceId?: string } }; + result?: { structuredContent?: { workspace_id?: string } }; }; - const workspaceId = openBody.result?.structuredContent?.workspaceId; + const workspaceId = openBody.result?.structuredContent?.workspace_id; assert.equal(typeof workspaceId, "string"); const command = [ @@ -462,9 +562,9 @@ test("server shutdown waits for an active MCP tool call", async (t) => { { name: "exec_command", arguments: { - workspaceId, + workspace_id: workspaceId, cmd: `node -e \"${command}\"`, - yieldTimeMs: 30_000, + yield_time_ms: 30_000, }, }, ); @@ -488,6 +588,31 @@ interface ServerFixture { project: string; } +function schemaPropertyPaths( + schema: unknown, + prefix = "", +): Array<{ key: string; path: string }> { + if (!schema || typeof schema !== "object") return []; + const record = schema as { + properties?: Record; + items?: unknown; + anyOf?: unknown[]; + oneOf?: unknown[]; + allOf?: unknown[]; + }; + const paths = Object.entries(record.properties ?? {}).flatMap(([key, child]) => { + const path = prefix ? `${prefix}.${key}` : key; + return [{ key, path }, ...schemaPropertyPaths(child, path)]; + }); + if (record.items) paths.push(...schemaPropertyPaths(record.items, `${prefix}[]`)); + for (const variant of [record.anyOf, record.oneOf, record.allOf]) { + for (const child of variant ?? []) { + paths.push(...schemaPropertyPaths(child, prefix)); + } + } + return paths; +} + interface HttpServerFixture { root: string; localBaseUrl: string; diff --git a/src/server.ts b/src/server.ts index 68a74bc3..94fee3a6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -80,7 +80,7 @@ function mcpServerInfo() { title: "DevSpace", version: "0.1.0", description: - "Coding tools for project workspaces. Open each project or worktree once, then reuse its workspaceId.", + "Coding tools for project workspaces. Open each project or worktree once, then reuse its workspace_id.", }; } @@ -133,7 +133,7 @@ function serverInstructions( ? `When ${toolNames.openWorkspace} returns available skills and a task matches a skill, use ${toolNames.read} to read that skill's path before proceeding. Skill paths may be outside the workspace, and ${toolNames.read} permits files within advertised skill directories. ` : ""; const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it. `; - const common = `Use DevSpace for coding work. Call ${toolNames.openWorkspace} once for each project folder or isolated worktree, then keep using its workspaceId. During continued work in the same project or worktree, do not call ${toolNames.openWorkspace} again. Open another workspace only when changing projects, switching checkout/worktree mode, creating another isolated worktree, or when the current workspaceId is rejected.`; + const common = `Use DevSpace for coding work. Call ${toolNames.openWorkspace} once for each project folder or isolated worktree, then keep using its workspace_id. During continued work in the same project or worktree, do not call ${toolNames.openWorkspace} again. Open another workspace only when changing projects, switching checkout/worktree mode, creating another isolated worktree, or when the current workspace_id is rejected.`; return `${common} ${toolSurface.instructions({ agents, skills })}${artifactInstruction}${showChangesInstruction}`; } @@ -386,7 +386,7 @@ function registerMcpSurface( { title: "Open workspace", description: - "Start work in a project directory or isolated worktree when no usable workspaceId exists for it. During continued work, reuse the existing workspaceId instead of calling this tool again. By default this uses the actual checkout; set mode=\"worktree\" for isolated or parallel work.", + "Start work in a project directory or isolated worktree when no usable workspace_id exists for it. During continued work, reuse the existing workspace_id instead of calling this tool again. By default this uses the actual checkout; set mode=\"worktree\" for isolated or parallel work.", inputSchema: { path: z .string() @@ -399,13 +399,13 @@ function registerMcpSurface( .describe( "Defaults to checkout, which works in the actual directory. Use worktree for isolated or parallel Git work.", ), - baseRef: z + base_ref: z .string() .optional() .describe("Git ref to base a worktree on. Only used with mode=\"worktree\". Defaults to HEAD."), }, outputSchema: { - workspaceId: z.string(), + workspace_id: z.string(), root: z.string(), mode: z.enum(["checkout", "worktree"]), sourceRoot: z.string().optional(), @@ -437,8 +437,9 @@ function registerMcpSurface( ...workspaceAppDescriptorMeta(config), annotations: { readOnlyHint: true }, }, - async ({ path, mode, baseRef }, { _meta }) => { + async ({ path, mode, base_ref }, { _meta }) => { const startedAt = performance.now(); + const baseRef = base_ref; const { workspace, agentsFiles, @@ -494,16 +495,16 @@ function registerMcpSurface( const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : []; const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : []; const cardInstruction = config.skillsEnabled - ? "Use this workspaceId for subsequent work in this project. Keep reusing it while working in this project. 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 for subsequent work in this project. Keep reusing it while working in this project. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; + ? "Use this workspace_id for subsequent work in this project. Keep reusing it while working in this project. 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 workspace_id for subsequent work in this project. Keep reusing it while working in this project. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; const workspaceInstruction = workspaceReused ? [ `Workspace already open as ${workspace.id}.`, - "Continue with this workspaceId.", + "Continue with this workspace_id.", "Keep following the project instructions, nested instruction files, skills, agent profiles, and diagnostics already provided for this workspace.", ].join("\n\n") : workspace.mode === "worktree" - ? "Use this workspaceId for subsequent work in this isolated worktree. Keep reusing it while working in this worktree. Follow the project instructions, nested instruction files, skills, agent profiles, and diagnostics returned for it." + ? "Use this workspace_id for subsequent work in this isolated worktree. Keep reusing it while working in this worktree. Follow the project instructions, nested instruction files, skills, agent profiles, and diagnostics returned for it." : cardInstruction; const instruction = preloadedSubagentInstructions && includeBootstrapContext ? [ @@ -580,7 +581,7 @@ function registerMcpSurface( }, }, structuredContent: { - workspaceId: workspace.id, + workspace_id: workspace.id, root: workspace.root, mode: workspace.mode, sourceRoot: workspace.sourceRoot, @@ -617,7 +618,7 @@ function registerMcpSurface( .filter(Boolean) .join(" "), inputSchema: { - workspaceId: z + workspace_id: z .string() .describe(workspaceIdDescription), path: z @@ -643,8 +644,9 @@ function registerMcpSurface( outputSchema: resultOutputSchema(), annotations: { readOnlyHint: true }, }, - async ({ workspaceId, ...input }) => { + async ({ workspace_id, ...input }) => { const startedAt = performance.now(); + const workspaceId = workspace_id; const workspace = workspaces.getWorkspace(workspaceId); const readPath = workspaces.resolveReadPath(workspace, input.path); const response = await readFileTool( @@ -697,17 +699,18 @@ function registerMcpSurface( description: "Show the changes made in this turn for an open workspace. Call this once after the final related file change and before your final response so the user can review the combined diff. Do not call it after each individual file change.", inputSchema: { - workspaceId: z.string().describe(workspaceIdDescription), + workspace_id: z.string().describe(workspaceIdDescription), }, outputSchema: resultOutputSchema({ - workspaceId: z.string(), + workspace_id: z.string(), reviewRef: z.string().regex(/^[0-9a-f]{40,64}$/), }), ...workspaceAppDescriptorMeta(config), annotations: { readOnlyHint: true }, }, - async ({ workspaceId }, { _meta }) => { + async ({ workspace_id }, { _meta }) => { const startedAt = performance.now(); + const workspaceId = workspace_id; const workspace = workspaces.getWorkspace(workspaceId); const reviewRef = typeof _meta?.["devspace/reviewRef"] === "string" ? _meta["devspace/reviewRef"] @@ -745,7 +748,7 @@ function registerMcpSurface( }, }, structuredContent: { - workspaceId, + workspace_id: workspaceId, reviewRef: review.reviewRef, result: contentText(content), }, diff --git a/src/tool-surfaces/claude.ts b/src/tool-surfaces/claude.ts index 4fc21f22..b6d7c2e8 100644 --- a/src/tool-surfaces/claude.ts +++ b/src/tool-surfaces/claude.ts @@ -47,7 +47,7 @@ function registerClaudeMutationTools(context: ToolRegistrationContext): void { title: "Write file", description: `Create or completely overwrite a file in a workspace. Prefer ${toolNames.edit} for targeted changes to existing files.`, inputSchema: { - workspaceId: z.string().describe(workspaceIdDescription), + workspace_id: z.string().describe(workspaceIdDescription), path: z .string() .describe("File path to write, relative to the workspace root."), @@ -56,8 +56,9 @@ function registerClaudeMutationTools(context: ToolRegistrationContext): void { outputSchema: resultOutputSchema(), annotations: WRITE_TOOL_ANNOTATIONS, }, - async ({ workspaceId, ...input }) => { + async ({ workspace_id, ...input }) => { const startedAt = performance.now(); + const workspaceId = workspace_id; const workspace = workspaces.getWorkspace(workspaceId); workspaces.resolvePath(workspace, input.path); const response = await writeFileTool(input, { @@ -100,21 +101,21 @@ function registerClaudeMutationTools(context: ToolRegistrationContext): void { toolNames.edit, { title: "Edit file", - description: `Edit one file in a workspace by replacing exact text blocks. Prefer this over ${toolNames.write} for targeted changes. Each oldText must match a unique, non-overlapping region of the original file; merge nearby changes into one edit and keep oldText as small as possible while still unique.`, + description: `Edit one file in a workspace by replacing exact text blocks. Prefer this over ${toolNames.write} for targeted changes. Each old_text must match a unique, non-overlapping region of the original file; merge nearby changes into one edit and keep old_text as small as possible while still unique.`, inputSchema: { - workspaceId: z.string().describe(workspaceIdDescription), + workspace_id: z.string().describe(workspaceIdDescription), path: z .string() .describe("File path to edit, relative to the workspace root."), edits: z .array( z.object({ - oldText: z + old_text: z .string() .describe( "Exact text to replace. Must match uniquely in the original file.", ), - newText: z.string().describe("Replacement text."), + new_text: z.string().describe("Replacement text."), }), ) .min(1), @@ -124,11 +125,18 @@ function registerClaudeMutationTools(context: ToolRegistrationContext): void { }), annotations: EDIT_TOOL_ANNOTATIONS, }, - async ({ workspaceId, ...input }) => { + async ({ workspace_id, edits, ...input }) => { const startedAt = performance.now(); + const workspaceId = workspace_id; const workspace = workspaces.getWorkspace(workspaceId); workspaces.resolvePath(workspace, input.path); - const response = await editFileTool(input, { + const response = await editFileTool({ + ...input, + edits: edits.map(({ old_text, new_text }) => ({ + oldText: old_text, + newText: new_text, + })), + }, { cwd: workspace.root, root: workspace.root, }); @@ -180,11 +188,11 @@ function registerShellTool(context: ToolRegistrationContext): void { title: "Bash", description: CLAUDE_SHELL_DESCRIPTION, inputSchema: { - workspaceId: z.string().describe(workspaceIdDescription), + workspace_id: z.string().describe(workspaceIdDescription), command: z .string() .describe("Shell command to execute."), - workingDirectory: z + working_directory: z .string() .optional() .describe( @@ -200,8 +208,10 @@ function registerShellTool(context: ToolRegistrationContext): void { outputSchema: resultOutputSchema(), annotations: SHELL_TOOL_ANNOTATIONS, }, - async ({ workspaceId, workingDirectory, ...input }) => { + async ({ workspace_id, working_directory, ...input }) => { const startedAt = performance.now(); + const workspaceId = workspace_id; + const workingDirectory = working_directory; const workspace = workspaces.getWorkspace(workspaceId); const cwd = workspaces.resolveWorkingDirectory( workspace, diff --git a/src/tool-surfaces/codex.ts b/src/tool-surfaces/codex.ts index 526e175b..1f3f1927 100644 --- a/src/tool-surfaces/codex.ts +++ b/src/tool-surfaces/codex.ts @@ -47,7 +47,7 @@ function processResult(snapshot: ProcessSnapshot): string { function processOutputSchema(): z.ZodRawShape { return resultOutputSchema({ - sessionId: z.number().optional(), + session_id: z.number().optional(), running: z.boolean(), exitCode: z.number().int().optional(), signal: z.string().optional(), @@ -63,7 +63,7 @@ function processToolResponse(snapshot: ProcessSnapshot) { content, structuredContent: { result, - sessionId: snapshot.sessionId, + session_id: snapshot.sessionId, running: snapshot.running, exitCode: snapshot.exitCode, signal: snapshot.signal, @@ -83,7 +83,7 @@ function registerApplyPatchTool(context: ToolRegistrationContext): void { description: "Apply one Codex-style patch in a workspace. Supports adding, overwriting, updating, deleting, and moving files. Use this for all file modifications. Paths must be relative to the workspace.", inputSchema: { - workspaceId: z.string().describe(workspaceIdDescription), + workspace_id: z.string().describe(workspaceIdDescription), patch: z .string() .describe( @@ -103,8 +103,9 @@ function registerApplyPatchTool(context: ToolRegistrationContext): void { }), annotations: EDIT_TOOL_ANNOTATIONS, }, - async ({ workspaceId, patch }) => { + async ({ workspace_id, patch }) => { const startedAt = performance.now(); + const workspaceId = workspace_id; const applied = await runLoggedToolOperation( config, { tool: "apply_patch", workspaceId }, @@ -139,9 +140,9 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { { title: "Execute command", description: - "Run a command with the local user's authority. Commands are not sandboxed; workspace validation only selects the initial working directory. Returns the result when it exits during the yield window, otherwise returns a sessionId for write_stdin. Use this for file inspection, tests, builds, package scripts, and long-running processes.", + "Run a command with the local user's authority. Commands are not sandboxed; workspace validation only selects the initial working directory. Returns the result when it exits during the yield window, otherwise returns a session_id for write_stdin. Use this for file inspection, tests, builds, package scripts, and long-running processes.", inputSchema: { - workspaceId: z.string().describe(workspaceIdDescription), + workspace_id: z.string().describe(workspaceIdDescription), cmd: z.string().min(1).describe("Shell command to execute."), tty: z .boolean() @@ -163,13 +164,13 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { .max(1_000) .optional() .describe("Initial PTY height. Defaults to 24."), - workingDirectory: z + working_directory: z .string() .optional() .describe( "Working directory relative to the workspace root. Defaults to the workspace root.", ), - yieldTimeMs: z + yield_time_ms: z .number() .int() .min(0) @@ -178,7 +179,7 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { .describe( "Milliseconds to wait before returning a running session. Defaults to 10000.", ), - maxOutputTokens: z + max_output_tokens: z .number() .int() .positive() @@ -190,16 +191,20 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { annotations: SHELL_TOOL_ANNOTATIONS, }, async ({ - workspaceId, + workspace_id, cmd, tty, columns, rows, - workingDirectory, - yieldTimeMs, - maxOutputTokens, + working_directory, + yield_time_ms, + max_output_tokens, }) => { const startedAt = performance.now(); + const workspaceId = workspace_id; + const workingDirectory = working_directory; + const yieldTimeMs = yield_time_ms; + const maxOutputTokens = max_output_tokens; const snapshot = await runLoggedToolOperation( config, { @@ -241,10 +246,10 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { description: "Poll or write characters to a process returned by exec_command. Omit chars or pass an empty string to poll. Pass \\u0003 to send Ctrl-C.", inputSchema: { - workspaceId: z + workspace_id: z .string() .describe("Workspace identifier used to start the process."), - sessionId: z + session_id: z .number() .describe("Process session identifier returned by exec_command."), chars: z @@ -267,7 +272,7 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { .max(1_000) .optional() .describe("Resize a PTY to this height."), - yieldTimeMs: z + yield_time_ms: z .number() .int() .min(0) @@ -276,7 +281,7 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { .describe( "Milliseconds to wait for process output or completion. Defaults to 10000.", ), - maxOutputTokens: z + max_output_tokens: z .number() .int() .positive() @@ -288,15 +293,19 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { annotations: SHELL_TOOL_ANNOTATIONS, }, async ({ - workspaceId, - sessionId, + workspace_id, + session_id, chars, columns, rows, - yieldTimeMs, - maxOutputTokens, + yield_time_ms, + max_output_tokens, }) => { const startedAt = performance.now(); + const workspaceId = workspace_id; + const sessionId = session_id; + const yieldTimeMs = yield_time_ms; + const maxOutputTokens = max_output_tokens; const snapshot = await runLoggedToolOperation( config, { tool: "write_stdin", workspaceId }, diff --git a/src/tool-surfaces/types.ts b/src/tool-surfaces/types.ts index bdf48598..62f1add6 100644 --- a/src/tool-surfaces/types.ts +++ b/src/tool-surfaces/types.ts @@ -14,7 +14,7 @@ export const toolNames = { } as const; export const workspaceIdDescription = - "Workspace to use. Reuse the current project's workspaceId."; + "Workspace to use. Reuse the current project's workspace_id."; export const WRITE_TOOL_ANNOTATIONS = { readOnlyHint: false, diff --git a/src/ui/tool-result.test.ts b/src/ui/tool-result.test.ts index b7c5315d..b2feecec 100644 --- a/src/ui/tool-result.test.ts +++ b/src/ui/tool-result.test.ts @@ -10,7 +10,7 @@ test("workspace cards can be rebuilt from structured content without result meta const decoded = decodeToolResult({ content: [], structuredContent: { - workspaceId: "ws_1", + workspace_id: "ws_1", root: "/tmp/project", mode: "checkout", skills: [{ name: "tdd", description: "Tests first", path: "/tmp/tdd/SKILL.md" }], @@ -32,7 +32,7 @@ test("review results use rich metadata when the host provides it", () => { const decoded = decodeToolResult({ content: [], structuredContent: { - workspaceId: "ws_1", + workspace_id: "ws_1", reviewRef: "a".repeat(40), result: "Changed 1 file (+1 -0).", }, @@ -57,7 +57,7 @@ test("review structured content becomes a reload reference when metadata is miss const decoded = decodeToolResult({ content: [], structuredContent: { - workspaceId: "ws_1", + workspace_id: "ws_1", reviewRef: "b".repeat(40), result: "Changed 1 file (+1 -0).", }, @@ -74,7 +74,7 @@ test("incomplete review metadata falls back to the durable review reference", () const decoded = decodeToolResult({ content: [], structuredContent: { - workspaceId: "ws_1", + workspace_id: "ws_1", reviewRef: "e".repeat(40), result: "Changed 1 file (+1 -0).", }, @@ -114,7 +114,7 @@ test("ChatGPT globals restore structured output and hidden MCP result metadata t }; const restored = toolResultFromChatGptGlobals({ toolOutput: { - workspaceId: "ws_1", + workspace_id: "ws_1", reviewRef: "c".repeat(40), result: "Changed 1 file.", }, @@ -124,7 +124,7 @@ test("ChatGPT globals restore structured output and hidden MCP result metadata t }); assert.deepEqual(restored?.structuredContent, { - workspaceId: "ws_1", + workspace_id: "ws_1", reviewRef: "c".repeat(40), result: "Changed 1 file.", }); @@ -134,7 +134,7 @@ test("ChatGPT globals restore structured output and hidden MCP result metadata t test("ChatGPT globals also accept result metadata exposed directly", () => { const restored = toolResultFromChatGptGlobals({ toolOutput: { - workspaceId: "ws_1", + workspace_id: "ws_1", reviewRef: "d".repeat(40), result: "Changed 1 file.", }, diff --git a/src/ui/tool-result.ts b/src/ui/tool-result.ts index efd1bdb4..aa62c088 100644 --- a/src/ui/tool-result.ts +++ b/src/ui/tool-result.ts @@ -16,7 +16,7 @@ export function decodeToolResult(result: CallToolResult): DecodedToolResult { const metaCard = cardFields(asRecord(asRecord(result._meta)?.card)); if (structured) { - const workspaceId = stringField(structured.workspaceId); + const workspaceId = stringField(structured.workspace_id); const reviewRef = stringField(structured.reviewRef); if (workspaceId && reviewRef) { if (isCompleteReviewCard(metaCard)) { diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 27d4f5b8..a04f48be 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -208,7 +208,7 @@ async function reopenReview( return app.callServerTool({ name: "show_changes", - arguments: { workspaceId }, + arguments: { workspace_id: workspaceId }, _meta: { "devspace/reviewRef": reviewRef }, }); }