fix(agent): correct session-prefix composition-order docs; scrub messagePrefix in snapshots
Two ds-review-bot findings:
The seam JSDoc claimed extending 'await next()' composes in
registration order — false for the append form: the waterfall unwinds
innermost-first, so appending places later-registered contributions
first. The canonical contribution is now documented as the PREPEND
'[mine, ...await next()]' (registration order on the wire), with the
append form's reverse-order behavior stated explicitly; the ordering
test now uses the canonical pattern in both listeners.
scrubRequestHeaders tokenized only system/tools, so a fixture recording
a composed session prefix would carry its raw text (workspace-specific
churn/leak). The scrubber now maps each header/delta messagePrefix
entry to a {{messagePrefix}} token — count stays a structural fact,
absence stays absent, the empty-array transition stays visible — with
normalize.spec coverage for the header, delta, absence, and odd-shape
paths.
This commit is contained in:
@@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
|
|||||||
|
|
||||||
Types: [Agent](../core-data-structures/core.md)
|
Types: [Agent](../core-data-structures/core.md)
|
||||||
|
|
||||||
Source: [`packages/core/agent/src/types.ts:455`](../../packages/core/agent/src/types.ts)
|
Source: [`packages/core/agent/src/types.ts:460`](../../packages/core/agent/src/types.ts)
|
||||||
|
|
||||||
### `agent/pre-step` — serial
|
### `agent/pre-step` — serial
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ Waterfall: compose the SESSION PREFIX — request-only messages placed in front
|
|||||||
|
|
||||||
This is the home for session-stable openers the model must always see but that must NOT become durable history — a skills catalog, an AGENTS.md digest, a workspace baseline: `Session.deriveMessages()` never returns the prefix, and the header events are its only durable record, so the request stays reconstructable from the log. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, a `tools/post-execute` decision's `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter.
|
This is the home for session-stable openers the model must always see but that must NOT become durable history — a skills catalog, an AGENTS.md digest, a workspace baseline: `Session.deriveMessages()` never returns the prefix, and the header events are its only durable record, so the request stays reconstructable from the log. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, a `tools/post-execute` decision's `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter.
|
||||||
|
|
||||||
The seed is a frozen empty list; a contributing listener returns a NEW array extending `await next()` (`[...prefix, mine]` — never an in-place push), so contributions compose across plugins in registration order and compose deterministically for a fixed plugin set. Call `next()` to delegate, or return a list without it to short-circuit.
|
The seed is a frozen empty list; a contributing listener returns a NEW array — never an in-place push. The canonical contribution is a PREPEND, `[mine, ...await next()]`: the waterfall unwinds innermost-first (the LAST-registered listener's `next()` resolves first), so prepending yields registration order on the wire, and every plugin using it composes deterministically. The append form `[...await next(), mine]` is legal but places a contribution AFTER every later-registered plugin's — reverse registration order when all contributors append. Call `next()` to delegate, or return a list without it to short-circuit.
|
||||||
|
|
||||||
```ts cordis-catalog
|
```ts cordis-catalog
|
||||||
'agent/session-prefix'(agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
|
'agent/session-prefix'(agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
|
||||||
@@ -113,7 +113,7 @@ The seed is a frozen empty list; a contributing listener returns a NEW array ext
|
|||||||
|
|
||||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
||||||
|
|
||||||
Source: [`packages/core/agent/src/types.ts:420`](../../packages/core/agent/src/types.ts)
|
Source: [`packages/core/agent/src/types.ts:425`](../../packages/core/agent/src/types.ts)
|
||||||
|
|
||||||
### `agent/session-start` — emit
|
### `agent/session-start` — emit
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va
|
|||||||
|
|
||||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
||||||
|
|
||||||
Source: [`packages/core/agent/src/types.ts:430`](../../packages/core/agent/src/types.ts)
|
Source: [`packages/core/agent/src/types.ts:435`](../../packages/core/agent/src/types.ts)
|
||||||
|
|
||||||
### `agent/turn-continuation` — waterfall
|
### `agent/turn-continuation` — waterfall
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis
|
|||||||
|
|
||||||
Types: [Agent](../core-data-structures/core.md)
|
Types: [Agent](../core-data-structures/core.md)
|
||||||
|
|
||||||
Source: [`packages/core/agent/src/types.ts:443`](../../packages/core/agent/src/types.ts)
|
Source: [`packages/core/agent/src/types.ts:448`](../../packages/core/agent/src/types.ts)
|
||||||
|
|
||||||
## `fs/*`
|
## `fs/*`
|
||||||
|
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
|||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:265`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:265`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:272`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:272`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:455`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:460`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||||
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) |
|
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) |
|
||||||
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:363`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:363`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||||
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:290`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:290`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:387`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:387`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||||
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:420`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:425`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:305`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:305`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||||
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:281`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) |
|
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:281`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||||
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:430`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:435`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||||
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:443`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:448`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||||
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
|
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||||
|
|||||||
@@ -352,23 +352,24 @@ describe('agent/session-prefix', () => {
|
|||||||
expect(agent.session.deriveMessages()[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'go' }] })
|
expect(agent.session.deriveMessages()[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'go' }] })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('contributions compose across listeners in registration order', async () => {
|
it('the canonical prepend pattern composes contributions in registration order', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||||
|
|
||||||
|
// Both listeners use the canonical `[mine, ...await next()]` prepend: the
|
||||||
|
// waterfall unwinds innermost-first (the second listener's array is built
|
||||||
|
// first), so prepending puts the FIRST-registered contribution first.
|
||||||
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
|
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
|
||||||
return [{ role: 'user', content: [{ type: 'text', text: 'first' }] }, ...await next()]
|
return [{ role: 'user', content: [{ type: 'text', text: 'first' }] }, ...await next()]
|
||||||
})
|
})
|
||||||
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
|
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
|
||||||
return [...await next(), { role: 'user', content: [{ type: 'text', text: 'second' }] }]
|
return [{ role: 'user', content: [{ type: 'text', text: 'second' }] }, ...await next()]
|
||||||
})
|
})
|
||||||
|
|
||||||
send(agent, 'hi')
|
send(agent, 'hi')
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
// Registration order composes: the first listener runs last on the way
|
|
||||||
// out (waterfall), so its prepend lands first.
|
|
||||||
const texts = adapter.requests[0]!.messages.map(m => m.content[0]?.type === 'text' ? m.content[0].text : '')
|
const texts = adapter.requests[0]!.messages.map(m => m.content[0]?.type === 'text' ? m.content[0].text : '')
|
||||||
expect(texts).toEqual(['first', 'second', 'hi'])
|
expect(texts).toEqual(['first', 'second', 'hi'])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -408,9 +408,14 @@ declare module 'cordis' {
|
|||||||
* durable `context/message` paid once and prefix-cached thereafter.
|
* durable `context/message` paid once and prefix-cached thereafter.
|
||||||
*
|
*
|
||||||
* The seed is a frozen empty list; a contributing listener returns a NEW
|
* The seed is a frozen empty list; a contributing listener returns a NEW
|
||||||
* array extending `await next()` (`[...prefix, mine]` — never an in-place
|
* array — never an in-place push. The canonical contribution is a
|
||||||
* push), so contributions compose across plugins in registration order
|
* PREPEND, `[mine, ...await next()]`: the waterfall unwinds
|
||||||
* and compose deterministically for a fixed plugin set. Call `next()` to
|
* innermost-first (the LAST-registered listener's `next()` resolves
|
||||||
|
* first), so prepending yields registration order on the wire, and every
|
||||||
|
* plugin using it composes deterministically. The append form
|
||||||
|
* `[...await next(), mine]` is legal but places a contribution AFTER
|
||||||
|
* every later-registered plugin's — reverse registration order when all
|
||||||
|
* contributors append. Call `next()` to
|
||||||
* delegate, or return a list without it to short-circuit.
|
* delegate, or return a list without it to short-circuit.
|
||||||
* @param agent - the agent whose session prefix is being composed.
|
* @param agent - the agent whose session prefix is being composed.
|
||||||
* @param prefix - the frozen empty seed; return an extended replacement to contribute.
|
* @param prefix - the frozen empty seed; return an extended replacement to contribute.
|
||||||
|
|||||||
@@ -13,8 +13,9 @@
|
|||||||
* (deterministic — `seq = log.length`, part of the event-log contract).
|
* (deterministic — `seq = log.length`, part of the event-log contract).
|
||||||
*
|
*
|
||||||
* A separate, composable normalizer — {@link scrubRequestHeaders} — replaces
|
* A separate, composable normalizer — {@link scrubRequestHeaders} — replaces
|
||||||
* the bulky request-header CONTENT (the composed system prompt and the tool
|
* the bulky request-header CONTENT (the composed system prompt, the tool
|
||||||
* schema list) with `{{system}}`/`{{tools}}` tokens. It is deliberately NOT
|
* schema list, and the session prefix) with
|
||||||
|
* `{{system}}`/`{{tools}}`/`{{messagePrefix}}` tokens. It is deliberately NOT
|
||||||
* folded into {@link normalizeSessionLog}: each suite's one header-pinning
|
* folded into {@link normalizeSessionLog}: each suite's one header-pinning
|
||||||
* scenario compares that content verbatim, every other scenario composes the
|
* scenario compares that content verbatim, every other scenario composes the
|
||||||
* scrub in (the `pinsHeader` flag on the scenario table, consumed by the suite
|
* scrub in (the `pinsHeader` flag on the scenario table, consumed by the suite
|
||||||
@@ -30,6 +31,7 @@ const SESSION_ID = '{{sessionId}}'
|
|||||||
const CWD = '{{cwd}}'
|
const CWD = '{{cwd}}'
|
||||||
const SYSTEM = '{{system}}'
|
const SYSTEM = '{{system}}'
|
||||||
const TOOLS = '{{tools}}'
|
const TOOLS = '{{tools}}'
|
||||||
|
const MESSAGE_PREFIX = '{{messagePrefix}}'
|
||||||
|
|
||||||
/** A UUID v4 string, the shape `randomUUID()` produces for session ids. */
|
/** A UUID v4 string, the shape `randomUUID()` produces for session ids. */
|
||||||
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
|
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
|
||||||
@@ -135,15 +137,22 @@ export function normalizeSessionLog(rawLog: string, ctx: NormalizeContext): stri
|
|||||||
/**
|
/**
|
||||||
* Replace request-header CONTENT in a session JSONL with stable tokens,
|
* Replace request-header CONTENT in a session JSONL with stable tokens,
|
||||||
* keeping its structure: a `request/header` event's `data.header.system` →
|
* keeping its structure: a `request/header` event's `data.header.system` →
|
||||||
* `{{system}}` and `data.header.tools` → `{{tools}}`; a
|
* `{{system}}`, `data.header.tools` → `{{tools}}`, and
|
||||||
|
* `data.header.messagePrefix` → one `{{messagePrefix}}` token per message
|
||||||
|
* (the session prefix is model-visible bulk — an AGENTS digest, a skills
|
||||||
|
* catalog — so its COUNT stays a structural fact while its text never lands
|
||||||
|
* in a fixture); a
|
||||||
* `request/header-delta` event keeps every structural fact — the system
|
* `request/header-delta` event keeps every structural fact — the system
|
||||||
* delta's `keepStart`/`keepEnd` line positions and inserted-line COUNT (one
|
* delta's `keepStart`/`keepEnd` line positions and inserted-line COUNT (one
|
||||||
* `{{system}}` token per inserted line), the tools delta's
|
* `{{system}}` token per inserted line), the tools delta's
|
||||||
* added/removed/changed tool NAMES — and tokenizes only the bulk (prompt
|
* added/removed/changed tool NAMES, the prefix replacement's message COUNT —
|
||||||
* text; each added/changed schema's fields other than `name` → `{{tools}}`),
|
* and tokenizes only the bulk (prompt
|
||||||
|
* text; each added/changed schema's fields other than `name` → `{{tools}}`;
|
||||||
|
* each replacement prefix message → `{{messagePrefix}}`),
|
||||||
* so two different deltas still compare different.
|
* so two different deltas still compare different.
|
||||||
* Absent fields stay absent — WHETHER a header carried a system prompt or
|
* Absent fields stay absent — WHETHER a header carried a system prompt,
|
||||||
* tools is behavior and stays visible; `config` and `reason` are small and
|
* tools, or a prefix is behavior and stays visible; `config` and `reason`
|
||||||
|
* are small and
|
||||||
* stable, so they stay verbatim (a model swap churns every fixture by design
|
* stable, so they stay verbatim (a model swap churns every fixture by design
|
||||||
* — it invalidates the recorded responses; a prompt/schema edit churns none —
|
* — it invalidates the recorded responses; a prompt/schema edit churns none —
|
||||||
* replay never reads this content, see dsh-llm-replay).
|
* replay never reads this content, see dsh-llm-replay).
|
||||||
@@ -166,9 +175,10 @@ export function scrubRequestHeaders(rawLog: string): string {
|
|||||||
if (record.type === 'request/header') {
|
if (record.type === 'request/header') {
|
||||||
const header = data.header as Record<string, unknown> | null | undefined
|
const header = data.header as Record<string, unknown> | null | undefined
|
||||||
if (header === null || typeof header !== 'object') return line
|
if (header === null || typeof header !== 'object') return line
|
||||||
if (!('system' in header) && !('tools' in header)) return line
|
if (!('system' in header) && !('tools' in header) && !('messagePrefix' in header)) return line
|
||||||
if ('system' in header) header.system = SYSTEM
|
if ('system' in header) header.system = SYSTEM
|
||||||
if ('tools' in header) header.tools = TOOLS
|
if ('tools' in header) header.tools = TOOLS
|
||||||
|
if (Array.isArray(header.messagePrefix)) header.messagePrefix = header.messagePrefix.map(() => MESSAGE_PREFIX)
|
||||||
return JSON.stringify(record)
|
return JSON.stringify(record)
|
||||||
}
|
}
|
||||||
if (record.type === 'request/header-delta') {
|
if (record.type === 'request/header-delta') {
|
||||||
@@ -183,6 +193,10 @@ export function scrubRequestHeaders(rawLog: string): string {
|
|||||||
if (Array.isArray(tools.added)) { tools.added = tools.added.map(scrubToolSchema); touched = true }
|
if (Array.isArray(tools.added)) { tools.added = tools.added.map(scrubToolSchema); touched = true }
|
||||||
if (Array.isArray(tools.changed)) { tools.changed = tools.changed.map(scrubToolSchema); touched = true }
|
if (Array.isArray(tools.changed)) { tools.changed = tools.changed.map(scrubToolSchema); touched = true }
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(data.messagePrefix)) {
|
||||||
|
data.messagePrefix = data.messagePrefix.map(() => MESSAGE_PREFIX)
|
||||||
|
touched = true
|
||||||
|
}
|
||||||
return touched ? JSON.stringify(record) : line
|
return touched ? JSON.stringify(record) : line
|
||||||
}
|
}
|
||||||
return line
|
return line
|
||||||
|
|||||||
@@ -155,6 +155,38 @@ describe('scrubRequestHeaders', () => {
|
|||||||
expect(toolsOnly).not.toContain('{{system}}')
|
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('scrubs a header-delta prefix replacement to one token per message', () => {
|
||||||
|
const delta = JSON.stringify({
|
||||||
|
type: 'request/header-delta', seq: 8, time: 9,
|
||||||
|
data: { messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'leaked opener' }] }] },
|
||||||
|
})
|
||||||
|
const out = scrubRequestHeaders(`${headerLine}\n${delta}\n`)
|
||||||
|
expect(out).toContain('"messagePrefix":["{{messagePrefix}}"]')
|
||||||
|
expect(out).not.toContain('leaked opener')
|
||||||
|
// The empty-array transition-to-absence stays a structural fact.
|
||||||
|
const toNone = JSON.stringify({ type: 'request/header-delta', seq: 9, time: 9, data: { messagePrefix: [] } })
|
||||||
|
expect(scrubRequestHeaders(`${headerLine}\n${toNone}\n`)).toContain('"messagePrefix":[]')
|
||||||
|
})
|
||||||
|
|
||||||
it('leaves a delta with no scrubbable payload byte-identical (config-only, or non-array shapes)', () => {
|
it('leaves a delta with no scrubbable payload byte-identical (config-only, or non-array shapes)', () => {
|
||||||
const configOnly = JSON.stringify({ type: 'request/header-delta', seq: 8, time: 9, data: { config: { model: 'm2' } } })
|
const configOnly = JSON.stringify({ type: 'request/header-delta', seq: 8, time: 9, data: { config: { model: 'm2' } } })
|
||||||
const oddShapes = JSON.stringify({ type: 'request/header-delta', seq: 9, time: 9, data: { system: { insert: 'not-an-array' }, tools: null } })
|
const oddShapes = JSON.stringify({ type: 'request/header-delta', seq: 9, time: 9, data: { system: { insert: 'not-an-array' }, tools: null } })
|
||||||
|
|||||||
Reference in New Issue
Block a user