From e073c27e8ba150a8ad681c60b633461f6223412d Mon Sep 17 00:00:00 2001 From: luo-xingyu <3194190064@qq.com> Date: Sat, 5 Sep 2026 23:24:37 +0800 Subject: [PATCH 1/2] fix: use snake_case for model tool inputs --- docs/artifact-exchange.md | 9 ++-- docs/chatgpt-coding-workflow.md | 16 +++---- docs/gotchas.md | 7 +-- docs/security.md | 6 +-- src/artifact-download.test.ts | 4 +- src/artifact-tools.ts | 8 ++-- src/server.test.ts | 79 +++++++++++++++++++++++++++++++-- src/server.ts | 21 +++++---- src/tool-surfaces/claude.ts | 32 ++++++++----- src/tool-surfaces/codex.ts | 45 +++++++++++-------- src/tool-surfaces/types.ts | 2 +- 11 files changed, 162 insertions(+), 67 deletions(-) diff --git a/docs/artifact-exchange.md b/docs/artifact-exchange.md index f4728eb0d..0950cae0f 100644 --- a/docs/artifact-exchange.md +++ b/docs/artifact-exchange.md @@ -8,19 +8,20 @@ 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 - unused workspace-relative `path` to `download_artifact`. +2. Pass the host-provided native `file`, the returned `workspaceId` as + `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 f6826617c..c7768ecba 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -15,7 +15,7 @@ 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`. +and shell calls should pass that same value as `workspace_id`. ChatGPT may support automatic checkout recovery through optional host conversation metadata. This is an OpenAI-host adapter detail, not a standard MCP @@ -23,9 +23,9 @@ 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` -returned by `open_workspace` for later operations. Hosts without supported -conversation context receive a normal new workspace and continue with that -explicit `workspaceId` workflow. +returned by `open_workspace` as `workspace_id` for later operations. Hosts +without supported conversation context receive a normal new workspace and +continue with that explicit workspace ID workflow. The model receives actionable workspace instructions; automatic-reuse bookkeeping is not a model-facing choice. @@ -78,12 +78,12 @@ 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 -`open_workspace` in worktree mode again only when another isolated worktree is -actually required. +`workspaceId`. Reuse that ID as `workspace_id` for work inside that worktree; +call `open_workspace` in worktree mode again only when another isolated worktree +is actually required. Uncommitted source checkout changes are not copied into the managed worktree. DevSpace reports when the source checkout was dirty so the model can decide how diff --git a/docs/gotchas.md b/docs/gotchas.md index 3bf7dd2d8..2d2f1ae49 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -149,8 +149,9 @@ the same project in that conversation; repeated opens reuse the `workspaceId` 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 -later tools. Other MCP hosts use this explicit workspace workflow as well. +In all cases, continue passing the `workspaceId` returned by `open_workspace` as +`workspace_id` 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 shows the combined changes and advances the review point automatically. @@ -186,7 +187,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 69bbc1303..c47f5fc5d 100644 --- a/docs/security.md +++ b/docs/security.md @@ -96,9 +96,9 @@ 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 -path. It returns only the workspace-relative path and does not create a -persistent artifact service or reusable artifact ID. +`workspace_id` containing the `workspaceId` 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. DevSpace accepts only the documented native-file object and trusted OpenAI download hosts and redirects. Arbitrary URL strings, local source paths, 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 fe9a195af..8871228f9 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. Pass the workspaceId returned by open_workspace as 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 79c21a66c..ce33e03e8 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -48,6 +48,52 @@ 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"), + ).workspaceId; + 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.sessionId, "number"); + + const finished = structuredContent(await context.client.callTool({ + name: "write_stdin", + arguments: { + workspace_id: workspaceId, + session_id: started.sessionId, + yield_time_ms: 2_000, + }, + })); + assert.equal(finished.running, false); + assert.equal(finished.exitCode, 0); +}); + 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) => { @@ -85,7 +131,7 @@ test("show_changes keeps model output compact and preserves the rich review card 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); @@ -138,7 +184,7 @@ test("show_changes can reopen a historical review without advancing the checkpoi 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"); @@ -146,7 +192,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); @@ -157,7 +203,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) ?? ""), @@ -292,6 +338,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; +} + async function fixture( t: TestContext, options: { diff --git a/src/server.ts b/src/server.ts index 9e7ded7fd..f567afce6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -107,7 +107,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, but ${toolNames.read} only permits advertised SKILL.md files and files under already-loaded 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 workspaceId as 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 workspaceId is rejected.`; return `${common} ${toolSurface.instructions({ agents, skills })}${artifactInstruction}${showChangesInstruction}`; } @@ -293,7 +293,7 @@ export function createMcpServer( 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 workspaceId as workspace_id.", }, { instructions: serverInstructions(config, toolSurface), @@ -337,7 +337,7 @@ export function createMcpServer( { 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 workspaceId exists for it. During continued work, reuse the returned workspaceId as 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() @@ -350,7 +350,7 @@ export function createMcpServer( .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."), @@ -388,8 +388,9 @@ export function createMcpServer( ...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, @@ -554,7 +555,7 @@ export function createMcpServer( .filter(Boolean) .join(" "), inputSchema: { - workspaceId: z + workspace_id: z .string() .describe(workspaceIdDescription), path: z @@ -580,8 +581,9 @@ export function createMcpServer( 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( @@ -635,7 +637,7 @@ export function createMcpServer( 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(), @@ -644,8 +646,9 @@ export function createMcpServer( ...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"] diff --git a/src/tool-surfaces/claude.ts b/src/tool-surfaces/claude.ts index 4fc21f221..b6d7c2e84 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 526e175bb..17d201942 100644 --- a/src/tool-surfaces/codex.ts +++ b/src/tool-surfaces/codex.ts @@ -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 }, @@ -141,7 +142,7 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { 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.", 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 a9d8131b8..6d96df16d 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. Pass the workspaceId returned by open_workspace as workspace_id."; export const WRITE_TOOL_ANNOTATIONS = { readOnlyHint: false, From 0e78ad8f5b8e959cd043b8f4170ece259a556f57 Mon Sep 17 00:00:00 2001 From: luo-xingyu <3194190064@qq.com> Date: Sun, 6 Sep 2026 01:37:50 +0800 Subject: [PATCH 2/2] fix: align workspace tool callers with snake_case --- src/server.test.ts | 47 +++++++++++++++++++++++++++++++++++++- src/server.ts | 8 +++---- src/ui/tool-result.test.ts | 9 ++++++++ src/ui/tool-result.ts | 11 +++++++++ src/ui/workspace-app.tsx | 7 ++---- 5 files changed, 72 insertions(+), 10 deletions(-) diff --git a/src/server.test.ts b/src/server.test.ts index ce33e03e8..b66a89af3 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { 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"; @@ -94,6 +94,51 @@ test("Codex process tools accept snake_case session and yield inputs", async (t) assert.equal(finished.exitCode, 0); }); +test("open_workspace instructions tell models to pass the returned ID as 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/); +}); + +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"), + ).workspaceId; + 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) => { diff --git a/src/server.ts b/src/server.ts index f567afce6..79acfffd4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -439,16 +439,16 @@ export function createMcpServer( 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 workspaceId as workspace_id for subsequent work in this project. Keep reusing that workspace_id 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 as workspace_id for subsequent work in this project. Keep reusing that workspace_id while working in this project. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; const instruction = workspaceReused ? [ `Workspace already open as ${workspace.id}.`, - "Continue with this workspaceId.", + "Continue passing this workspaceId as 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 workspaceId as workspace_id for subsequent work in this isolated worktree. Keep reusing that workspace_id while working in this worktree. Follow the project instructions, nested instruction files, skills, agent profiles, and diagnostics returned for it." : cardInstruction; const resultContent: ToolContent[] = [ { diff --git a/src/ui/tool-result.test.ts b/src/ui/tool-result.test.ts index b7c5315dd..208cc253d 100644 --- a/src/ui/tool-result.test.ts +++ b/src/ui/tool-result.test.ts @@ -3,9 +3,18 @@ import test from "node:test"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { decodeToolResult, + historicalReviewToolCall, toolResultFromChatGptGlobals, } from "./tool-result.js"; +test("historical review reload requests use snake_case tool inputs", () => { + assert.deepEqual(historicalReviewToolCall("ws_1", "review_1"), { + name: "show_changes", + arguments: { workspace_id: "ws_1" }, + _meta: { "devspace/reviewRef": "review_1" }, + }); +}); + test("workspace cards can be rebuilt from structured content without result metadata", () => { const decoded = decodeToolResult({ content: [], diff --git a/src/ui/tool-result.ts b/src/ui/tool-result.ts index efd1bdb4e..8fc96b63d 100644 --- a/src/ui/tool-result.ts +++ b/src/ui/tool-result.ts @@ -11,6 +11,17 @@ export interface ChatGptToolGlobals { toolResponseMetadata?: unknown; } +export function historicalReviewToolCall( + workspaceId: string, + reviewRef: string, +) { + return { + name: "show_changes" as const, + arguments: { workspace_id: workspaceId }, + _meta: { "devspace/reviewRef": reviewRef }, + }; +} + export function decodeToolResult(result: CallToolResult): DecodedToolResult { const structured = asRecord(result.structuredContent); const metaCard = cardFields(asRecord(asRecord(result._meta)?.card)); diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 27d4f5b88..3b06082ce 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -25,6 +25,7 @@ import { } from "./patch-display.js"; import { decodeToolResult, + historicalReviewToolCall, toolResultFromChatGptGlobals, type ChatGptToolGlobals, } from "./tool-result.js"; @@ -206,11 +207,7 @@ async function reopenReview( throw new Error("This host cannot reload historical review details."); } - return app.callServerTool({ - name: "show_changes", - arguments: { workspaceId }, - _meta: { "devspace/reviewRef": reviewRef }, - }); + return app.callServerTool(historicalReviewToolCall(workspaceId, reviewRef)); } function chatGptRestoredResult(): CallToolResult | undefined {