Skip to content

[Bug]: ACP v2 tool updates retain explicitly cleared fields and merge replacement input #35819

Closed

Description

@coygeek

Summary

ACP v2 tool updates keep old tool metadata, input, and output content after an agent explicitly clears them. Replacement input objects also retain keys from earlier input, so the displayed tool state can describe parameters or results that no longer apply. A deterministic library reproduction confirms that omitted-field updates work while explicit clears and replacements do not.

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. Use a checkout at the commit below with its locked workspace dependencies installed.
  2. Save the script below as repro-tool-patch.mjs outside the checkout and run node repro-tool-patch.mjs /path/to/checkout.
  3. Compare the CLEAR and REPLACE rawInput output with the preceding omitted-field control.

The final script reproduced the defect deterministically in one run. An earlier probe produced the same result before the package-identification line was clarified; the private workspace package has no version field.

The script requires Node with native TypeScript support and the checkout's installed dependencies. It creates disposable HOME, TMPDIR, and T3CODE_HOME directories, imports the exported runtime-model library, checks a passing omitted-field control and image placeholder, and removes its scratch on exit. Exit 0 means the expected clear/replace behavior, 1 means the defect reproduced, and 2 means setup or the control failed. It leaves the checkout unchanged.

Reproduction script: repro-tool-patch.mjs
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const root = path.resolve(process.argv[2] ?? '.');
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'acp-patch-'));
let status = 2;
try {
  for (const key of ['HOME', 'TMPDIR', 'T3CODE_HOME']) {
    const dir = path.join(scratch, key);
    fs.mkdirSync(dir);
    process.env[key] = dir;
  }
  console.log(`Node ${process.version}; ${process.platform} ${process.arch}`);
  const pkg = JSON.parse(fs.readFileSync(path.join(root, 'packages/effect-acp/package.json'), 'utf8'));
  console.log(`${pkg.name}: private workspace package (no version field)`);
  const { parseSessionUpdateEvent, mergeToolCallState, acpContentBlockDisplayText } = await import(pathToFileURL(path.join(root, 'packages/provider-acp/src/server/runtimeModel.ts')));
  const parse = update => {
    const event = parseSessionUpdateEvent({ sessionId: 'session-1', update: { sessionUpdate: 'tool_call_update', toolCallId: 'call-1', ...update } }).events.find(e => e._tag === 'ToolCallUpdated');
    if (!event) throw new Error('No tool event');
    return event.toolCall;
  };
  const previous = parse({ name: 'old-name', title: 'Old title', kind: 'read', status: 'in_progress', content: [{type:'content',content:{type:'text',text:'old output'}}], rawInput: {path:'old.txt', obsolete:true}, _meta:{old:true} });
  const omitted = mergeToolCallState(previous, parse({status:'completed'}));
  if (omitted.data.name !== 'old-name' || omitted.data.content[0].content.text !== 'old output' || omitted.status !== 'completed') throw new Error('Omitted-field control failed');
  if (!acpContentBlockDisplayText({type:'image',mimeType:'image/png',data:'AA=='}).includes('ACP image')) throw new Error('Image placeholder control failed');
  console.log('CONTROL: omitted fields retained; status updated; image placeholder present');
  const cleared = mergeToolCallState(previous, parse({name:null,title:null,kind:null,status:null,content:null,rawInput:null,_meta:null}));
  const replaced = mergeToolCallState(previous, parse({rawInput:{path:'new.txt'}}));
  console.log('CLEAR: '+JSON.stringify({name:cleared.data.name,title:cleared.data.title,kind:cleared.kind,status:cleared.status,content:cleared.data.content,rawInput:cleared.data.rawInput,meta:cleared.data.meta}));
  console.log('REPLACE rawInput: '+JSON.stringify(replaced.data.rawInput));
  const stale = cleared.data.name != null || cleared.data.title != null || cleared.kind != null || cleared.status != null || (cleared.data.content?.length ?? 0)>0 || cleared.data.rawInput != null || cleared.data.meta != null || replaced.data.rawInput.obsolete === true;
  status = stale ? 1 : 0;
} catch (error) {
  console.log('SETUP: '+String(error));
} finally {
  fs.rmSync(scratch,{recursive:true,force:true});
}
console.log(status === 0 ? 'RESULT: expected behavior' : status === 1 ? 'RESULT: defect reproduced' : 'RESULT: inconclusive');
process.exitCode = status;

Expected behavior

The pinned ACP v2 alpha.3 ToolCallUpdate schema specifies that omission retains the previous value, explicit null clears it, concrete values replace it, and null or [] clears collections. This is also recorded in T3's generated packages/effect-acp/src/_generated/schema.gen.ts#L6515-L6599. The separate v1 name rule treats null as no change, so this report concerns negotiated wire v2 rather than imposing v2 semantics on v1.

Actual behavior

A tool-call upsert with name, title, kind, status, content, rawInput, and _meta explicitly set to null retains every previous value in the merged ACP tool state. Replacing rawInput with {"path":"new.txt"} retains the previous obsolete: true property. The supported adapter path calls the same parser and merger from its session-update handler and emitTool; this run exercises those exported library functions directly.

Impact

Minor bug or occasional failure

Impact: P2 - Valid ACP v2 updates leave stale tool metadata, parameters, and content in the state projected to users; the demonstrated failure is bounded to affected tool calls.

The direct run covers all listed clears and object-input replacement. It does not establish which installed vendor agents emit these sequences. No user-facing workaround was verified. The report makes no claim that the stale input changes actual tool execution or authorization.

Version or commit

T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93; workspace effect-acp has no package version field. Schema pins: schema-v1.21.0 and schema-v2.0.0-alpha.3.

Environment

macOS 27.2, arm64; Node v26.12.1; locked Effect 4.1.3. No provider executable or paid prompt was used.

Evidence

The reproduction printed:

Node v26.12.1; darwin arm64
effect-acp: private workspace package (no version field)
CONTROL: omitted fields retained; status updated; image placeholder present
CLEAR: {"name":"old-name","title":"Old title","kind":"read","status":"inProgress","content":[{"type":"content","content":{"type":"text","text":"old output"}}],"rawInput":{"path":"old.txt","obsolete":true},"meta":{"old":true}}
REPLACE rawInput: {"path":"new.txt","obsolete":true}
RESULT: defect reproduced

Source evidence: packages/provider-acp/src/server/runtimeModel.ts#L831-L879 drops null fields before packages/provider-acp/src/server/runtimeModel.ts#L927-L949 falls back to prior state. packages/shared/src/toolActivity.ts#L197-L226 merges raw input object properties. The production adapter invokes that merger in packages/provider-acp/src/server/adapter.ts#L3948-L3990.

Restoration check

After the explicit-clear upsert, the fields cleared by the agent must no longer contain their former values. Replacing raw input must yield only the replacement object's properties. Omitted fields must continue to retain their existing values, and the v1 null-name convention must remain generation-specific.

Workaround

No user-facing workaround verified.

Verification scope

Evidence provenance: observed. Local verification: reproduced. Reproduction completeness: complete for the exported parser/merger; the production adapter call path was inspected in source. No live vendor-agent or UI run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions