From 7c0259e8ac6b5e2f09e72c04e19e6e988a0f334a Mon Sep 17 00:00:00 2001 From: luo-xingyu <3194190064@qq.com> Date: Tue, 8 Sep 2026 16:41:29 +0800 Subject: [PATCH] fix: use snake_case for model-facing MCP contracts --- AGENTS.md | 4 +- docs/artifact-exchange.md | 6 +- docs/chatgpt-coding-workflow.md | 20 ++-- docs/gotchas.md | 12 +-- docs/security.md | 2 +- src/artifact-download.test.ts | 4 +- src/artifact-tools.ts | 8 +- src/server.test.ts | 172 ++++++++++++++++++++++++-------- src/server.ts | 80 ++++++++------- src/tool-surfaces/claude.ts | 32 ++++-- src/tool-surfaces/codex.ts | 70 +++++++------ src/tool-surfaces/types.ts | 2 +- src/ui/tool-result.test.ts | 28 +++--- src/ui/tool-result.ts | 35 ++++++- src/ui/workspace-app.tsx | 2 +- 15 files changed, 313 insertions(+), 164 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fa13d5081..fa9340585 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 f4728eb0d..bdbf70873 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 7d53fa397..cb0d73528 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. @@ -98,7 +98,7 @@ When a workspace opens, DevSpace loads root-level instruction files: - `CLAUDE.md` - `CLAUDE.MD` -Nested instruction files are returned as `availableAgentsFiles`. The model +Nested instruction files are returned as `available_agents_files`. The model should read the relevant nested file before working under that directory. This keeps instructions explicit and inspectable instead of silently injecting @@ -199,10 +199,10 @@ the review point automatically. Reusing a workspace does not change this workflow. The model-facing result stays compact: DevSpace returns the workspace ID, a -Git-backed `reviewRef`, and the summary text. MCP Apps hosts receive the full +Git-backed `review_ref`, and the summary text. MCP Apps hosts receive the full file list and patch in result metadata for immediate rendering. If a host later restores only the structured result, the review card can reopen that exact -`reviewRef` from DevSpace's Git review history without advancing the current +`review_ref` from DevSpace's Git review history without advancing the current review point. For local inspection, run `devspace show-changes `. Add `--json` to diff --git a/docs/gotchas.md b/docs/gotchas.md index a43a59d68..f34b0b051 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. @@ -274,7 +274,7 @@ metadata and only show text results; `show_changes` remains available there. If both cards are missing in ChatGPT, confirm that `ui.enabled` is not `false` in `~/.devspace/config.jsonc` and reconnect the MCP server. -Historical `show_changes` cards use the `reviewRef` in their structured result +Historical `show_changes` cards use the `review_ref` in their structured result to recover the exact Git-backed review when a host reloads the app without its original result metadata. `open_workspace` can rebuild its card directly from its structured result. diff --git a/docs/security.md b/docs/security.md index 69bbc1303..13efd7d08 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 c49aad84b..0e1fe4433 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 c6956afdb..d6afd418a 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 = await workspaces.getWorkspace(input.workspaceId); + const workspace = await 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 e31bad244..80505ba6e 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,57 @@ test("tool modes expose the expected host-facing tool surface", async (t) => { } }); +test("model-facing tool schemas 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}.input.${path}`), + ...schemaPropertyPaths(tool.outputSchema) + .filter(({ key }) => !/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/.test(key)) + .map(({ path }) => `${tool.name}.output.${path}`), + ]); + + assert.deepEqual(invalidPaths, []); + }); + } +}); + +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,19 +131,20 @@ 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.match(structured.reviewRef as string, /^[0-9a-f]{40,64}$/); + assert.equal(structured.workspace_id, workspaceId); + assert.equal("workspaceId" in structured, false); + assert.match(structured.review_ref as string, /^[0-9a-f]{40,64}$/); assert.equal("summary" in structured, false); assert.equal("files" in structured, false); assert.equal("patch" in structured, false); @@ -119,8 +171,9 @@ 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 && "reviewRef" in outputProperties); + assert.ok(outputProperties && "workspace_id" in outputProperties); + assert.equal(outputProperties && "workspaceId" in outputProperties, false); + assert.ok(outputProperties && "review_ref" in outputProperties); assert.equal(outputProperties && "summary" in outputProperties, false); assert.equal(outputProperties && "files" in outputProperties, false); assert.equal(outputProperties && "patch" in outputProperties, false); @@ -133,24 +186,24 @@ 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; + const reviewRef = first.review_ref; assert.equal(typeof reviewRef, "string"); 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); + assert.equal(structuredContent(reopened).review_ref, reviewRef); assert.match( (((responseCard(reopened).payload as { patch?: string } | undefined)?.patch) ?? ""), /\+first/, @@ -158,7 +211,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,39 +232,47 @@ 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 { + const providerSchema = outputProperties?.agent_providers as { items?: { properties?: Record }; } | undefined; assert.ok(providerSchema?.items?.properties?.note); const firstStructured = structuredContent(first); - assert.equal(firstStructured.workspaceId, structuredContent(repeated).workspaceId); - assert.ok(Array.isArray(firstStructured.agentsFiles)); - assert.ok(Array.isArray(firstStructured.availableAgentsFiles)); + 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.agents_files)); + assert.ok(Array.isArray(firstStructured.available_agents_files)); assert.ok(Array.isArray(firstStructured.skills)); - assert.ok(Array.isArray(firstStructured.agentProviders)); + assert.ok(Array.isArray(firstStructured.agent_providers)); assert.equal( - (firstStructured.agentProviders as Array>)[0]?.id, + (firstStructured.agent_providers as Array>)[0]?.id, "codex", ); assert.equal( - (firstStructured.agentProviders as Array>)[0]?.note, + (firstStructured.agent_providers as Array>)[0]?.note, providerNote, ); assert.ok(Array.isArray(firstStructured.agents)); - assert.ok(Array.isArray(firstStructured.skillDiagnostics)); + assert.ok(Array.isArray(firstStructured.skill_diagnostics)); assert.equal("workspaceReused" in firstStructured, false); assert.equal("includeBootstrapContext" in firstStructured, false); const repeatedStructured = structuredContent(repeated); - assert.equal(repeatedStructured.agentsFiles, undefined); - assert.equal(repeatedStructured.availableAgentsFiles, undefined); + assert.match(firstStructured.instruction as string, /workspace_id/); + assert.match(repeatedStructured.instruction as string, /workspace_id/); + assert.doesNotMatch(firstStructured.instruction as string, /workspaceId/); + assert.doesNotMatch(repeatedStructured.instruction as string, /workspaceId/); + assert.equal(repeatedStructured.agents_files, undefined); + assert.equal(repeatedStructured.available_agents_files, undefined); assert.equal(repeatedStructured.skills, undefined); - assert.equal(repeatedStructured.agentProviders, undefined); + assert.equal(repeatedStructured.agent_providers, undefined); assert.equal(repeatedStructured.agents, undefined); - assert.equal(repeatedStructured.skillDiagnostics, undefined); + assert.equal(repeatedStructured.skill_diagnostics, undefined); assert.equal("workspaceReused" in repeatedStructured, false); assert.equal("includeBootstrapContext" in repeatedStructured, false); @@ -236,13 +297,13 @@ test("open_workspace refreshes provider availability for each catalog", async (t }); const unavailable = structuredContent(await callOpen(context.client, context.project, "chat-1")); - assert.deepEqual(unavailable.agentProviders, []); + assert.deepEqual(unavailable.agent_providers, []); assert.deepEqual(unavailable.agents, []); available = true; const usable = structuredContent(await callOpen(context.client, context.project, "chat-2")); assert.equal( - (usable.agentProviders as Array>)[0]?.id, + (usable.agent_providers as Array>)[0]?.id, "codex", ); assert.equal( @@ -269,7 +330,7 @@ test("open_workspace omits providers disabled by configuration", async (t) => { const opened = structuredContent(await callOpen(context.client, context.project, "chat-1")); assert.deepEqual( - (opened.agentProviders as Array>).map((provider) => provider.id), + (opened.agent_providers as Array>).map((provider) => provider.id), ["codex"], ); }); @@ -308,12 +369,12 @@ 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).agentsFiles, undefined); - assert.notEqual(structuredContent(otherSession).workspaceId, structuredContent(first).workspaceId); - assert.notEqual(structuredContent(unscoped).workspaceId, structuredContent(first).workspaceId); - assert.ok(Array.isArray(structuredContent(otherSession).agentsFiles)); - assert.ok(Array.isArray(structuredContent(unscoped).agentsFiles)); + assert.equal(structuredContent(repeated).workspace_id, structuredContent(first).workspace_id); + assert.equal(structuredContent(repeated).agents_files, undefined); + 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).agents_files)); + assert.ok(Array.isArray(structuredContent(unscoped).agents_files)); }); test("HTTP endpoint serves modern MCP and stateless legacy clients", async (t) => { @@ -366,9 +427,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; agents_files?: unknown[] } }; }; - const workspaceId = callBody.result?.structuredContent?.workspaceId; + const workspaceId = callBody.result?.structuredContent?.workspace_id; assert.equal(typeof workspaceId, "string"); const repeated = await postModernMcp( @@ -383,10 +444,10 @@ 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; agents_files?: unknown[] } }; }; - assert.equal(repeatedBody.result?.structuredContent?.workspaceId, workspaceId); - assert.equal(repeatedBody.result?.structuredContent?.agentsFiles, undefined); + assert.equal(repeatedBody.result?.structuredContent?.workspace_id, workspaceId); + assert.equal(repeatedBody.result?.structuredContent?.agents_files, undefined); const legacy = await fetch(`${localBaseUrl}/mcp`, { method: "POST", @@ -445,9 +506,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 +523,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 +549,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 744d765d3..e70ddbd7f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -81,7 +81,7 @@ function mcpServerInfo() { title: "DevSpace", version: DEVSPACE_VERSION, 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,8 +133,8 @@ function serverInstructions( const skills = config.skillsEnabled ? `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 agents = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in available_agents_files, 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 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}`; } @@ -387,7 +387,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() @@ -400,32 +400,32 @@ 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(), + source_root: z.string().optional(), worktree: z .object({ path: z.string(), - baseRef: z.string(), - baseSha: z.string(), - dirtySource: z.boolean(), + base_ref: z.string(), + base_sha: z.string(), + dirty_source: z.boolean(), detached: z.boolean(), managed: z.boolean(), }) .optional(), - agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(), - availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema).optional(), + agents_files: z.array(workspaceAgentsFileOutputSchema).optional(), + available_agents_files: z.array(workspaceAvailableAgentsFileOutputSchema).optional(), skills: z.array(workspaceSkillOutputSchema).optional(), - agentProviders: z.array(workspaceLocalAgentProviderOutputSchema).optional(), + agent_providers: z.array(workspaceLocalAgentProviderOutputSchema).optional(), agents: z.array(workspaceLocalAgentOutputSchema).optional(), - skillDiagnostics: z.array(z.unknown()).optional(), + skill_diagnostics: z.array(z.unknown()).optional(), review: z.discriminatedUnion("available", [ z.object({ available: z.literal(true) }), z.object({ @@ -438,8 +438,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, @@ -495,16 +496,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 agents_files instructions. Before working under a path listed in available_agents_files, 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 agents_files instructions. Before working under a path listed in available_agents_files, 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 ? [ @@ -581,20 +582,29 @@ function registerMcpSurface( }, }, structuredContent: { - workspaceId: workspace.id, + workspace_id: workspace.id, root: workspace.root, mode: workspace.mode, - sourceRoot: workspace.sourceRoot, - worktree: workspace.worktree, + source_root: workspace.sourceRoot, + worktree: workspace.worktree + ? { + path: workspace.worktree.path, + base_ref: workspace.worktree.baseRef, + base_sha: workspace.worktree.baseSha, + dirty_source: workspace.worktree.dirtySource, + detached: workspace.worktree.detached, + managed: workspace.worktree.managed, + } + : undefined, review, ...(includeBootstrapContext ? { - agentsFiles: loadedAgentsFiles, - availableAgentsFiles: availableAgentsFileOutputs, + agents_files: loadedAgentsFiles, + available_agents_files: availableAgentsFileOutputs, skills: visibleSkills, - agentProviders: visibleAgentProviders, + agent_providers: visibleAgentProviders, agents: visibleAgents, - skillDiagnostics: workspace.skillDiagnostics, + skill_diagnostics: workspace.skillDiagnostics, } : {}), instruction, @@ -618,7 +628,7 @@ function registerMcpSurface( .filter(Boolean) .join(" "), inputSchema: { - workspaceId: z + workspace_id: z .string() .describe(workspaceIdDescription), path: z @@ -644,8 +654,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 = await workspaces.getWorkspace(workspaceId); const readPath = workspaces.resolveReadPath(workspace, input.path); const response = await readFileTool( @@ -698,17 +709,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(), - reviewRef: z.string().regex(/^[0-9a-f]{40,64}$/), + workspace_id: z.string(), + review_ref: 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 = await workspaces.getWorkspace(workspaceId); const reviewRef = typeof _meta?.["devspace/reviewRef"] === "string" ? _meta["devspace/reviewRef"] @@ -746,8 +758,8 @@ function registerMcpSurface( }, }, structuredContent: { - workspaceId, - reviewRef: review.reviewRef, + workspace_id: workspaceId, + review_ref: review.reviewRef, result: contentText(content), }, }; diff --git a/src/tool-surfaces/claude.ts b/src/tool-surfaces/claude.ts index 912c5951f..635179d98 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 = await 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 = await 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 = await workspaces.getWorkspace(workspaceId); const cwd = workspaces.resolveWorkingDirectory( workspace, diff --git a/src/tool-surfaces/codex.ts b/src/tool-surfaces/codex.ts index e3495eff5..16b7e4ccc 100644 --- a/src/tool-surfaces/codex.ts +++ b/src/tool-surfaces/codex.ts @@ -47,12 +47,12 @@ 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(), + exit_code: z.number().int().optional(), signal: z.string().optional(), - wallTimeMs: z.number().nonnegative(), - outputTruncated: z.boolean(), + wall_time_ms: z.number().nonnegative(), + output_truncated: z.boolean(), }); } @@ -63,12 +63,12 @@ function processToolResponse(snapshot: ProcessSnapshot) { content, structuredContent: { result, - sessionId: snapshot.sessionId, + session_id: snapshot.sessionId, running: snapshot.running, - exitCode: snapshot.exitCode, + exit_code: snapshot.exitCode, signal: snapshot.signal, - wallTimeMs: snapshot.wallTimeMs, - outputTruncated: snapshot.outputTruncated, + wall_time_ms: snapshot.wallTimeMs, + output_truncated: snapshot.outputTruncated, }, }; } @@ -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( @@ -96,15 +96,16 @@ function registerApplyPatchTool(context: ToolRegistrationContext): void { files: z.array( z.object({ path: z.string(), - previousPath: z.string().optional(), + previous_path: z.string().optional(), operation: z.enum(["add", "update", "delete", "move"]), }), ), }), 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 }, @@ -124,7 +125,10 @@ function registerApplyPatchTool(context: ToolRegistrationContext): void { result, additions: applied.additions, removals: applied.removals, - files: applied.files, + files: applied.files.map(({ previousPath, ...file }) => ({ + ...file, + previous_path: previousPath, + })), }, }; }, @@ -139,9 +143,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 +167,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 +182,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 +194,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 +249,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 +275,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 +284,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 +296,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 bdf48598f..62f1add65 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 b7c5315dd..e5b39abf9 100644 --- a/src/ui/tool-result.test.ts +++ b/src/ui/tool-result.test.ts @@ -10,11 +10,11 @@ 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" }], - agentsFiles: [{ path: "AGENTS.md", content: "instructions" }], + agents_files: [{ path: "AGENTS.md", content: "instructions" }], review: { available: true }, instruction: "Reuse this workspace.", }, @@ -32,8 +32,8 @@ test("review results use rich metadata when the host provides it", () => { const decoded = decodeToolResult({ content: [], structuredContent: { - workspaceId: "ws_1", - reviewRef: "a".repeat(40), + workspace_id: "ws_1", + review_ref: "a".repeat(40), result: "Changed 1 file (+1 -0).", }, _meta: { @@ -57,8 +57,8 @@ test("review structured content becomes a reload reference when metadata is miss const decoded = decodeToolResult({ content: [], structuredContent: { - workspaceId: "ws_1", - reviewRef: "b".repeat(40), + workspace_id: "ws_1", + review_ref: "b".repeat(40), result: "Changed 1 file (+1 -0).", }, }); @@ -74,8 +74,8 @@ test("incomplete review metadata falls back to the durable review reference", () const decoded = decodeToolResult({ content: [], structuredContent: { - workspaceId: "ws_1", - reviewRef: "e".repeat(40), + workspace_id: "ws_1", + review_ref: "e".repeat(40), result: "Changed 1 file (+1 -0).", }, _meta: { card: {} }, @@ -114,8 +114,8 @@ test("ChatGPT globals restore structured output and hidden MCP result metadata t }; const restored = toolResultFromChatGptGlobals({ toolOutput: { - workspaceId: "ws_1", - reviewRef: "c".repeat(40), + workspace_id: "ws_1", + review_ref: "c".repeat(40), result: "Changed 1 file.", }, toolResponseMetadata: { @@ -124,8 +124,8 @@ test("ChatGPT globals restore structured output and hidden MCP result metadata t }); assert.deepEqual(restored?.structuredContent, { - workspaceId: "ws_1", - reviewRef: "c".repeat(40), + workspace_id: "ws_1", + review_ref: "c".repeat(40), result: "Changed 1 file.", }); assert.deepEqual(restored?._meta, fullResult._meta); @@ -134,8 +134,8 @@ 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", - reviewRef: "d".repeat(40), + workspace_id: "ws_1", + review_ref: "d".repeat(40), result: "Changed 1 file.", }, toolResponseMetadata: { diff --git a/src/ui/tool-result.ts b/src/ui/tool-result.ts index efd1bdb4e..446af64f9 100644 --- a/src/ui/tool-result.ts +++ b/src/ui/tool-result.ts @@ -16,8 +16,8 @@ export function decodeToolResult(result: CallToolResult): DecodedToolResult { const metaCard = cardFields(asRecord(asRecord(result._meta)?.card)); if (structured) { - const workspaceId = stringField(structured.workspaceId); - const reviewRef = stringField(structured.reviewRef); + const workspaceId = stringField(structured.workspace_id); + const reviewRef = stringField(structured.review_ref); if (workspaceId && reviewRef) { if (isCompleteReviewCard(metaCard)) { return { @@ -45,7 +45,7 @@ export function decodeToolResult(result: CallToolResult): DecodedToolResult { const root = stringField(structured.root); const mode = workspaceMode(structured.mode); if (workspaceId && root && mode) { - const structuredCard = cardFields(structured) ?? {}; + const structuredCard = structuredWorkspaceCardFields(structured) ?? {}; return { kind: "card", card: { @@ -73,6 +73,35 @@ export function decodeToolResult(result: CallToolResult): DecodedToolResult { return { kind: "invalid" }; } +function structuredWorkspaceCardFields( + record: Record | undefined, +): Partial | undefined { + if (!record) return undefined; + const worktreeRecord = asRecord(record.worktree); + return cardFields({ + root: record.root, + mode: record.mode, + sourceRoot: record.source_root, + worktree: worktreeRecord + ? { + path: worktreeRecord.path, + baseRef: worktreeRecord.base_ref, + baseSha: worktreeRecord.base_sha, + dirtySource: worktreeRecord.dirty_source, + detached: worktreeRecord.detached, + managed: worktreeRecord.managed, + } + : undefined, + review: record.review, + agentsFiles: record.agents_files, + availableAgentsFiles: record.available_agents_files, + skills: record.skills, + agentProviders: record.agent_providers, + agents: record.agents, + instruction: record.instruction, + }); +} + function isCompleteReviewCard( card: Partial | undefined, ): card is Partial & { diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 27d4f5b88..a04f48bea 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 }, }); }