refactor(agent): unify sourced message delivery
This commit is contained in:
@@ -6,7 +6,7 @@ Four layers, importable separately:
|
||||
|
||||
- **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy.
|
||||
- **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic.
|
||||
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; cwd-rooted separators selected as canonical `/` or host-native; `session_info_update.updatedAt` → `{{updatedAt}}`; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
|
||||
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; cwd-rooted separators selected as canonical `/` or host-native; `session_info_update.updatedAt` → `{{updatedAt}}`; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
|
||||
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh expands packed timing envelopes before aligning existing volatile event times, so switching between packed and unpacked layouts cannot shift later records; fresh chunk-fragment arrays remain authoritative. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
|
||||
|
||||
A consuming `*.snapshot.ts` is the scenario table plus one factory call:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Pure ACP transcript and session-log normalizers. They scrub session ids, run cwd, RPC ids,
|
||||
* timestamps, and hook duration while preserving deterministic event sequence numbers.
|
||||
* Request-header scrubbers stay composable so one scenario per header class can pin prompt and
|
||||
* tool-schema sidecars while retaining any model-visible prefix in the session log.
|
||||
* tool-schema sidecars.
|
||||
* @module @deepseek-ai/dsh-acp-snapshot/normalize
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,6 @@ const SESSION_ID = '{{sessionId}}'
|
||||
const CWD = '{{cwd}}'
|
||||
const SYSTEM = '{{system}}'
|
||||
const TOOLS = '{{tools}}'
|
||||
const MESSAGE_PREFIX = '{{messagePrefix}}'
|
||||
const UPDATED_AT = '{{updatedAt}}'
|
||||
|
||||
/** A cwd-rooted path after volatile cwd replacement, through its last separator-delimited segment. */
|
||||
@@ -221,14 +220,13 @@ export function scrubToolSchemas(rawLog: string): string {
|
||||
* @returns The JSONL with all header bulk tokenized, other lines byte-identical.
|
||||
*/
|
||||
export function scrubRequestHeaders(rawLog: string): string {
|
||||
return scrubHeaderContent(rawLog, { system: true, tools: true, prefix: true })
|
||||
return scrubHeaderContent(rawLog, { system: true, tools: true })
|
||||
}
|
||||
|
||||
/** Which independent request-header payloads a scrubber replaces. */
|
||||
interface HeaderScrubOptions {
|
||||
system?: boolean
|
||||
tools?: boolean
|
||||
prefix?: boolean
|
||||
}
|
||||
|
||||
/** Transform the selected request-header payloads. */
|
||||
@@ -245,10 +243,6 @@ function scrubHeaderContent(rawLog: string, options: HeaderScrubOptions): string
|
||||
let touched = false
|
||||
if (options.system === true && 'system' in header) { header.system = SYSTEM; touched = true }
|
||||
if (options.tools === true && 'tools' in header) { header.tools = TOOLS; touched = true }
|
||||
if (options.prefix === true && Array.isArray(header.messagePrefix)) {
|
||||
header.messagePrefix = header.messagePrefix.map(() => MESSAGE_PREFIX)
|
||||
touched = true
|
||||
}
|
||||
return touched ? JSON.stringify(record) : line
|
||||
}
|
||||
return line
|
||||
|
||||
@@ -53,9 +53,7 @@ export interface Scenario {
|
||||
* Whether the run persists a comparable session log to diff against the
|
||||
* `session.jsonl` fixture. Defaults to {@link hasModelTurn} (a model turn
|
||||
* always produces a log worth comparing). Set it independently for a scenario
|
||||
* that produces a non-trivial log WITHOUT a model turn — e.g. a prompt blocked
|
||||
* by a `UserPromptSubmit` hook, which opens a `rejected` turn carrying `hook/*`
|
||||
* events but never calls the model.
|
||||
* that produces a non-trivial durable log without calling the model.
|
||||
*/
|
||||
comparesLog?: boolean
|
||||
/**
|
||||
@@ -701,8 +699,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
await expect(stdout, `${expected.file} mismatch`).toMatchFileSnapshot(join(dir, expected.file))
|
||||
}
|
||||
|
||||
// A model turn always produces a log worth comparing; a hook scenario can
|
||||
// produce one without a model turn (a `rejected` turn carrying `hook/*`).
|
||||
// A model turn always produces a log worth comparing; an explicitly
|
||||
// authored non-model scenario may opt in independently.
|
||||
if (comparesLog) {
|
||||
// The harvested logs (primary-first) must match their committed fixtures 1:1.
|
||||
expect(result.sessionLogs.length, 'this scenario must persist one log per session fixture').toBe(fixtureFiles.length)
|
||||
|
||||
@@ -339,25 +339,6 @@ describe('scrubRequestHeaders', () => {
|
||||
expect(toolsOnly).not.toContain('{{system}}')
|
||||
})
|
||||
|
||||
it('scrubs the header session prefix to one token per message, keeping the count', () => {
|
||||
const ev = headerEvent({
|
||||
config: { model: 'm' },
|
||||
messagePrefix: [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'workspace AGENTS digest' }] },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'skills catalog' }] },
|
||||
],
|
||||
})
|
||||
const out = scrubRequestHeaders(`${headerLine}\n${ev}\n`)
|
||||
expect(out).toContain('"messagePrefix":["{{messagePrefix}}","{{messagePrefix}}"]')
|
||||
expect(out).not.toContain('AGENTS digest')
|
||||
expect(out).not.toContain('skills catalog')
|
||||
// Absence stays absent — a prefix-less header gains no token…
|
||||
expect(scrubRequestHeaders(`${headerLine}\n${headerEvent({ system: 's' })}\n`)).not.toContain('{{messagePrefix}}')
|
||||
// …and a non-array shape passes through untouched.
|
||||
const odd = JSON.stringify({ type: 'request/header', seq: 4, time: 9, data: { header: { config: { model: 'm' }, messagePrefix: 'weird' }, reason: 'initial' } })
|
||||
expect(scrubRequestHeaders(`${headerLine}\n${odd}\n`)).toContain('"messagePrefix":"weird"')
|
||||
})
|
||||
|
||||
it('leaves malformed headers with no scrubbable payload byte-identical', () => {
|
||||
const headerless = JSON.stringify({ type: 'request/header', seq: 10, time: 9, data: { reason: 'initial' } })
|
||||
const nullData = JSON.stringify({ type: 'request/header', seq: 11, time: 9, data: null })
|
||||
@@ -376,14 +357,13 @@ describe('scrubRequestHeaders', () => {
|
||||
})
|
||||
|
||||
describe('scrubSystemPrompts', () => {
|
||||
it('scrubs only system prompt payloads while keeping tools and prefixes verbatim', () => {
|
||||
it('scrubs only system prompt payloads while keeping tools verbatim', () => {
|
||||
const header = JSON.stringify({
|
||||
type: 'request/header', seq: 1, time: 2,
|
||||
data: {
|
||||
header: {
|
||||
system: 'full prompt',
|
||||
tools: [{ name: 'read', description: 'full schema' }],
|
||||
messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'full prefix' }] }],
|
||||
},
|
||||
reason: 'initial',
|
||||
},
|
||||
@@ -394,7 +374,6 @@ describe('scrubSystemPrompts', () => {
|
||||
header: {
|
||||
system: 'new prompt',
|
||||
tools: [{ name: 'read', description: 'changed schema' }],
|
||||
messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'changed prefix' }] }],
|
||||
},
|
||||
reason: 'change',
|
||||
},
|
||||
@@ -409,23 +388,20 @@ describe('scrubSystemPrompts', () => {
|
||||
expect(out).not.toContain('full prompt')
|
||||
expect(out).not.toContain('new prompt')
|
||||
expect(out).toContain('full schema')
|
||||
expect(out).toContain('full prefix')
|
||||
expect(out).toContain('changed schema')
|
||||
expect(out).toContain('changed prefix')
|
||||
expect(out.split('\n')[2]).toBe(toolsOnly)
|
||||
expect(scrubSystemPrompts(out)).toBe(out)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrubToolSchemas', () => {
|
||||
it('scrubs only tool-schema payloads while keeping prompts and prefixes verbatim', () => {
|
||||
it('scrubs only tool-schema payloads while keeping prompts verbatim', () => {
|
||||
const header = JSON.stringify({
|
||||
type: 'request/header', seq: 1, time: 2,
|
||||
data: {
|
||||
header: {
|
||||
system: 'full prompt',
|
||||
tools: [{ name: 'read', description: 'full schema', parameters: { type: 'object' } }],
|
||||
messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'full prefix' }] }],
|
||||
},
|
||||
reason: 'initial',
|
||||
},
|
||||
@@ -436,7 +412,6 @@ describe('scrubToolSchemas', () => {
|
||||
header: {
|
||||
system: 'new prompt',
|
||||
tools: [{ name: 'grep', description: 'new schema' }],
|
||||
messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'changed prefix' }] }],
|
||||
},
|
||||
reason: 'change',
|
||||
},
|
||||
@@ -452,8 +427,6 @@ describe('scrubToolSchemas', () => {
|
||||
expect(out).not.toContain('new schema')
|
||||
expect(out).toContain('full prompt')
|
||||
expect(out).toContain('new prompt')
|
||||
expect(out).toContain('full prefix')
|
||||
expect(out).toContain('changed prefix')
|
||||
expect(out.split('\n')[2]).toBe(systemOnly)
|
||||
expect(scrubToolSchemas(out)).toBe(out)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user