Skip to content

Assert the recorded model request on replay in the agent host E2E suite - #327955

Draft
roblourens wants to merge 12 commits into
mainfrom
roblou/agents/e2e-assert-model-requests
Draft

Assert the recorded model request on replay in the agent host E2E suite#327955
roblourens wants to merge 12 commits into
mainfrom
roblou/agents/e2e-assert-model-requests

Conversation

@roblourens

Copy link
Copy Markdown
Member

CapiReplayProxy selects a recorded response ordinally — the Nth request to an endpoint replays the Nth recorded response. Nothing ever compared what was actually asked.

That left the request body completely unasserted, and the request body is the host's own product: prompt assembly, retained conversation history, truncation, attachment marshalling, and how tool results are handed back to the model. A regression in any of it replayed green, and was silently promoted to the new expected value the next time somebody re-recorded. Several multiChat tests already hand-rolled assertions over observedModelRequestBodies to compensate — that was the signal the gap was real.

What changed

Response selection stays ordinal (matching on request bodies would be brittle against volatile fields and would desync the agent loop). Separately, every replayed turn now compares the live request against the recorded one through a projection in harness/modelRequestProjection.ts.

The same projection is applied to both sides, so captures keep their existing shape and stay readable — the previously decorative request: block simply became executable.

Asserted (host-authored) Elided (unreproducible or environment-derived)
Message roles and ordering tool_result payloads
Retained history Run-time identifiers
Whether a system prompt was sent Reasoning blocks
Text and attachment content The model id
Tool names, inputs, tool_use_id wiring

Every elision is load-bearing, and each was established by running the assertion against the committed captures rather than guessed:

  • tool_result payloads would reintroduce exactly the platform coupling the portable-command work removed — command output, line endings and listing formats all differ per OS — and would need a per-tool normalizer layer to stay stable. Presence and wiring are asserted; the text is not.
  • Reasoning blocks cannot survive the capture round-trip: aggregating a recorded reply drops them, so the assistant turn replayed back to the agent never carries one even though the live recording did.
  • Run-time identifiers are stored as ordinals (${uuid_0}) assigned at write time, which a live run cannot reproduce.
  • The model id tracks the provider default and the catalog rather than anything the host composes, so asserting it would break every capture on an unrelated model bump. Captures still record it for review.

What it found immediately

Switching it on surfaced seven stale captures:

  • Four whose prompt text had been edited without re-recording (drifted in Make the deterministic shell command E2E test platform-neutral #327642).
  • One that had captured a one-off ordering of two parallel tool_result blocks (verified the live ordering is stable across three runs before re-recording, rather than weakening the assertion by sorting).
  • One Codex capture still carrying the pre-pinned pwd prompt.
  • Claude's side-chat capture, which cannot be refreshed: recording it hits the known provider-context fork defect (Invalid upToMessageId) that already gates supportsChatForkE2E. It is listed in STALE_RECORDED_REQUEST_EXCEPTIONS — keyed by provider and test title, so it silences only the capture it was written for — and documented in KNOWN_ISSUES.md with a re-enable path.

The first six are re-recorded here.

Validation

Conformance 52, Copilot 49, Claude 44, Codex 8 — 0 failing, re-verified after merging main. 23 harness unit tests. typecheck-client and hygiene clean.

Independent of #327934 — no textual conflicts; the two can land in either order.

(Written by Copilot)

roblourens and others added 10 commits July 27, 2026 21:54
Snapshot and capture normalization ended with an unanchored
`replaceAll(userName, '${user}')`. With the GitHub Actions Linux account
name `runner` — an ordinary English word — captured text such as
`the runner completed` was rewritten to `the ${user} completed`.

That is worse than cosmetic. It only misfires on platforms whose account
name happens to be a common word, so the same recording normalizes
differently on macOS and Linux CI, which is exactly the cross-platform
mismatch this normalization exists to prevent.

Replace only the two positions where the name genuinely identifies a
user: after a path separator (including the escaped form found in
embedded JSON), and the owner/group columns of an `ls -l` listing. The
listing case is load-bearing — the committed subagent capture records the
account name there, outside any path — so it cannot simply be dropped.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The test was disabled on Windows because its temporary git repository
could not be deleted, which failed suite teardown even though every
assertion passed. Its assertions were always platform-independent.

Two independent causes, both now addressed:

- Read-only `.git/objects` files. Fixed by clearing read-only attributes
  before retrying removal, which also unblocked `inspects git status`.
- Background `git gc` holding handles under `.git` after the test
  finishes. `initTestGitRepo` now sets `gc.auto 0`; these repositories
  never create enough objects to need it.

Collapse the duplicated init/identity setup in three suites into
`initTestGitRepo` so the gc setting cannot be forgotten by the next test
that needs a repository.

Whether this holds can only be confirmed on a Windows run; the remaining
risk is a handle neither mechanism covers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The comment claiming this test could not be made portable was wrong on
two of its three counts:

- The path assertions use `URI.fsPath`, which is platform-native, and the
  expected value is derived at runtime from the host's own `sessionAdded`
  notification rather than hardcoded as a POSIX path.
- The recorded `pwd` is only reached when the provider routes commands
  through the host terminal tool, which on Windows is PowerShell, where
  `pwd` is an alias for `Get-Location`.

The third — that pinning a command stops the turn on an unanswered
permission prompt — was observed in a different test. This one already
handles confirmation, dispatching `ChatToolCallConfirmed` when
`toolCallReady` arrives unconfirmed.

Drop `pwd` from the record-time command blocklist for the same reason,
which empties `POSIX_COMMAND_EXCEPTIONS`; a recording now passes the check
without an opt-out.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…mmand

Tried replacing `pwd` with `node -e "console.log(process.cwd())"` on both
of the test's turns. Both stalled on `session/inputNeededSet`: providers
auto-approve `pwd` as a safe read-only command, and an arbitrary `node -e`
invocation is not on that list, so pinning turns a silent tool call into
one that waits for a confirmation this test's flow does not answer — even
on the turn that does dispatch `ChatToolCallConfirmed`.

`pwd` is portable regardless, since PowerShell defines it as an alias for
`Get-Location`, so the test still runs on Windows.

Correct the stale comment, which still claimed the assertions were
POSIX-shaped, and record the general point: pinning a command changes its
permission posture, so prefer steering to a file tool where one exists.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Answers why pinning `node -e` failed here when it works in every other
ported test: those all go through `driveTurnToCompletion`, which confirms
each unconfirmed `chat/toolCallReady` as it arrives. This test drives its
turn by hand, and its host-terminal branch approved exactly once while its
SDK-shell branch already used `startBackgroundApprovalLoop`.

A pinned command is not on the provider's auto-approve list the way `pwd`
is, so it adds an approval round-trip. With single-shot approval a later
request stays pending and the turn stalls on `session/inputNeededSet`,
which reads as a hang rather than a permission problem.

Both branches now use the shared loop, and the command is pinned like
everywhere else. Verified by re-recording both providers.

This also removes the last special case: no test now depends on a command
being auto-approved, and `POSIX_COMMAND_EXCEPTIONS` stays empty.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Windows CI failed it for two reasons, neither about command portability,
and both specific to this test's output assertions:

- The expected path comes from `os.tmpdir()`, which on Windows CI returns
  an 8.3 short form (`C:\Users\CLOUDT~1\...`) while the shell reports the
  long form, so the `includes()` assertion can never match and the wait
  times out. Same class of mismatch as `/var` versus `/private/var` on
  macOS, which snapshot normalization already special-cases.
- The Copilot branch waits for a `chat/toolCallContentChanged` carrying a
  terminal resource, and on Windows that notification never arrives even
  though the tool call starts, is confirmed, and completes.

Keep the pinned command and the shared approval loop from the previous
commit — both are correct regardless of platform — and record what CI
showed so the next attempt starts from evidence rather than a fresh guess.

Reworking this needs `realpathSync.native` on both sides of the path
comparison, and the missing terminal content understood first; neither is
reproducible without a Windows machine.

`session configuration resolves and completes git branches` passed on
Windows in the same run, so the git lock fixes hold.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Response selection stays ordinal, but every replayed turn now compares the
live model request against the one recorded in the capture, through a
projection applied symmetrically to both sides. Captures keep their existing
shape and stay readable.

The request body is the host's own product - prompt assembly, retained
history, truncation, attachment marshalling, and tool-result hand-back - and
none of it was checked before, so a regression replayed green and became the
new expected value at the next re-record.

Asserts host-authored structure (roles and ordering, retained history, system
prompt presence, text and attachment content, tool names/inputs, tool_use_id
wiring) and elides what replay cannot reproduce or what is environment-derived
(tool_result payloads, run-time identifiers, reasoning blocks, the model id).

Switching this on found seven stale captures: four whose prompt text had been
edited without re-recording, one holding a one-off ordering of parallel
tool_result blocks, and one Codex capture still carrying the pre-pinned pwd
prompt - all re-recorded here. The seventh, Claude's side-chat capture, cannot
be refreshed because recording it hits the known provider-context fork defect,
so it is listed as an exception and documented in KNOWN_ISSUES.

(Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ert-model-requests

# Conflicts:
#	src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts
#	src/vs/platform/agentHost/test/node/e2e/harness/capiReplayProxy.ts
Copilot AI review requested due to automatic review settings July 29, 2026 03:32
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Screenshot Changes

Base: 93b7a210 Current: baaeb4a8

Changed (6)

agentsVoice/voiceModeOnboarding/Voice Mode onboarding (panel)/Dark
Before After
before after
agentsVoice/voiceModeOnboarding/Voice Mode onboarding (panel)/Light
Before After
before after
agentsVoice/voiceModeOnboarding/Voice Mode onboarding (wide)/Dark
Before After
before after
agentsVoice/voiceModeOnboarding/Voice Mode onboarding (wide)/Light
Before After
before after
agentsVoice/voiceModeOnboarding/Voice Mode onboarding (narrow)/Dark
Before After
before after
agentsVoice/voiceModeOnboarding/Voice Mode onboarding (narrow)/Light
Before After
before after

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

This PR makes agent host E2E replay fixtures assert the recorded model request (via a stable projection) in addition to selecting responses ordinally, closing a gap where prompt/request regressions could replay green.

Changes:

  • Add a request projection + mismatch reporting and enforce it during replayed model turns in CapiReplayProxy.
  • Extract shell tool name placeholder logic into a dedicated helper module and update related tests.
  • Re-record/update several E2E capture fixtures and expand documentation for the new assertion behavior.
Show a summary per file
File Description
src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts Plumbs allowStaleRecordedRequest through server start options into the replay proxy.
src/vs/platform/agentHost/test/node/modelRequestProjection.test.ts Adds unit tests for the request projection/matching/mismatch formatting behavior.
src/vs/platform/agentHost/test/node/e2e/harness/modelRequestProjection.ts Implements projection logic to compare live vs recorded requests while eliding unstable fields.
src/vs/platform/agentHost/test/node/e2e/harness/shellToolNames.ts Centralizes platform-neutral shell tool naming normalization/expansion.
src/vs/platform/agentHost/test/node/e2e/harness/capiReplayProxy.ts Captures recorded requests per turn and asserts replayed requests against them; improves replay error reporting.
src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts Adds per-capture stale-request exception handling and wires replay error lifecycle to the test lease.
src/vs/platform/agentHost/test/node/e2e/README.md Documents model request assertion behavior and new replay mismatch error mode.
src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md Updates known-issues guidance and documents the stale-capture exception rationale.
src/vs/platform/agentHost/test/node/capiReplayProxyShellTools.test.ts Updates imports to the new shellToolNames module.
src/vs/platform/agentHost/test/node/e2e/**/captures/*.yaml Updates recorded fixtures to match current host request behavior and tool/result ordering.
src/vs/platform/agentHost/test/node/e2e/providers/snapshots/*.yaml Updates provider traffic snapshot to reflect fixture/toolcall ordering changes.

Review details

  • Files reviewed: 18/18 changed files
  • Comments generated: 3
  • Review effort level: Low

Comment on lines +128 to +131
/** Compares two projections. */
export function modelRequestsMatch(expected: IProjectedModelRequest, actual: IProjectedModelRequest): boolean {
return JSON.stringify(expected) === JSON.stringify(actual);
}
Comment on lines +120 to +126
/** Projects a readable model request down to its host-authored structure. */
export function projectModelRequest(request: IReadableAnthropicRequest): IProjectedModelRequest {
return {
system: request.system,
messages: request.messages.map(message => ({ role: message.role, content: projectContent(message.content) })),
};
}
Comment on lines +105 to +117
return content.filter(block => !isReasoningBlock(block)).map(block => {
const b = block as { type?: string; text?: string; name?: string; input?: unknown; tool_use_id?: string };
switch (b.type) {
case 'text':
return { type: 'text', text: elideRuntimeIds(b.text ?? '') };
case 'tool_use':
return { type: 'tool_use', name: normalizeShellToolNameForCapture(b.name ?? ''), input: projectValue(b.input) };
case 'tool_result':
return { type: 'tool_result', tool_use_id: elideRuntimeIds(b.tool_use_id ?? ''), content: TOOL_RESULT_PLACEHOLDER };
default:
return { type: b.type };
}
});
roblourens and others added 2 commits July 28, 2026 20:53
Windows CI failed three model-request assertions. None was a host regression:
each was the same file addressed differently than the macOS-recorded capture.

Three separate spellings were involved - an unsubstituted \${workdir}, an
unsubstituted \${homedir}, and a backslash separator - which is the point: a
directory has too many per-machine spellings (separator, drive letter, 8.3
short names, /var vs /private/var, and whether the recorder substituted its
placeholder at all) to be compared literally across platforms.

The projection now collapses a path to \${path}/<basename>. Which file the
host referenced stays asserted; where it lives does not. A different filename
is still a mismatch.

Also fixes a real gap in the recorder: \`homeDir\` was normalized only in its
raw form, while \`workDir\` also had a JSON-escaped variant, so a Windows home
directory embedded in a JSON body was never substituted.

(Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The remaining Windows failure was a recorded bare `${workdir}` (the workspace
directory itself) against a live raw directory path. Keeping the basename
cannot reconcile those two, because the live basename is the temp directory's
own name.

Paths are now elided entirely rather than reduced to their filename. A path has
too many per-machine spellings to compare literally - separator, drive letter,
8.3 short names, /var vs /private/var, whether the recorder substituted its
placeholder, and whether the value is a file or the directory itself - and two
rounds of Windows CI failures came from exactly those, none a real regression.

This is a deliberate narrowing: the projection no longer asserts which file a
prompt referenced. That is a weaker oracle than it looks, because the tests
that care assert the filesystem effect directly. The instruction wrapped around
the path is still compared.

(Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants