Skip to content

[Bug]: ACP streamed messages merge or split when opaque IDs contain whitespace #35820

Closed

Description

@coygeek

Summary

ACP replies can merge distinct messages or split one message into two when the agent uses message IDs with surrounding whitespace. Message identity should preserve the exact opaque ID across full updates and streamed chunks. A deterministic adapter reproduction demonstrates both failures while the existing interleaving controls pass.

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.
  • Removed credentials, tokens, private paths, and sensitive data.

Area

apps/server

Actual behavior

At the public ACP provider adapter's registered session-update handler, two agent_message_chunk updates with message IDs "answer" and " answer " and texts "A" and "B" produce one assistant message "AB" instead of two messages. Conversely, an agent_message full update with ID " answer " and content "A", followed by an agent_message_chunk with that identical ID and text "B", produces two assistant messages "A" and "B" instead of one "AB". The fake agent used by the harness negotiates ACP wire v2; no external provider or paid prompt is involved.

Steps to reproduce

  1. Use a clean checkout of the commit in Version with its lockfile dependencies already installed.
  2. Run the reproduction script below with that checkout as its only argument. It creates a detached disposable worktree, reuses the installed dependencies, and extends the existing resumes ACP text segments table with the two input sequences above.
  3. Observe that both new expectations fail while all three original interleaving controls pass.

The final script reproduced both cases in one run. An earlier direct focused test also reproduced both cases. The first script attempt was inconclusive because its dependency-link setup encountered an already tracked fixture directory; the final script skips existing fixture directories. An earlier pnpm exec launch attempted dependency verification against a linked dependency directory and printed a setup warning before running the same focused tests; the final script invokes the installed Vite+ entry point directly.

Save this as repro.sh and run sh repro.sh /path/to/t3code. It requires Git, Python 3, the checkout's Node runtime, and dependencies installed from the lockfile. It creates its worktree, test inputs, HOME, TMPDIR, and T3 state directory under a disposable temporary directory, and removes that worktree and scratch on exit. It returns 1 with RESULT: defect reproduced when a new identity assertion fails with passing controls, 0 with RESULT: expected behavior when both identity cases pass, or 2 with RESULT: inconclusive when setup or the controls fail. It leaves the supplied checkout's tracked files unchanged.

Reproduction script: repro.sh
#!/bin/sh
set -u
if [ "$#" -ne 1 ]; then
  echo 'Usage: sh repro.sh /path/to/t3code'
  echo 'RESULT: inconclusive'
  exit 2
fi
repo=$(cd "$1" && pwd -P) || exit 2
scratch=$(mktemp -d /tmp/acp-repro.XXXXXX) || exit 2
wt=$scratch/wt
cleanup() {
  git -C "$repo" worktree remove --force "$wt" >/dev/null 2>&1 || true
  rm -rf "$scratch"
}
trap cleanup EXIT HUP INT TERM
for tool in git node python3; do
  "$tool" --version || { echo 'RESULT: inconclusive'; exit 2; }
done
git -C "$repo" rev-parse HEAD || { echo 'RESULT: inconclusive'; exit 2; }
git -C "$repo" worktree add --quiet --detach "$wt" HEAD >"$scratch/setup.log" 2>&1 || {
  echo 'Worktree setup failed'
  echo 'RESULT: inconclusive'
  exit 2
}
python3 - "$repo" "$wt" <<'PY'
import os, sys
from pathlib import Path
src, dst = map(Path, sys.argv[1:])
for root, dirs, _ in os.walk(src):
    for excluded in ('.git', '.repos'):
        if excluded in dirs:
            dirs.remove(excluded)
    if 'node_modules' in dirs:
        target = dst / Path(root).relative_to(src) / 'node_modules'
        if not target.exists() and not target.is_symlink():
            target.symlink_to(Path(root) / 'node_modules', target_is_directory=True)
        dirs.remove('node_modules')
p = dst / 'apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts'
s = p.read_text()
needle = '    {\n      label: "a thought with its own id interrupts a named assistant message",'
assert s.count(needle) == 1, 'Existing control table not found'
s = s.replace(needle, '''    {
      label: "opaque IDs differ only in surrounding whitespace",
      steps: [answer("answer", "A"), answer(" answer ", "B")],
      expected: [["assistant_message", "A"], ["assistant_message", "B"]],
    },
    {
      label: "opaque ID survives a full update followed by a chunk",
      steps: [agentMessage(" answer ", "A"), answer(" answer ", "B")],
      expected: [["assistant_message", "AB"]],
    },
''' + needle)
p.write_text(s)
PY
if [ "$?" -ne 0 ]; then echo 'RESULT: inconclusive'; exit 2; fi
mkdir -p "$scratch/home" "$scratch/temp" "$scratch/state"
export HOME=$scratch/home TMPDIR=$scratch/temp T3CODE_HOME=$scratch/state
cd "$wt" || { echo 'RESULT: inconclusive'; exit 2; }
node node_modules/vite-plus/bin/vp --version || { echo 'RESULT: inconclusive'; exit 2; }
node node_modules/vite-plus/bin/vp test run apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts -t 'resumes ACP text segments' --maxWorkers 1 --reporter=json --outputFile "$scratch/result.json" >"$scratch/test.log" 2>&1
python3 - "$scratch/result.json" <<'PY'
import json, sys
from pathlib import Path
try:
    report = json.loads(Path(sys.argv[1]).read_text())
    rows = [row for suite in report['testResults'] for row in suite['assertionResults'] if 'resumes ACP text segments' in row['fullName']]
    controls = [row for row in rows if 'opaque ID' not in row['fullName']]
    cases = [row for row in rows if 'opaque ID' in row['fullName']]
    assert len(controls) == 3 and len(cases) == 2
    assert all(row['status'] == 'passed' for row in controls)
    print('Existing interleaving controls: 3 passed')
    for row in cases:
        print(row['title'] + ': ' + row['status'])
    if all(row['status'] == 'passed' for row in cases):
        print('RESULT: expected behavior')
        sys.exit(0)
    assert any(row['status'] == 'failed' and 'deeply equal' in '\n'.join(row['failureMessages']) for row in cases)
    print('RESULT: defect reproduced')
    sys.exit(1)
except (OSError, ValueError, KeyError, AssertionError) as error:
    print('Setup or control failed: ' + type(error).__name__)
    print('RESULT: inconclusive')
    sys.exit(2)
PY

Expected behavior

Preserve each message's opaque ID exactly. Distinct IDs "answer" and " answer " identify distinct messages; a full update and a chunk with the same ID " answer " address one message, so the chunk appends to its current content. This is already required by the pinned ACP v2 alpha.3 prompt lifecycle, Message IDs section, which defines opaque message IDs and upserts/chunks keyed by them. The pinned v2 alpha.3 schema defines MessageId as a string without a whitespace restriction or normalization, and AgentMessage specifies that subsequent chunks with the same ID append to current content.

Impact

Minor bug or occasional failure.

Impact: P2 - Valid bounded ACP output sequences produce the wrong conversation structure: unrelated messages combine, or one message splits. This is consequential transcript presentation behavior, but it does not establish lost provider execution or a blocked core workflow.

Both cases were reproduced with deterministic adapter fixtures on macOS arm64. The same appendText path handles assistant, reasoning, and user chunks; only assistant output was exercised. Real-agent prevalence and other platforms are NOT_ENOUGH_INFO: a real provider trace or runs on those platforms would establish reach. No end-user workaround was verified; changing an agent's opaque IDs would require control of that agent. IDs without surrounding whitespace passed the existing controls.

Version or commit

T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93. Schema generator pins: wire v1 schema-v1.21.0, wire v2 schema-v2.0.0-alpha.3.

Environment

Darwin arm64; Node v26.12.1; Python 3.15.2; Git 2.56.0; lockfile Vite+ v1.4.0 with Vitest v5.0.5; Effect 4.1.3. No real ACP provider was executed.

Interface

API: ACP provider adapter session-update callback and projected turn_item.updated events.

Earliest failure stage

Core processing

Logs or stack traces

The final portable script printed the following relevant result lines; the version list above preserves its tool output. The assertion values below were also observed directly in the initial focused run, with machine-local stack paths omitted.

Existing interleaving controls: 3 passed
resumes ACP text segments when opaque IDs differ only in surrounding w…: failed
resumes ACP text segments when opaque ID survives a full update follow…: failed
RESULT: defect reproduced

Distinct IDs:
actual:   [["assistant_message", "AB"]]
expected: [["assistant_message", "A"], ["assistant_message", "B"]]

Same ID, full update then chunk:
actual:   [["assistant_message", "A"], ["assistant_message", "B"]]
expected: [["assistant_message", "AB"]]

Source evidence: packages/provider-acp/src/server/adapter.ts#L2994-L3022 calls messageId?.trim() for streamed chunks; packages/provider-acp/src/server/adapter.ts#L3025-L3041 compares the full-update ID without that normalization. The exercised entry point dispatches chunks and full updates to those paths in packages/provider-acp/src/server/adapter.ts#L5511-L5559. The reproduction extends the existing adapter test harness rather than copying the production implementation.

Expected outcome or recovery

Both new table cases pass and the three existing interleaving controls remain passing. Distinct opaque IDs remain separate, and a full update followed by a chunk for the identical ID retains one message containing the concatenated content.

Analysis or suspected cause

Source: chunk handling trims IDs, whereas full-update handling retains their exact spelling. The observed merge and split match those two different identity comparisons.

References

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