Merge remote-tracking branch 'origin/master' into codex/invariant-service-seam
This commit is contained in:
@@ -34,7 +34,7 @@ The store pairs announced creation with disposal, publishes post-commit append n
|
||||
|
||||
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
|
||||
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, and complete replacement coverage, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
|
||||
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, complete replacement coverage, and content-only single-result `tool/result` rewrites, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
|
||||
- `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over shared frozen messages. Assistant projections preserve provider/model provenance and adapter-private replay state. A surface rewrite rebuilds the projection; there is no raw-log fallback.
|
||||
- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and request checks.
|
||||
- `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite.
|
||||
@@ -51,14 +51,14 @@ Durable values need one accepted representation, not a check followed by a secon
|
||||
- `SurfaceOp` — how an event entered the ordered surface: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace entries from `start` through `end` inclusive — both must be valid surface seqs; `start === end` replaces one entry). Used by compaction to shadow old events without deleting them.
|
||||
- `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types.
|
||||
- `SessionSurface` — the readonly live `nodes` and `replaceGeneration` projection exposed by `session.surface`; candidate validation remains private to `Session`.
|
||||
- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, and replacements that fail to cite every shadowed surface entry; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache.
|
||||
- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, replacements that fail to cite every shadowed surface entry, and a `tool/result` replacement that changes anything except one current result's `content`; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache.
|
||||
- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event; the second detects a surface-eligible event missing its marker when validating a seed or loaded log.
|
||||
|
||||
### Request-header reconstruction (`request-header.ts`)
|
||||
|
||||
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. `messagePrefix` remains separate from derived history. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
|
||||
|
||||
`context/message` defaults to the canonical tagged context projection. A producer may set `envelope: 'raw'` when its `content` already contains the complete model-facing frame, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`.
|
||||
`context/message` renders its `content` verbatim as a user-role message, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`.
|
||||
|
||||
### Session event vocabulary (`types.ts`)
|
||||
|
||||
@@ -81,7 +81,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
|
||||
|
||||
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
|
||||
- Replay/fork: `create(id, { seed })` validates and freezes a contiguous current-format log and rebuilds its surface; request headers require provider/model and assistant messages require provider/model provenance. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage.
|
||||
- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface entries behind a summary checkpoint. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns ordered surface membership and `replaceGeneration`.
|
||||
- Compaction: `dsh-compact-basic` appends a `user/message` replacement for summary checkpoints, while `dsh-compact-tool-result-prune` appends a content-only `tool/result` replacement. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns ordered surface membership, replacement validation, and `replaceGeneration`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -89,7 +89,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The model receives projections of `user/message`, `assistant/message`, and `tool/result` surface entries verbatim. A `context/message` is a user-role message containing exactly `<context source="<source-kind>">`, its content blocks, and `</context>`; `steering/message` uses the identical `<steering source="<source-kind>">` / `</steering>` wrapper. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
|
||||
The model receives projections of `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` surface entries verbatim: each is a user- or assistant-role message carrying its content blocks unchanged. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ import { isAbsolute } from 'node:path'
|
||||
import { deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
|
||||
import type { ContextEnvelope, CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import { snapshotJsonValue } from './json.ts'
|
||||
import { SurfaceManager } from './surface.ts'
|
||||
import type { SessionSurface } from './surface.ts'
|
||||
@@ -80,21 +80,6 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render injected context as tagged synthetic user-role content, keeping the
|
||||
* canonical session vocabulary provider-neutral. Adapter-specific exceptions
|
||||
* belong in the adapter.
|
||||
*/
|
||||
function renderTagged(tag: string, content: ContentBlock[], source: MessageSource): ContentBlock[] {
|
||||
const open = `<${tag} source=${JSON.stringify(source.kind)}>`
|
||||
const close = `</${tag}>`
|
||||
return [
|
||||
{ type: 'text', text: open },
|
||||
...content,
|
||||
{ type: 'text', text: close },
|
||||
]
|
||||
}
|
||||
|
||||
/** Detach, validate, and freeze the creation metadata published by a session. */
|
||||
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
|
||||
const input: unknown = source === undefined
|
||||
@@ -228,22 +213,6 @@ interface SessionEntry {
|
||||
/** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */
|
||||
const attachments = new WeakMap<Session, SessionEntry>()
|
||||
|
||||
/**
|
||||
* Render one context contribution exactly as it will appear in model history.
|
||||
* @param content - content blocks supplied by the context producer.
|
||||
* @param source - attribution used by the canonical context envelope.
|
||||
* @param envelope - canonical tagged framing or caller-owned raw framing.
|
||||
* @returns a detached block list ready for the derived model transcript.
|
||||
*/
|
||||
export function renderContextContent(
|
||||
content: ContentBlock[],
|
||||
source: MessageSource,
|
||||
envelope: ContextEnvelope = 'context',
|
||||
): ContentBlock[] {
|
||||
const cloned = structuredClone(content)
|
||||
return envelope === 'raw' ? cloned : renderTagged('context', cloned, source)
|
||||
}
|
||||
|
||||
/**
|
||||
* An event-sourced session: an append-only log of {@link SessionEvent}s.
|
||||
*
|
||||
@@ -509,7 +478,18 @@ export class Session {
|
||||
// trace/replay data.
|
||||
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
// Injected context and mid-turn steering project identically to a user
|
||||
// prompt: content verbatim, in user role. context's `source`/`meta` and
|
||||
// steering's `turn` are log-only and do not reach the model. Do NOT
|
||||
// re-add per-type framing (e.g. `<context>`/`<steering>`) here: framing is
|
||||
// caller-owned — a producer bakes it into `content`, as workspace-context
|
||||
// does with `<system-reminder>` — or, if reintroduced, must be driven by
|
||||
// the event `meta` map and a dedicated renderer, keeping this projection a
|
||||
// verbatim pass-through. See the deferred design note in
|
||||
// ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md
|
||||
case 'user/message':
|
||||
case 'context/message':
|
||||
case 'steering/message': {
|
||||
return { role: 'user', content: event.data.content }
|
||||
}
|
||||
case 'assistant/message': {
|
||||
@@ -526,14 +506,6 @@ export class Session {
|
||||
content: [{ type: 'tool-result', toolCallId: callId, content, isError }],
|
||||
}
|
||||
}
|
||||
case 'context/message': {
|
||||
const { content, source, envelope } = event.data
|
||||
return { role: 'user', content: renderContextContent(content, source, envelope) }
|
||||
}
|
||||
case 'steering/message': {
|
||||
const { content, source } = event.data
|
||||
return { role: 'user', content: renderTagged('steering', content, source) }
|
||||
}
|
||||
default:
|
||||
// A non-surface event (boundary, chunk, log-only record) projects to
|
||||
// no message. Merge-extensible union: no assertNever here.
|
||||
|
||||
@@ -124,6 +124,14 @@ function validateEvent(
|
||||
break
|
||||
}
|
||||
case 'tool/result': {
|
||||
// Session has already validated a provenance-backed content rewrite.
|
||||
// It is durable turn work, not a second execution of the original call.
|
||||
if (event.surfaceOp !== 'append') {
|
||||
if (trace.openTurn === null) {
|
||||
fail('tool/result surface replacement appended outside any open turn')
|
||||
}
|
||||
break
|
||||
}
|
||||
requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step, fail)
|
||||
const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted'
|
||||
if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* @module @deepseek-ai/dsh-session/surface
|
||||
*/
|
||||
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { SessionEvent, SurfaceEvent, SurfaceEventType, SurfaceOp } from './types.ts'
|
||||
|
||||
/** Runtime counterpart of the message-producing event union. */
|
||||
@@ -187,11 +188,37 @@ function replacementRange(
|
||||
}
|
||||
}
|
||||
|
||||
/** Restrict a tool-result replacement to one current result's content. */
|
||||
function assertToolResultRewrite(
|
||||
event: SessionEvent,
|
||||
shadowedSeqs: readonly number[],
|
||||
events: readonly SessionEvent[],
|
||||
): void {
|
||||
if (event.type !== 'tool/result') return
|
||||
if (shadowedSeqs.length !== 1) {
|
||||
throw new Error('tool/result surface replacement must rewrite exactly one current node')
|
||||
}
|
||||
for (const originalSeq of shadowedSeqs) {
|
||||
const original = events[originalSeq]
|
||||
if (original?.type !== 'tool/result') {
|
||||
throw new Error('tool/result surface replacement must target a current tool/result')
|
||||
}
|
||||
const originalRest = { ...original.data } as Record<string, unknown>
|
||||
const replacementRest = { ...event.data } as Record<string, unknown>
|
||||
delete originalRest['content']
|
||||
delete replacementRest['content']
|
||||
if (!isDeepStrictEqual(originalRest, replacementRest)) {
|
||||
throw new Error('tool/result surface replacement may change only content')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate one event at its replay boundary and prepare its atomic fold transition. */
|
||||
function planSurfaceEvent(
|
||||
state: SurfaceFoldState,
|
||||
event: SessionEvent,
|
||||
expectedSeq: number,
|
||||
events: readonly SessionEvent[],
|
||||
): SurfacePlan | undefined {
|
||||
if (event.seq !== expectedSeq) {
|
||||
throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
|
||||
@@ -204,6 +231,7 @@ function planSurfaceEvent(
|
||||
}
|
||||
const range = replacementRange(state, surfaceOp)
|
||||
assertProvenance(event, range.shadowedSeqs)
|
||||
assertToolResultRewrite(event, range.shadowedSeqs, events)
|
||||
return {
|
||||
kind: 'replace',
|
||||
seq: event.seq,
|
||||
@@ -218,8 +246,9 @@ function applySurfaceEvent(
|
||||
state: SurfaceFoldState,
|
||||
event: SessionEvent,
|
||||
expectedSeq: number,
|
||||
events: readonly SessionEvent[],
|
||||
): SurfaceFoldReplacement | undefined {
|
||||
const plan = planSurfaceEvent(state, event, expectedSeq)
|
||||
const plan = planSurfaceEvent(state, event, expectedSeq, events)
|
||||
if (plan?.kind === 'append') {
|
||||
state.nodes.push(plan.seq)
|
||||
} else if (plan?.kind === 'replace') {
|
||||
@@ -239,13 +268,13 @@ function applySurfaceEvent(
|
||||
* Replay a complete session log through the canonical surface fold.
|
||||
* @param events - session events in contiguous seq order.
|
||||
* @returns detached current sequences and replacement history.
|
||||
* @throws when an event violates surface metadata, provenance, or range rules.
|
||||
* @throws when an event violates surface metadata, provenance, range, or tool-result rewrite rules.
|
||||
*/
|
||||
export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult {
|
||||
const state = createFoldState()
|
||||
const replacements: SurfaceFoldReplacement[] = []
|
||||
for (const [index, event] of events.entries()) {
|
||||
const replacement = applySurfaceEvent(state, event, index)
|
||||
const replacement = applySurfaceEvent(state, event, index, events)
|
||||
if (replacement !== undefined) replacements.push(replacement)
|
||||
}
|
||||
return { nodes: [...state.nodes], replacements }
|
||||
@@ -266,7 +295,7 @@ export class SurfaceManager implements SessionSurface {
|
||||
*/
|
||||
validateNext(event: SessionEvent): void {
|
||||
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
|
||||
planSurfaceEvent(this._state, event, this.log.length)
|
||||
planSurfaceEvent(this._state, event, this.log.length, this.log)
|
||||
}
|
||||
|
||||
/** Monotonic count of folded positional replacements. */
|
||||
@@ -285,7 +314,7 @@ export class SurfaceManager implements SessionSurface {
|
||||
private _processDelta(): void {
|
||||
for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
|
||||
applySurfaceEvent(this._state, this.log[i]!, i)
|
||||
applySurfaceEvent(this._state, this.log[i]!, i, this.log)
|
||||
this._lastProcessedSeq = i
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,6 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue } from './json.ts'
|
||||
|
||||
/** Canonical context-tag framing, or caller-owned framing rendered verbatim. */
|
||||
export type ContextEnvelope = 'context' | 'raw'
|
||||
|
||||
/** Identifies one session in the store (and its persistence artifacts). */
|
||||
export type SessionId = Branded<'SessionId'>
|
||||
|
||||
@@ -109,8 +106,8 @@ export interface TurnEndReasonMap {
|
||||
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
|
||||
'max-tokens': { kind: 'max-tokens' }
|
||||
/**
|
||||
* Policy blocked every prompt before the first step. The zero-step turn still
|
||||
* records a balanced durable boundary and the veto reason.
|
||||
* Policy blocked the turn's claimed prompt before the first step. The
|
||||
* zero-step turn still records a balanced durable boundary and veto reason.
|
||||
*/
|
||||
rejected: { kind: 'rejected'; reason: string }
|
||||
/**
|
||||
@@ -179,40 +176,44 @@ export type RequestHeaderReason = 'initial' | 'resume' | 'change'
|
||||
*/
|
||||
export interface SessionEventMap {
|
||||
/**
|
||||
* Opens turn `turn`. `trigger` records what started it — a drained message
|
||||
* batch or an idle-time injection. The turn is the durability/replay
|
||||
* Opens turn `turn`. `trigger` records what started it — one claimed queued
|
||||
* message or an idle-time injection. The turn is the durability/replay
|
||||
* boundary: every event sits between a `turn/start` and its matching
|
||||
* `turn/end` (the turn-enclosure invariant).
|
||||
*/
|
||||
'turn/start': { turn: number; trigger: TurnTrigger }
|
||||
/**
|
||||
* Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop
|
||||
* fires the awaited `session/flush` checkpoint at every turn end, so the turn
|
||||
* boundary is also the durable-commit boundary.
|
||||
* awaits `session/flush` after an ordinary turn ends before claiming the next
|
||||
* queued item. Success commits the turn; rejection is reported live and does
|
||||
* not prevent later work.
|
||||
*/
|
||||
'turn/end': { turn: number; reason: TurnEndReason }
|
||||
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
|
||||
'step/start': { turn: number; step: number }
|
||||
/** Closes step `step` of turn `turn`. */
|
||||
'step/end': { turn: number; step: number }
|
||||
/** A user-visible prompt (queued message drained at turn start). */
|
||||
/** A user-visible prompt (the queued message claimed for this turn). */
|
||||
'user/message': { content: ContentBlock[]; source: MessageSource }
|
||||
/**
|
||||
* Durable record of a prompt veto and its reason. It is log-only: the blocked
|
||||
* prompt never enters the model-visible surface, including in a mixed batch.
|
||||
* prompt never enters the model-visible surface, and its turn runs zero steps.
|
||||
*/
|
||||
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
|
||||
/**
|
||||
* In-session context injection (file-change notices, subdir AGENTS.md,
|
||||
* skill content, cron notifications, …). Rendered into the derived history
|
||||
* as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller
|
||||
* own the complete model-facing frame; `meta` is durable JSON state omitted
|
||||
* from the model projection.
|
||||
* as a synthetic user-role message carrying `content` verbatim — NOT a
|
||||
* user prompt. `meta` is durable JSON state omitted from the model
|
||||
* projection; it is also the intended channel for any future framing
|
||||
* directive (a producer declares the frame, a dedicated renderer applies it —
|
||||
* see the deferred note in
|
||||
* ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md),
|
||||
* so the surface keeps projecting `content` verbatim rather than wrapping it.
|
||||
*/
|
||||
'context/message': {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
envelope?: ContextEnvelope
|
||||
meta?: JsonValue
|
||||
}
|
||||
/** Raw stream chunk — token-level replay fidelity. */
|
||||
|
||||
@@ -180,6 +180,82 @@ describe('session-log invariants', () => {
|
||||
}, { surfaceOp: 'append' })).toThrow(/no prior tool\/call/)
|
||||
})
|
||||
|
||||
it('keeps fresh tool-result appends open-step checked', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('closed'),
|
||||
content: [],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step null/)
|
||||
})
|
||||
|
||||
it('treats a validated tool-result replacement as a turn-enclosed rewrite', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('rewrite'),
|
||||
name: 'echo',
|
||||
arguments: '{}',
|
||||
})
|
||||
const original = session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('rewrite'),
|
||||
content: [{ type: 'text', text: 'original' }],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => session.append('tool/result', {
|
||||
...original.data,
|
||||
content: [{ type: 'text', text: 'pruned' }],
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
|
||||
sourceEventSeqs: [original.seq],
|
||||
})).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a tool-result replacement outside a turn', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('rewrite'),
|
||||
name: 'echo',
|
||||
arguments: '{}',
|
||||
})
|
||||
const original = session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('rewrite'),
|
||||
content: [{ type: 'text', text: 'original' }],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
expect(() => session.append('tool/result', {
|
||||
...original.data,
|
||||
content: [{ type: 'text', text: 'pruned' }],
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
|
||||
sourceEventSeqs: [original.seq],
|
||||
})).toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('allows interrupted repair results and unresolved calls at step end', async () => {
|
||||
const repaired = (await setup()).ctx.sessions.create()
|
||||
expect(() => {
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('Session', () => {
|
||||
expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' })
|
||||
})
|
||||
|
||||
it('renders context and steering messages as tagged synthetic user content', () => {
|
||||
it('renders context and steering messages as plain user content', () => {
|
||||
const session = new Session(SessionId('s2'))
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'file changed: a.ts' }],
|
||||
@@ -62,12 +62,12 @@ describe('Session', () => {
|
||||
|
||||
const [contextMessage, steeringMessage] = session.deriveMessages()
|
||||
expect(contextMessage!.role).toBe('user')
|
||||
expect(contextMessage!.content[0]).toMatchObject({ type: 'text', text: '<context source="plugin">' })
|
||||
expect(contextMessage!.content.at(-1)).toMatchObject({ type: 'text', text: '</context>' })
|
||||
expect(steeringMessage!.content[0]).toMatchObject({ type: 'text', text: '<steering source="user">' })
|
||||
expect(contextMessage!.content).toEqual([{ type: 'text', text: 'file changed: a.ts' }])
|
||||
expect(steeringMessage!.role).toBe('user')
|
||||
expect(steeringMessage!.content).toEqual([{ type: 'text', text: 'focus on tests' }])
|
||||
})
|
||||
|
||||
it('renders raw context without a generic envelope while preserving structured metadata', () => {
|
||||
it('keeps context meta durable in the event while hiding it from the projection', () => {
|
||||
const session = new Session(SessionId('s2-raw'))
|
||||
const meta = {
|
||||
kind: 'workspace-instructions',
|
||||
@@ -77,7 +77,6 @@ describe('Session', () => {
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
|
||||
source: { kind: 'plugin', plugin: 'workspace-context' },
|
||||
envelope: 'raw',
|
||||
meta,
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
|
||||
@@ -369,8 +369,8 @@ describe('deriveMessages with surface', () => {
|
||||
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'focus' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
const messages = s.deriveMessages()
|
||||
expect(messages).toHaveLength(2)
|
||||
expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: '<context source="plugin">' })
|
||||
expect(messages[1]!.content[0]).toMatchObject({ type: 'text', text: '<steering source="user">' })
|
||||
expect(messages[0]!.content).toEqual([{ type: 'text', text: 'file changed' }])
|
||||
expect(messages[1]!.content).toEqual([{ type: 'text', text: 'focus' }])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user