Description
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
- Use a checkout at the commit below with its locked workspace dependencies installed.
- Save the script below as
repro-tool-patch.mjsoutside the checkout and runnode repro-tool-patch.mjs /path/to/checkout. - Compare the
CLEARandREPLACE rawInputoutput 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.
Activity