diff --git a/CHANGELOG.md b/CHANGELOG.md index a38e52207..286f05f66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ tagged release also ships native binaries for Linux, macOS, and Windows. - Fix duration day/minute boundaries, file directory and extension labels, and payload-size rounding. Unknown payload sizes now display clearly. Background queues process undefined items, reject invalid concurrency, and release timeout timers when an operation fails. +- A `todowrite` call whose output is not JSON no longer leaves the tool call + running forever: the completed update still reaches the editor. + - Expand account, privacy, and usage documentation; clarify prepaid Ace access, fixed optional reloads, and current account requirements. ### Changed diff --git a/backend/cli/src/acp/agent.ts b/backend/cli/src/acp/agent.ts index a9b1dccd0..a278c35c2 100644 --- a/backend/cli/src/acp/agent.ts +++ b/backend/cli/src/acp/agent.ts @@ -91,6 +91,22 @@ export namespace ACP { return getNewContent(content, diff) } + function parseTodoOutput(output: string) { + let raw: unknown + try { + raw = JSON.parse(output) + } catch (error) { + log.error("could not parse todowrite output as JSON", { error }) + return undefined + } + const parsed = z.array(Todo.Info).safeParse(raw) + if (!parsed.success) { + log.error("failed to parse todo output", { error: parsed.error }) + return undefined + } + return parsed.data + } + export async function init({ sdk: _sdk }: { sdk: OpenScienceClient }) { return { create: (connection: AgentSideConnection, fullConfig: ACPConfig) => { @@ -329,14 +345,14 @@ export namespace ACP { } if (part.tool === "todowrite") { - const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output)) - if (parsedTodos.success) { + const todos = parseTodoOutput(part.state.output) + if (todos) { await this.connection .sessionUpdate({ sessionId, update: { sessionUpdate: "plan", - entries: parsedTodos.data.map((todo) => { + entries: todos.map((todo) => { const status: PlanEntry["status"] = todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"]) return { @@ -350,8 +366,6 @@ export namespace ACP { .catch((error) => { log.error("failed to send session update for todo", { error }) }) - } else { - log.error("failed to parse todo output", { error: parsedTodos.error }) } } diff --git a/backend/cli/test/acp/event-subscription.test.ts b/backend/cli/test/acp/event-subscription.test.ts index 6e7a90788..085379f06 100644 --- a/backend/cli/test/acp/event-subscription.test.ts +++ b/backend/cli/test/acp/event-subscription.test.ts @@ -368,6 +368,48 @@ describe("acp.agent event subscription", () => { }) }) + test("completes a todowrite tool call whose output is not valid JSON", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, controller, updates, stop } = createFakeAgent() + const cwd = "/tmp/openscience-acp-test" + + const sessionID = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + controller.push({ + directory: cwd, + payload: { + type: "message.part.updated", + properties: { + part: { + sessionID, + messageID: "msg_1", + callID: "call_1", + type: "tool", + tool: "todowrite", + state: { + status: "completed", + input: {}, + output: "the model narrated instead of emitting json", + title: "todowrite", + metadata: {}, + }, + }, + }, + } as any, + }) + + await waitFor(() => (updates.get(sessionID) ?? []).includes("tool_call_update")) + + expect(updates.get(sessionID)).toContain("tool_call_update") + expect(updates.get(sessionID)).not.toContain("plan") + stop() + }, + }) + }) + test("permission.asked events are handled and replied", async () => { await using tmp = await tmpdir() await Instance.provide({