mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat(x): reference-based model requests + wire-form composer
model_call_requested carried three duplications that dominated turn-file
size (measured on a real 647KB two-step turn): the system prompt (28KB)
and the 38-tool snapshot (40KB) repeated per call despite being byte-
identical to turn_created.agent.resolved, and messages re-inlining the
whole current-turn transcript (230KB re-stating a 207KB tool result).
ModelRequest is now a list of references into the turn's own events —
call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that
batch's toolResults in source order]; re-issue after an interruption:
[]. Every referenced byte exists exactly once in the file; the measured
turn drops to ~285KB and each further model call costs ~200 bytes. The
reducer's ordering invariant got stricter: reference lists are matched
exactly against the transcript.
Debuggability is now byte-for-byte at the wire level: ResolvedModel
gains encodeMessages (the structural->wire conversion — user-message
context weaving, attachment rendering, tool-result enveloping, i.e.
convertFromMessages), and composeModelRequest rebuilds the exact
provider payload (resolved system prompt + wrapped tools + materialized
prefix + resolved refs, encoded). The loop transmits exactly the
composer's output, so the durable file plus the composer reproduce what
the model received — pinned by a property test asserting composed ==
sent for every call, and a 2KB size guard on request events. A bridge
test demonstrates the woven wire form (no raw userMessageContext).
Breaking for existing dev turn files (same schemaVersion, pre-release):
wipe ~/.rowboat/storage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
a58493b545
commit
3822bf53da
18 changed files with 524 additions and 227 deletions
1
apps/x/.gitignore
vendored
1
apps/x/.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
node_modules/
|
||||
test-fixtures/
|
||||
*.tsbuildinfo
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ describe('useSessionChat', () => {
|
|||
// A new turn streams in over the feed.
|
||||
act(() => {
|
||||
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: created('turn-2', S1, user('q2')) })
|
||||
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: requested('turn-2', 0, [user('q2')]) })
|
||||
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: requested('turn-2', 0) })
|
||||
emit({
|
||||
kind: 'turn-event',
|
||||
sessionId: S1,
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ describe('SessionChatStore', () => {
|
|||
await store.setSession(S1)
|
||||
|
||||
emit(turnEvent(S1, 'turn-1', created('turn-1', S1, user('go'))))
|
||||
emit(turnEvent(S1, 'turn-1', requested('turn-1', 0, [user('go')])))
|
||||
emit(turnEvent(S1, 'turn-1', requested('turn-1', 0)))
|
||||
expect(store.getSnapshot().chatState?.isThinking).toBe(true)
|
||||
|
||||
emit(turnEvent(S1, 'turn-1', completed('turn-1', 0, assistantText('done'))))
|
||||
|
|
@ -161,7 +161,7 @@ describe('SessionChatStore', () => {
|
|||
client.sessions.set(S1, sessionState(S1, []))
|
||||
await store.setSession(S1)
|
||||
emit(turnEvent(S1, 'turn-1', created('turn-1', S1, user('go'))))
|
||||
emit(turnEvent(S1, 'turn-1', requested('turn-1', 0, [user('go')])))
|
||||
emit(turnEvent(S1, 'turn-1', requested('turn-1', 0)))
|
||||
emit(turnEvent(S1, 'turn-1', { type: 'text_delta', turnId: 'turn-1', modelCallIndex: 0, delta: 'he' }))
|
||||
emit(turnEvent(S1, 'turn-1', { type: 'text_delta', turnId: 'turn-1', modelCallIndex: 0, delta: 'y' }))
|
||||
expect(store.getSnapshot().chatState?.currentAssistantMessage).toBe('hey')
|
||||
|
|
@ -199,7 +199,7 @@ describe('SessionChatStore', () => {
|
|||
// The feed attached mid-turn: we never saw turn_created for turn-9.
|
||||
client.turns.set('turn-9', [
|
||||
created('turn-9', S1, user('missed')),
|
||||
requested('turn-9', 0, [user('missed')]),
|
||||
requested('turn-9', 0),
|
||||
])
|
||||
emit(turnEvent(S1, 'turn-9', completed('turn-9', 0, assistantText('caught up'))))
|
||||
await Promise.resolve()
|
||||
|
|
@ -273,7 +273,7 @@ describe('SessionChatStore', () => {
|
|||
|
||||
await store.setSession(S1)
|
||||
emit(turnEvent(S1, 'turn-1', created('turn-1', S1, user('go'))))
|
||||
emit(turnEvent(S1, 'turn-1', requested('turn-1', 0, [user('go')])))
|
||||
emit(turnEvent(S1, 'turn-1', requested('turn-1', 0)))
|
||||
emit(turnEvent(S1, 'turn-1', completed('turn-1', 0, assistantText('done'))))
|
||||
emit(turnEvent(S1, 'turn-1', turnCompleted('turn-1')))
|
||||
expect(store.getSnapshot().chatState?.isProcessing).toBe(false)
|
||||
|
|
|
|||
|
|
@ -46,16 +46,24 @@ export function created(
|
|||
}
|
||||
}
|
||||
|
||||
export function requested(turnId: string, index: number, messages: unknown[]): TEvent {
|
||||
type RequestRef =
|
||||
| { kind: 'context' }
|
||||
| { kind: 'input' }
|
||||
| { kind: 'assistant'; modelCallIndex: number }
|
||||
| { kind: 'toolResult'; toolCallId: string }
|
||||
|
||||
export function requested(
|
||||
turnId: string,
|
||||
index: number,
|
||||
refs: RequestRef[] = [{ kind: 'input' }],
|
||||
): TEvent {
|
||||
return {
|
||||
type: 'model_call_requested',
|
||||
turnId,
|
||||
ts: TS,
|
||||
modelCallIndex: index,
|
||||
request: {
|
||||
systemPrompt: 'SYS',
|
||||
messages: messages as never,
|
||||
tools: [],
|
||||
messages: refs,
|
||||
parameters: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -127,7 +135,7 @@ export function completedTurnLog(
|
|||
): TEvent[] {
|
||||
return [
|
||||
created(turnId, sessionId, user(question)),
|
||||
requested(turnId, 0, [user(question)]),
|
||||
requested(turnId, 0),
|
||||
completed(turnId, 0, assistantText(answer)),
|
||||
turnCompleted(turnId, answer),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -87,14 +87,13 @@ describe('buildTurnConversation', () => {
|
|||
const call = assistantCalls(toolCallPart('tc1', 'echo', { x: 1 }))
|
||||
const state = reduceTurn([
|
||||
created(T1, S1, user('run it')),
|
||||
requested(T1, 0, [user('run it')]),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, call),
|
||||
invocation(T1, 'tc1', 'echo'),
|
||||
toolResult(T1, 'tc1', 'echo', { echoed: true }),
|
||||
requested(T1, 1, [
|
||||
user('run it'),
|
||||
call,
|
||||
{ role: 'tool', content: '{"echoed":true}', toolCallId: 'tc1', toolName: 'echo' },
|
||||
{ kind: 'assistant', modelCallIndex: 0 },
|
||||
{ kind: 'toolResult', toolCallId: 'tc1' },
|
||||
]),
|
||||
completed(T1, 1, assistantText('all done')),
|
||||
turnCompleted(T1, 'all done'),
|
||||
|
|
@ -113,7 +112,7 @@ describe('buildTurnConversation', () => {
|
|||
it('marks permission-pending tools pending and running tools running', () => {
|
||||
const state = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0, [user('hello')]),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, assistantCalls(toolCallPart('p1', 'executeCommand'), toolCallPart('r1', 'echo'))),
|
||||
{
|
||||
type: 'tool_permission_required',
|
||||
|
|
@ -139,7 +138,7 @@ describe('buildTurnConversation', () => {
|
|||
}
|
||||
const state = reduceTurn([
|
||||
created(T1, S1, input as never),
|
||||
requested(T1, 0, [input]),
|
||||
requested(T1, 0),
|
||||
{ type: 'model_call_failed', turnId: T1, ts: TS, modelCallIndex: 0, error: 'boom' },
|
||||
{ type: 'turn_failed', turnId: T1, ts: TS, error: 'boom', usage: {} },
|
||||
])
|
||||
|
|
@ -154,7 +153,7 @@ describe('buildSessionChatState', () => {
|
|||
const prior = reduceTurn(completedTurnLog('turn-1', S1, 'first?', 'first answer'))
|
||||
const latest = reduceTurn([
|
||||
created('turn-2', S1, user('second?')),
|
||||
requested('turn-2', 0, [user('second?')]),
|
||||
requested('turn-2', 0),
|
||||
])
|
||||
const state = buildSessionChatState([prior, latest], emptyOverlay())
|
||||
expect(
|
||||
|
|
@ -174,7 +173,7 @@ describe('buildSessionChatState', () => {
|
|||
it('exposes pending permissions as request events; waiting is not thinking', () => {
|
||||
const turn = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0, [user('hello')]),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, assistantCalls(toolCallPart('p1', 'executeCommand', { command: 'rm -rf /' }))),
|
||||
{
|
||||
type: 'tool_permission_required',
|
||||
|
|
@ -209,7 +208,7 @@ describe('buildSessionChatState', () => {
|
|||
it('maps human decisions to responses and classifier decisions to auto-decisions', () => {
|
||||
const turn = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0, [user('hello')]),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, assistantCalls(toolCallPart('h1', 'echo'), toolCallPart('c1', 'echo'))),
|
||||
{ type: 'tool_permission_required', turnId: T1, ts: TS, toolCallId: 'h1', toolName: 'echo', request: { kind: 'command', commandNames: ['x'] } },
|
||||
{ type: 'tool_permission_required', turnId: T1, ts: TS, toolCallId: 'c1', toolName: 'echo', request: { kind: 'command', commandNames: ['y'] } },
|
||||
|
|
@ -230,7 +229,7 @@ describe('buildSessionChatState', () => {
|
|||
it('exposes pending ask-human calls with question and options', () => {
|
||||
const turn = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0, [user('hello')]),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, assistantCalls(toolCallPart('ah1', 'ask-human', { question: 'Deploy?', options: ['Yes', 'No'] }))),
|
||||
{
|
||||
type: 'tool_invocation_requested',
|
||||
|
|
@ -254,7 +253,7 @@ describe('buildSessionChatState', () => {
|
|||
it('stitches live tool output onto the matching tool item', () => {
|
||||
const turn = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0, [user('hello')]),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, assistantCalls(toolCallPart('tc1', 'executeCommand'))),
|
||||
invocation(T1, 'tc1', 'executeCommand'),
|
||||
])
|
||||
|
|
@ -265,7 +264,7 @@ describe('buildSessionChatState', () => {
|
|||
})
|
||||
|
||||
it('surfaces streaming text as currentAssistantMessage', () => {
|
||||
const turn = reduceTurn([created(T1, S1), requested(T1, 0, [user('hello')])])
|
||||
const turn = reduceTurn([created(T1, S1), requested(T1, 0)])
|
||||
const state = buildSessionChatState([turn], { ...emptyOverlay(), text: 'typing…' })
|
||||
expect(state.currentAssistantMessage).toBe('typing…')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -479,11 +479,15 @@ Every AI SDK `streamText` invocation performs exactly one model step. Tool
|
|||
execution is controlled manually by the turn loop.
|
||||
|
||||
```ts
|
||||
type ModelRequestMessageRef =
|
||||
| { kind: "context" } // the inline context block
|
||||
| { kind: "input" } // the turn's user message
|
||||
| { kind: "assistant"; modelCallIndex: number } // → model_call_completed
|
||||
| { kind: "toolResult"; toolCallId: string }; // → tool_result
|
||||
|
||||
interface ModelRequest {
|
||||
systemPrompt: string;
|
||||
contextRef?: { previousTurnId: string };
|
||||
messages: ConversationMessage[];
|
||||
tools: ToolDescriptor[];
|
||||
contextRef?: { previousTurnId: string }; // call 0 only (cross-turn prefix)
|
||||
messages: ModelRequestMessageRef[]; // what is NEW since the previous call
|
||||
parameters: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
|
|
@ -497,24 +501,26 @@ interface ModelCallRequested extends BaseTurnEvent {
|
|||
`modelCallIndex` starts at `0` and increments monotonically for each primary
|
||||
agent model call in the turn.
|
||||
|
||||
`request` records the exact model input with one substitution: when the
|
||||
turn's context is a reference, the resolved prefix is not re-inlined.
|
||||
`contextRef` repeats the reference and `messages` contains only current-turn
|
||||
messages — the turn input, this turn's prior assistant responses, and ordered
|
||||
tool results — captured byte-for-byte as assembled immediately before
|
||||
provider invocation. When the turn's context is inline, `contextRef` is
|
||||
absent and `messages` begins with that inline context. The full request is
|
||||
deterministically materializable because referenced turns are immutable; a
|
||||
`materializeRequest(turnId, modelCallIndex)` debug utility composes resolver
|
||||
output with the persisted messages.
|
||||
`request` is a list of references into the turn's own events: every
|
||||
referenced byte exists exactly once in the file. A request records only what
|
||||
is new since the previous model call — call 0 is `[{context}?, {input}]`
|
||||
(context only when inline and nonempty; cross-turn prefixes ride
|
||||
`contextRef`); call N is `[{assistant: N-1}, …that batch's toolResults in
|
||||
source order]`; a re-issued call after an interruption is `[]`. The system
|
||||
prompt and tool set are not repeated: they are byte-identical to
|
||||
`turn_created.agent.resolved` by construction.
|
||||
|
||||
Within-turn duplication is deliberate: each request restates the current
|
||||
turn's accumulated messages even though they also exist as earlier events in
|
||||
the same file. The persisted `messages` are the ground truth of what the loop
|
||||
actually assembled and sent — including any assembly bugs — which a derived
|
||||
reconstruction would mask.
|
||||
The exact provider payload is rebuilt by the request composer
|
||||
(`composeModelRequest` in core): resolved system prompt, wrapped tool
|
||||
definitions, the materialized cross-turn prefix, and every request's
|
||||
references resolved against the turn's events, all passed through the model
|
||||
bridge's `encodeMessages` (the deterministic structural→wire conversion —
|
||||
user-message context weaving, attachment rendering, tool-result enveloping).
|
||||
The loop transmits exactly the composer's output, and debugging/audit calls
|
||||
the same function, so the durable file plus the composer reproduce the wire
|
||||
bytes and the two views cannot diverge.
|
||||
|
||||
`request` excludes credentials, auth headers, functions, model objects, and
|
||||
Requests exclude credentials, auth headers, functions, model objects, and
|
||||
transport objects.
|
||||
|
||||
The name `requested` is intentional. The event proves durable intent, not that
|
||||
|
|
@ -1526,7 +1532,11 @@ but the first turn implementation does not enforce it.
|
|||
- Any event after a terminal turn event.
|
||||
- Mutation of immutable turn definition data.
|
||||
- `model_call_requested.contextRef` values inconsistent with the turn
|
||||
definition's context.
|
||||
definition's context, or present on a non-initial model call.
|
||||
- Request references that do not match the transcript: call 0 must be
|
||||
`[{context}?, {input}]`; call N must reference the previous completed
|
||||
call's response and its batch's tool results in source order (or nothing,
|
||||
after an interrupted call).
|
||||
|
||||
The reducer validates `context` structurally but treats it as opaque; it
|
||||
never resolves references (section 6.6).
|
||||
|
|
@ -1755,8 +1765,8 @@ tests for:
|
|||
closure results.
|
||||
- A reference to a missing or corrupt turn file is an infrastructure error
|
||||
and appends nothing.
|
||||
- `materializeRequest` output equals resolver output plus persisted
|
||||
current-turn request messages.
|
||||
- The composed request equals what the loop sent (byte-for-byte property:
|
||||
durable file + composer reproduce the provider payload).
|
||||
- The reducer never resolves references.
|
||||
|
||||
## 28. Suggested module layout
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ function turnLog(
|
|||
turnId,
|
||||
ts: TS,
|
||||
modelCallIndex: 0,
|
||||
request: { systemPrompt: "SYS", messages: [user("hi")], tools: [], parameters: {} },
|
||||
request: { messages: [{ kind: 'input' as const }], parameters: {} },
|
||||
};
|
||||
switch (status) {
|
||||
case "idle":
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ function makeRegistry(parts: Array<Record<string, unknown>>, capture: InvokerOpt
|
|||
function request(overrides: Partial<ModelStreamRequest> = {}): ModelStreamRequest {
|
||||
return {
|
||||
systemPrompt: "SYS",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
messages: [{ role: "user", content: "hello" }] as ModelStreamRequest["messages"],
|
||||
tools: [
|
||||
{
|
||||
toolId: "builtin:echo",
|
||||
|
|
@ -107,9 +107,10 @@ describe("RealModelRegistry", () => {
|
|||
},
|
||||
});
|
||||
|
||||
// The invoker received the system prompt, converted messages, and tools.
|
||||
// The invoker received the system prompt, the pre-encoded messages
|
||||
// verbatim (encoding is the composer's job), and the wrapped tools.
|
||||
expect(capture[0].system).toBe("SYS");
|
||||
expect(capture[0].messages[0]).toMatchObject({ role: "user" });
|
||||
expect(capture[0].messages).toEqual([{ role: "user", content: "hello" }]);
|
||||
expect(Object.keys(capture[0].tools)).toEqual(["echo"]);
|
||||
});
|
||||
|
||||
|
|
@ -152,6 +153,41 @@ describe("RealModelRegistry", () => {
|
|||
).rejects.toThrowError("rate limited");
|
||||
});
|
||||
|
||||
it("encodeMessages produces the LLM-facing wire form (context woven, tool results enveloped)", async () => {
|
||||
const registry = makeRegistry([], []);
|
||||
const model = await registry.resolve({ provider: "openai", model: "gpt-test" });
|
||||
const encoded = model.encodeMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: "list my downloads",
|
||||
userMessageContext: {
|
||||
currentDateTime: "2026-07-02T10:30:00Z",
|
||||
middlePane: { kind: "empty" },
|
||||
},
|
||||
},
|
||||
{ role: "tool", content: "[…files…]", toolCallId: "tc1", toolName: "file-list" },
|
||||
]) as Array<{ role: string; content: unknown }>;
|
||||
|
||||
// The user message is the woven wire text, not the internal structure.
|
||||
expect(encoded[0].role).toBe("user");
|
||||
const userText = String(encoded[0].content);
|
||||
expect(userText).toContain("2026-07-02T10:30:00Z");
|
||||
expect(userText).toContain("list my downloads");
|
||||
expect(userText).not.toContain("userMessageContext");
|
||||
|
||||
// Tool output rides the AI SDK tool-result envelope.
|
||||
expect(encoded[1]).toMatchObject({
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "tc1",
|
||||
output: { type: "text", value: "[…files…]" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("stops promptly when the signal aborts mid-stream", async () => {
|
||||
const controller = new AbortController();
|
||||
const registry = makeRegistry(
|
||||
|
|
|
|||
|
|
@ -64,6 +64,12 @@ export class RealModelRegistry implements IModelRegistry {
|
|||
const model = provider.languageModel(descriptor.model);
|
||||
return {
|
||||
descriptor,
|
||||
// The structural -> wire conversion the app uses today: weaves
|
||||
// userMessageContext into the user text, renders attachments,
|
||||
// wraps tool output as tool-result parts. Deterministic and
|
||||
// per-message, so composed requests are byte-stable.
|
||||
encodeMessages: (messages) =>
|
||||
convertFromMessages(messages) as unknown as JsonValue[],
|
||||
stream: (request) => this.run(model, request),
|
||||
};
|
||||
}
|
||||
|
|
@ -90,7 +96,7 @@ export class RealModelRegistry implements IModelRegistry {
|
|||
const result = this.invoke({
|
||||
model,
|
||||
system: request.systemPrompt,
|
||||
messages: convertFromMessages(request.messages),
|
||||
messages: request.messages as ModelMessage[],
|
||||
tools,
|
||||
abortSignal: request.signal,
|
||||
});
|
||||
|
|
|
|||
61
apps/x/packages/core/src/turns/compose-model-request.ts
Normal file
61
apps/x/packages/core/src/turns/compose-model-request.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import type { z } from "zod";
|
||||
import {
|
||||
type ConversationMessage,
|
||||
type JsonValue,
|
||||
type ToolDescriptor,
|
||||
type TurnState,
|
||||
requestMessagesFor,
|
||||
} from "@x/shared/dist/turns.js";
|
||||
import type { IContextResolver } from "./context-resolver.js";
|
||||
|
||||
// The exact provider payload for one model call, rebuilt deterministically
|
||||
// from durable state (turn-runtime-design.md §8.3):
|
||||
// - systemPrompt and tools come from the resolved agent snapshot (their
|
||||
// single canonical copy in turn_created),
|
||||
// - messages are the cross-turn prefix plus every request's reference list
|
||||
// resolved against the turn's own events, encoded to wire form.
|
||||
// This is the SAME code path the loop sends through, so the debug view and
|
||||
// the transmitted bytes cannot diverge.
|
||||
export interface ComposedModelRequest {
|
||||
systemPrompt: string;
|
||||
messages: JsonValue[];
|
||||
tools: Array<z.infer<typeof ToolDescriptor>>;
|
||||
parameters: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
export function composeModelRequest(
|
||||
state: TurnState,
|
||||
modelCallIndex: number,
|
||||
// The materialized cross-turn prefix (contextResolver output). Ignored
|
||||
// for inline-context turns, whose context rides the {context} ref.
|
||||
resolvedPrefix: Array<z.infer<typeof ConversationMessage>>,
|
||||
encode: (messages: Array<z.infer<typeof ConversationMessage>>) => JsonValue[],
|
||||
): ComposedModelRequest {
|
||||
const call = state.modelCalls[modelCallIndex];
|
||||
if (!call) {
|
||||
throw new Error(`no model call at index ${modelCallIndex}`);
|
||||
}
|
||||
const prefix = Array.isArray(state.definition.context) ? [] : resolvedPrefix;
|
||||
const structural = [...prefix];
|
||||
for (let index = 0; index <= modelCallIndex; index++) {
|
||||
structural.push(...requestMessagesFor(state, index));
|
||||
}
|
||||
return {
|
||||
systemPrompt: state.definition.agent.resolved.systemPrompt,
|
||||
messages: encode(structural),
|
||||
tools: state.definition.agent.resolved.tools,
|
||||
parameters: call.request.parameters,
|
||||
};
|
||||
}
|
||||
|
||||
// Debug/materialization convenience: compose from durable state alone,
|
||||
// resolving the cross-turn prefix through the context resolver.
|
||||
export async function materializeModelRequest(
|
||||
state: TurnState,
|
||||
modelCallIndex: number,
|
||||
contextResolver: IContextResolver,
|
||||
encode: (messages: Array<z.infer<typeof ConversationMessage>>) => JsonValue[],
|
||||
): Promise<ComposedModelRequest> {
|
||||
const prefix = await contextResolver.resolve(state.definition.context);
|
||||
return composeModelRequest(state, modelCallIndex, prefix, encode);
|
||||
}
|
||||
|
|
@ -57,12 +57,11 @@ function completedTurnLog(
|
|||
ts,
|
||||
modelCallIndex: 0,
|
||||
request: {
|
||||
systemPrompt: "SYS",
|
||||
...(Array.isArray(context) ? {} : { contextRef: context }),
|
||||
messages: Array.isArray(context)
|
||||
? [...context, user(inputText)]
|
||||
: [user(inputText)],
|
||||
tools: [],
|
||||
messages:
|
||||
Array.isArray(context) && context.length > 0
|
||||
? [{ kind: "context" as const }, { kind: "input" as const }]
|
||||
: [{ kind: "input" as const }],
|
||||
parameters: {},
|
||||
},
|
||||
},
|
||||
|
|
@ -139,9 +138,7 @@ function limitFailedTurnLog(turnId: string): TEvent[] {
|
|||
ts,
|
||||
modelCallIndex: 0,
|
||||
request: {
|
||||
systemPrompt: "SYS",
|
||||
messages: [user("do it")],
|
||||
tools: [echo],
|
||||
messages: [{ kind: "input" as const }],
|
||||
parameters: {},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -41,9 +41,7 @@ function requested(turnId = TURN_ID): z.infer<typeof TurnEvent> {
|
|||
ts: "2026-07-02T10:00:01Z",
|
||||
modelCallIndex: 0,
|
||||
request: {
|
||||
systemPrompt: "SYS",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
tools: [],
|
||||
messages: [{ kind: "input" }],
|
||||
parameters: {},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export * from "./api.js";
|
|||
export * from "./agent-resolver.js";
|
||||
export * from "./bus.js";
|
||||
export * from "./clock.js";
|
||||
export * from "./compose-model-request.js";
|
||||
export * from "./context-resolver.js";
|
||||
export * from "./fs-repo.js";
|
||||
export * from "./in-memory-turn-repo.js";
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@ export type LlmStreamEvent =
|
|||
|
||||
export interface ModelStreamRequest {
|
||||
systemPrompt: string;
|
||||
messages: Array<z.infer<typeof ConversationMessage>>;
|
||||
// Provider wire-form messages: the output of encodeMessages. The loop
|
||||
// builds these through the shared request composer, so what is sent is
|
||||
// exactly what composeModelRequest reproduces from the durable log.
|
||||
messages: JsonValue[];
|
||||
tools: Array<z.infer<typeof ToolDescriptor>>;
|
||||
parameters: Record<string, JsonValue>;
|
||||
signal: AbortSignal;
|
||||
|
|
@ -35,6 +38,11 @@ export interface ModelStreamRequest {
|
|||
|
||||
export interface ResolvedModel {
|
||||
descriptor: z.infer<typeof ModelDescriptor>;
|
||||
// Deterministic per-message structural -> wire conversion (e.g. weaving
|
||||
// userMessageContext into the user text, tool-result enveloping).
|
||||
encodeMessages(
|
||||
messages: Array<z.infer<typeof ConversationMessage>>,
|
||||
): JsonValue[];
|
||||
stream(request: ModelStreamRequest): AsyncIterable<LlmStreamEvent>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ import type { z } from "zod";
|
|||
import {
|
||||
MODEL_CALL_LIMIT_ERROR_CODE,
|
||||
type JsonValue,
|
||||
type ModelRequestMessageRef,
|
||||
type ResolvedAgent,
|
||||
type ToolDescriptor,
|
||||
type TurnEvent,
|
||||
type TurnStreamEvent,
|
||||
reduceTurn,
|
||||
} from "@x/shared/dist/turns.js";
|
||||
import type { IAgentResolver } from "./agent-resolver.js";
|
||||
import type { TurnExecution, TurnOutcome } from "./api.js";
|
||||
|
|
@ -28,6 +30,7 @@ import type {
|
|||
PermissionClassificationBatch,
|
||||
PermissionClassificationInput,
|
||||
} from "./permission.js";
|
||||
import { composeModelRequest } from "./compose-model-request.js";
|
||||
import { TurnRuntime } from "./runtime.js";
|
||||
import type {
|
||||
IToolRegistry,
|
||||
|
|
@ -153,6 +156,8 @@ class FakeModelRegistry implements IModelRegistry {
|
|||
): Promise<ResolvedModel> {
|
||||
return {
|
||||
descriptor,
|
||||
// Identity encoding: tests assert on structural messages directly.
|
||||
encodeMessages: (messages) => messages as unknown as JsonValue[],
|
||||
stream: (request) => {
|
||||
this.requests.push(request);
|
||||
const call = this.calls[this.next++];
|
||||
|
|
@ -365,6 +370,15 @@ function typesOf(events: Array<{ type: string }>): string[] {
|
|||
return events.map((e) => e.type);
|
||||
}
|
||||
|
||||
// The fake model's identity encoding passes structural messages through.
|
||||
function sentMessages(request: ModelStreamRequest) {
|
||||
return request.messages as Array<{
|
||||
role: string;
|
||||
content?: unknown;
|
||||
toolCallId?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
async function persisted(
|
||||
repo: InMemoryTurnRepo,
|
||||
turnId: string,
|
||||
|
|
@ -378,7 +392,7 @@ async function persisted(
|
|||
|
||||
describe("plain model response (26.1)", () => {
|
||||
it("runs one model step to completion with exact persisted request", async () => {
|
||||
const { runtime, repo, bus } = makeRuntime({
|
||||
const { runtime, repo, models, bus } = makeRuntime({
|
||||
models: [
|
||||
respond(
|
||||
{ type: "text_delta", delta: "do" },
|
||||
|
|
@ -408,11 +422,12 @@ describe("plain model response (26.1)", () => {
|
|||
const request = log[1];
|
||||
expect(request).toMatchObject({
|
||||
modelCallIndex: 0,
|
||||
request: {
|
||||
systemPrompt: "SYS",
|
||||
messages: [user("hello")],
|
||||
},
|
||||
request: { messages: [{ kind: "input" }] },
|
||||
});
|
||||
// The model received the composed payload: resolved system prompt,
|
||||
// snapshot tools, encoded messages.
|
||||
expect(models.requests[0].systemPrompt).toBe("SYS");
|
||||
expect(sentMessages(models.requests[0])).toEqual([user("hello")]);
|
||||
// Deltas are streamed but never persisted.
|
||||
expect(events.filter((e) => e.type === "text_delta")).toHaveLength(2);
|
||||
expect(typesOf(log)).not.toContain("text_delta");
|
||||
|
|
@ -505,19 +520,39 @@ describe("mixed sync and async tools (26.2)", () => {
|
|||
(e) => e.type === "model_call_requested" && e.modelCallIndex === 1,
|
||||
);
|
||||
expect(secondRequest).toBeDefined();
|
||||
const toolMessages =
|
||||
secondRequest?.type === "model_call_requested"
|
||||
? secondRequest.request.messages.filter((m) => m.role === "tool")
|
||||
: [];
|
||||
expect(toolMessages.map((m) => (m.role === "tool" ? m.toolCallId : ""))).toEqual(
|
||||
["A", "B", "C", "D"],
|
||||
);
|
||||
// The live model call saw the same ordering.
|
||||
expect(
|
||||
models.requests[1].messages
|
||||
secondRequest?.type === "model_call_requested"
|
||||
? secondRequest.request.messages
|
||||
: [],
|
||||
).toEqual([
|
||||
{ kind: "assistant", modelCallIndex: 0 },
|
||||
{ kind: "toolResult", toolCallId: "A" },
|
||||
{ kind: "toolResult", toolCallId: "B" },
|
||||
{ kind: "toolResult", toolCallId: "C" },
|
||||
{ kind: "toolResult", toolCallId: "D" },
|
||||
]);
|
||||
// The live model call saw the results in source order.
|
||||
expect(
|
||||
sentMessages(models.requests[1])
|
||||
.filter((m) => m.role === "tool")
|
||||
.map((m) => (m.role === "tool" ? m.toolCallId : "")),
|
||||
.map((m) => m.toolCallId),
|
||||
).toEqual(["A", "B", "C", "D"]);
|
||||
// Byte-for-byte property: the durable file plus the shared composer
|
||||
// reproduces exactly what the model received.
|
||||
const state = reduceTurn(log);
|
||||
for (const index of [0, 1]) {
|
||||
const composed = composeModelRequest(state, index, [], (m) => m as never);
|
||||
expect(composed.messages).toEqual(models.requests[index].messages);
|
||||
expect(composed.systemPrompt).toBe(models.requests[index].systemPrompt);
|
||||
expect(composed.tools).toEqual(models.requests[index].tools);
|
||||
}
|
||||
// Size guard: request events stay reference-sized — the transcript
|
||||
// duplication this design removes must not creep back.
|
||||
for (const event of log) {
|
||||
if (event.type === "model_call_requested") {
|
||||
expect(JSON.stringify(event).length).toBeLessThan(2048);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects async results for calls that are not pending", async () => {
|
||||
|
|
@ -1129,16 +1164,17 @@ describe("crash recovery (26.7)", () => {
|
|||
};
|
||||
}
|
||||
|
||||
function seedRequested(index: number, messages: Array<ReturnType<typeof user> | ReturnType<typeof assistantCalls> | { role: "assistant"; content: string } | { role: "tool"; content: string; toolCallId: string; toolName: string }>): z.infer<typeof TurnEvent> {
|
||||
function seedRequested(
|
||||
index: number,
|
||||
refs: z.infer<typeof ModelRequestMessageRef>[] = [{ kind: "input" }],
|
||||
): z.infer<typeof TurnEvent> {
|
||||
return {
|
||||
type: "model_call_requested",
|
||||
turnId: SEED_ID,
|
||||
ts: TS,
|
||||
modelCallIndex: index,
|
||||
request: {
|
||||
systemPrompt: "SYS",
|
||||
messages,
|
||||
tools: defaultAgent.tools,
|
||||
messages: refs,
|
||||
parameters: {},
|
||||
},
|
||||
};
|
||||
|
|
@ -1169,7 +1205,7 @@ describe("crash recovery (26.7)", () => {
|
|||
|
||||
it("an unmatched model request is closed as interrupted and re-issued", async () => {
|
||||
const repo = new InMemoryTurnRepo();
|
||||
repo.seed([seedCreated(), seedRequested(0, [user("hello")])]);
|
||||
repo.seed([seedCreated(), seedRequested(0)]);
|
||||
const { runtime, models } = makeRuntime({
|
||||
repo,
|
||||
models: [respond(completedResp(assistantText("done")))],
|
||||
|
|
@ -1197,7 +1233,7 @@ describe("crash recovery (26.7)", () => {
|
|||
humanAvailable: true,
|
||||
maxModelCalls: 1,
|
||||
}),
|
||||
seedRequested(0, [user("hello")]),
|
||||
seedRequested(0),
|
||||
]);
|
||||
const { runtime } = makeRuntime({ repo });
|
||||
const { outcome } = await advanceAndSettle(runtime, SEED_ID);
|
||||
|
|
@ -1212,7 +1248,7 @@ describe("crash recovery (26.7)", () => {
|
|||
const batch = assistantCalls(toolCallPart("S", "echo"));
|
||||
repo.seed([
|
||||
seedCreated(),
|
||||
seedRequested(0, [user("hello")]),
|
||||
seedRequested(0),
|
||||
seedCompleted(0, batch),
|
||||
{
|
||||
type: "tool_invocation_requested",
|
||||
|
|
@ -1248,7 +1284,7 @@ describe("crash recovery (26.7)", () => {
|
|||
const batch = assistantCalls(toolCallPart("B", "fetch"));
|
||||
repo.seed([
|
||||
seedCreated(),
|
||||
seedRequested(0, [user("hello")]),
|
||||
seedRequested(0),
|
||||
seedCompleted(0, batch),
|
||||
{
|
||||
type: "tool_invocation_requested",
|
||||
|
|
@ -1288,7 +1324,7 @@ describe("crash recovery (26.7)", () => {
|
|||
const batch = assistantCalls(toolCallPart("S", "echo"));
|
||||
repo.seed([
|
||||
seedCreated(),
|
||||
seedRequested(0, [user("hello")]),
|
||||
seedRequested(0),
|
||||
seedCompleted(0, batch),
|
||||
{
|
||||
type: "tool_invocation_requested",
|
||||
|
|
@ -1317,7 +1353,7 @@ describe("crash recovery (26.7)", () => {
|
|||
const { outcome } = await advanceAndSettle(runtime, SEED_ID);
|
||||
expect(outcome?.status).toBe("completed");
|
||||
expect(
|
||||
models.requests[0].messages.filter((m) => m.role === "tool"),
|
||||
sentMessages(models.requests[0]).filter((m) => m.role === "tool"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
|
@ -1326,7 +1362,7 @@ describe("crash recovery (26.7)", () => {
|
|||
const batch = assistantCalls(toolCallPart("P", "echo"));
|
||||
repo.seed([
|
||||
seedCreated(),
|
||||
seedRequested(0, [user("hello")]),
|
||||
seedRequested(0),
|
||||
seedCompleted(0, batch),
|
||||
{
|
||||
type: "tool_permission_required",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
type JsonValue,
|
||||
type ModelCallFailed,
|
||||
type ModelRequest,
|
||||
type ModelRequestMessageRef,
|
||||
type ToolCallState,
|
||||
type ToolDescriptor,
|
||||
type ToolInvocationRequested,
|
||||
|
|
@ -21,7 +22,6 @@ import {
|
|||
outstandingAsyncTools,
|
||||
outstandingPermissions,
|
||||
reduceTurn,
|
||||
turnTranscript,
|
||||
} from "@x/shared/dist/turns.js";
|
||||
import type { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
|
||||
import type { IAgentResolver } from "./agent-resolver.js";
|
||||
|
|
@ -37,6 +37,7 @@ import {
|
|||
} from "./api.js";
|
||||
import type { ITurnLifecycleBus } from "./bus.js";
|
||||
import type { IClock } from "./clock.js";
|
||||
import { composeModelRequest } from "./compose-model-request.js";
|
||||
import type { IContextResolver } from "./context-resolver.js";
|
||||
import type {
|
||||
IModelRegistry,
|
||||
|
|
@ -847,17 +848,39 @@ class TurnAdvance {
|
|||
|
||||
// §8.3/§18h–l: one model step. The durable request barrier precedes the
|
||||
// provider call; step events persist before the next provider read;
|
||||
// deltas bypass storage. Returns an outcome only on failure/cancel.
|
||||
// deltas bypass storage. The request records only REFERENCES to what is
|
||||
// new since the previous call; the payload actually sent is built by the
|
||||
// shared composer over the durable state, so file + composer reproduce
|
||||
// the wire bytes exactly. Returns an outcome only on failure/cancel.
|
||||
private async runModelStep(): Promise<TurnOutcome | undefined> {
|
||||
const index = this.state.modelCalls.length;
|
||||
const transcript = turnTranscript(this.state);
|
||||
const context = this.definition.context;
|
||||
const isRef = !Array.isArray(context);
|
||||
let refs: Array<z.infer<typeof ModelRequestMessageRef>>;
|
||||
if (index === 0) {
|
||||
refs =
|
||||
!isRef && context.length > 0
|
||||
? [{ kind: "context" }, { kind: "input" }]
|
||||
: [{ kind: "input" }];
|
||||
} else {
|
||||
const previous = this.state.modelCalls[index - 1];
|
||||
refs =
|
||||
previous.response !== undefined
|
||||
? [
|
||||
{ kind: "assistant", modelCallIndex: previous.index },
|
||||
...this.state.toolCalls
|
||||
.filter((tc) => tc.modelCallIndex === previous.index)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((tc) => ({
|
||||
kind: "toolResult" as const,
|
||||
toolCallId: tc.toolCallId,
|
||||
})),
|
||||
]
|
||||
: []; // re-issue after an interrupted call adds nothing new
|
||||
}
|
||||
const request: z.infer<typeof ModelRequest> = {
|
||||
systemPrompt: this.definition.agent.resolved.systemPrompt,
|
||||
...(isRef ? { contextRef: context } : {}),
|
||||
messages: isRef ? transcript : [...context, ...transcript],
|
||||
tools: this.definition.agent.resolved.tools,
|
||||
...(isRef && index === 0 ? { contextRef: context } : {}),
|
||||
messages: refs,
|
||||
parameters: {},
|
||||
};
|
||||
await this.append({
|
||||
|
|
@ -868,14 +891,21 @@ class TurnAdvance {
|
|||
request,
|
||||
});
|
||||
|
||||
const composed = composeModelRequest(
|
||||
this.state,
|
||||
index,
|
||||
this.resolvedContext,
|
||||
(messages) => this.model.encodeMessages(messages),
|
||||
);
|
||||
|
||||
let completion: Extract<LlmStreamEvent, { type: "completed" }> | null =
|
||||
null;
|
||||
try {
|
||||
for await (const event of this.model.stream({
|
||||
systemPrompt: request.systemPrompt,
|
||||
messages: [...this.resolvedContext, ...transcript],
|
||||
tools: request.tools,
|
||||
parameters: request.parameters,
|
||||
systemPrompt: composed.systemPrompt,
|
||||
messages: composed.messages,
|
||||
tools: composed.tools,
|
||||
parameters: composed.parameters,
|
||||
signal: this.signal,
|
||||
})) {
|
||||
switch (event.type) {
|
||||
|
|
|
|||
|
|
@ -118,9 +118,7 @@ function requested(
|
|||
ts: TS,
|
||||
modelCallIndex: index,
|
||||
request: {
|
||||
systemPrompt: "SYS",
|
||||
messages,
|
||||
tools: [echoTool, fetchTool],
|
||||
parameters: {},
|
||||
...requestOverrides,
|
||||
},
|
||||
|
|
@ -348,13 +346,13 @@ function syncToolSequence(): TEvent[] {
|
|||
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
|
||||
return [
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
permRequired("tc1", "echo"),
|
||||
permResolved("tc1", "allow"),
|
||||
invocation("tc1"),
|
||||
result("tc1", "echo"),
|
||||
requested(1, [user("hello"), call0, toolMsg("tc1", "echo")]),
|
||||
requested(1, [{ kind: "assistant", modelCallIndex: 0 }, { kind: "toolResult", toolCallId: "tc1" }]),
|
||||
completed(1, assistantText("done")),
|
||||
turnCompletedEv(),
|
||||
];
|
||||
|
|
@ -395,7 +393,7 @@ describe("plain completion", () => {
|
|||
it("reduces a plain model response to a completed turn", () => {
|
||||
const state = reduceTurn([
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
stepEvent(0),
|
||||
completed(0, assistantText("done")),
|
||||
turnCompletedEv(),
|
||||
|
|
@ -420,7 +418,7 @@ describe("plain completion", () => {
|
|||
it("leaves usage fields undefined when never reported", () => {
|
||||
const state = reduceTurn([
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, assistantText("a"), { inputTokens: 5 }),
|
||||
]);
|
||||
expect(state.usage).toEqual({ inputTokens: 5 });
|
||||
|
|
@ -436,7 +434,7 @@ describe("tool execution", () => {
|
|||
);
|
||||
const state = reduceTurn([
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
]);
|
||||
expect(state.toolCalls).toHaveLength(2);
|
||||
|
|
@ -459,7 +457,7 @@ describe("tool execution", () => {
|
|||
it("leaves identity undefined for tools missing from the agent snapshot", () => {
|
||||
const state = reduceTurn([
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, assistantCalls(toolCallPart("x1", "unknown-tool"))),
|
||||
result("x1", "unknown-tool", "runtime", "No such tool", true),
|
||||
]);
|
||||
|
|
@ -482,7 +480,7 @@ describe("tool execution", () => {
|
|||
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
|
||||
const state = reduceTurn([
|
||||
created({ config: { autoPermission: true, humanAvailable: true, maxModelCalls: 20 } }),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
permRequired("tc1", "echo"),
|
||||
permClassified("tc1", "allow"),
|
||||
|
|
@ -499,7 +497,7 @@ describe("tool execution", () => {
|
|||
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
|
||||
const state = reduceTurn([
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
permRequired("tc1", "echo"),
|
||||
permClassificationFailed(["tc1"]),
|
||||
|
|
@ -514,7 +512,7 @@ describe("tool execution", () => {
|
|||
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
|
||||
const state = reduceTurn([
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
invocation("tc1"),
|
||||
progress("tc1"),
|
||||
|
|
@ -528,7 +526,7 @@ describe("tool execution", () => {
|
|||
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
|
||||
const state = reduceTurn([
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
permRequired("tc1", "echo"),
|
||||
permResolved("tc1", "deny"),
|
||||
|
|
@ -543,7 +541,7 @@ describe("context references", () => {
|
|||
it("accepts a referenced context with matching contextRef on requests", () => {
|
||||
const state = reduceTurn([
|
||||
created({ context: { previousTurnId: PREV_TURN_ID } }),
|
||||
requested(0, [user("hello")], {
|
||||
requested(0, [{ kind: "input" }], {
|
||||
contextRef: { previousTurnId: PREV_TURN_ID },
|
||||
}),
|
||||
completed(0, assistantText("done")),
|
||||
|
|
@ -556,7 +554,7 @@ describe("context references", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created({ context: { previousTurnId: PREV_TURN_ID } }),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
],
|
||||
/contextRef inconsistent/,
|
||||
);
|
||||
|
|
@ -566,7 +564,7 @@ describe("context references", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created({ context: { previousTurnId: PREV_TURN_ID } }),
|
||||
requested(0, [user("hello")], {
|
||||
requested(0, [{ kind: "input" }], {
|
||||
contextRef: { previousTurnId: "some-other-turn" },
|
||||
}),
|
||||
],
|
||||
|
|
@ -578,7 +576,7 @@ describe("context references", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")], {
|
||||
requested(0, [{ kind: "input" }], {
|
||||
contextRef: { previousTurnId: PREV_TURN_ID },
|
||||
}),
|
||||
],
|
||||
|
|
@ -586,7 +584,7 @@ describe("context references", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("ignores tool messages inside an inline context prefix when checking order", () => {
|
||||
it("requires a {context} ref before {input} when inline context is nonempty", () => {
|
||||
const context = [
|
||||
user("earlier"),
|
||||
assistantCalls(toolCallPart("old1", "echo")),
|
||||
|
|
@ -594,11 +592,38 @@ describe("context references", () => {
|
|||
];
|
||||
const state = reduceTurn([
|
||||
created({ context }),
|
||||
requested(0, [...context, user("hello")]),
|
||||
requested(0, [{ kind: "context" }, { kind: "input" }]),
|
||||
completed(0, assistantText("done")),
|
||||
turnCompletedEv(),
|
||||
]);
|
||||
expect(deriveTurnStatus(state)).toBe("completed");
|
||||
expectCorruption(
|
||||
[created({ context }), requested(0, [{ kind: "input" }])],
|
||||
/references do not match/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a contextRef on a non-initial model call", () => {
|
||||
expectCorruption(
|
||||
[
|
||||
created({ context: { previousTurnId: PREV_TURN_ID } }),
|
||||
requested(0, [{ kind: "input" }], {
|
||||
contextRef: { previousTurnId: PREV_TURN_ID },
|
||||
}),
|
||||
completed(0, assistantCalls(toolCallPart("tc1", "echo"))),
|
||||
invocation("tc1"),
|
||||
result("tc1", "echo"),
|
||||
requested(
|
||||
1,
|
||||
[
|
||||
{ kind: "assistant", modelCallIndex: 0 },
|
||||
{ kind: "toolResult", toolCallId: "tc1" },
|
||||
],
|
||||
{ contextRef: { previousTurnId: PREV_TURN_ID } },
|
||||
),
|
||||
],
|
||||
/contextRef on a non-initial model call/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -610,7 +635,7 @@ describe("suspension", () => {
|
|||
);
|
||||
const state = reduceTurn([
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
permRequired("tc1", "echo"),
|
||||
invocation("a1", fetchTool),
|
||||
|
|
@ -628,7 +653,7 @@ describe("suspension", () => {
|
|||
);
|
||||
const state = reduceTurn([
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
permRequired("tc1", "echo"),
|
||||
invocation("a1", fetchTool),
|
||||
|
|
@ -648,7 +673,7 @@ describe("suspension", () => {
|
|||
);
|
||||
const state = reduceTurn([
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
invocation("a1", fetchTool),
|
||||
invocation("a2", fetchTool),
|
||||
|
|
@ -657,10 +682,9 @@ describe("suspension", () => {
|
|||
suspendedEv([], [{ id: "a1", tool: fetchTool }]),
|
||||
result("a1", "fetch", "async", "first"),
|
||||
requested(1, [
|
||||
user("hello"),
|
||||
call0,
|
||||
toolMsg("a1", "fetch", "first"),
|
||||
toolMsg("a2", "fetch", "second"),
|
||||
{ kind: "assistant", modelCallIndex: 0 },
|
||||
{ kind: "toolResult", toolCallId: "a1" },
|
||||
{ kind: "toolResult", toolCallId: "a2" },
|
||||
]),
|
||||
completed(1, assistantText("done")),
|
||||
turnCompletedEv(),
|
||||
|
|
@ -673,7 +697,7 @@ describe("suspension", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
permRequired("tc1", "echo"),
|
||||
permResolved("tc1", "allow"),
|
||||
|
|
@ -693,7 +717,7 @@ describe("suspension", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
permRequired("tc1", "echo"),
|
||||
invocation("a1", fetchTool),
|
||||
|
|
@ -707,7 +731,7 @@ describe("suspension", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
suspendedEv([{ id: "tc1", name: "echo" }], []),
|
||||
],
|
||||
/suspension while a model call is unsettled/,
|
||||
|
|
@ -719,9 +743,9 @@ describe("recovery-shaped histories", () => {
|
|||
it("accepts an interrupted model call closed and re-issued (§23 fix)", () => {
|
||||
const state = reduceTurn([
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
callFailed(0, "interrupted by process restart"),
|
||||
requested(1, [user("hello")]),
|
||||
requested(1, []),
|
||||
completed(1, assistantText("done")),
|
||||
turnCompletedEv(),
|
||||
]);
|
||||
|
|
@ -736,9 +760,9 @@ describe("recovery-shaped histories", () => {
|
|||
created({
|
||||
config: { autoPermission: false, humanAvailable: true, maxModelCalls: 1 },
|
||||
}),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
callFailed(0, "interrupted"),
|
||||
requested(1, [user("hello")]),
|
||||
requested(1, []),
|
||||
],
|
||||
/exceeds maxModelCalls/,
|
||||
);
|
||||
|
|
@ -748,7 +772,7 @@ describe("recovery-shaped histories", () => {
|
|||
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
|
||||
const state = reduceTurn([
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
invocation("tc1"),
|
||||
result(
|
||||
|
|
@ -758,7 +782,7 @@ describe("recovery-shaped histories", () => {
|
|||
"Tool execution was interrupted; its outcome is unknown and it was not retried.",
|
||||
true,
|
||||
),
|
||||
requested(1, [user("hello"), call0, toolMsg("tc1", "echo", "…")]),
|
||||
requested(1, [{ kind: "assistant", modelCallIndex: 0 }, { kind: "toolResult", toolCallId: "tc1" }]),
|
||||
completed(1, assistantText("done")),
|
||||
turnCompletedEv(),
|
||||
]);
|
||||
|
|
@ -770,7 +794,7 @@ describe("recovery-shaped histories", () => {
|
|||
const call0 = assistantCalls(toolCallPart("a1", "fetch"));
|
||||
const state = reduceTurn([
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
invocation("a1", fetchTool),
|
||||
suspendedEv([], [{ id: "a1", tool: fetchTool }]),
|
||||
|
|
@ -784,7 +808,7 @@ describe("recovery-shaped histories", () => {
|
|||
it("accepts a live model failure closing the turn", () => {
|
||||
const state = reduceTurn([
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
callFailed(0),
|
||||
turnFailedEv("provider exploded"),
|
||||
]);
|
||||
|
|
@ -797,7 +821,7 @@ describe("recovery-shaped histories", () => {
|
|||
created({
|
||||
config: { autoPermission: false, humanAvailable: true, maxModelCalls: 1 },
|
||||
}),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
invocation("tc1"),
|
||||
result("tc1", "echo"),
|
||||
|
|
@ -816,7 +840,7 @@ describe("invariants", () => {
|
|||
});
|
||||
|
||||
it("rejects a log not starting with turn_created", () => {
|
||||
expectCorruption([requested(0, [user("hello")])], /first event must be turn_created/);
|
||||
expectCorruption([requested(0, [{ kind: "input" }])], /first event must be turn_created/);
|
||||
});
|
||||
|
||||
it("rejects duplicate turn_created", () => {
|
||||
|
|
@ -825,7 +849,7 @@ describe("invariants", () => {
|
|||
|
||||
it("rejects mismatched turn ids", () => {
|
||||
expectCorruption(
|
||||
[created(), { ...requested(0, [user("hello")]), turnId: "other" }],
|
||||
[created(), { ...requested(0, [{ kind: "input" }]), turnId: "other" }],
|
||||
/does not match turn/,
|
||||
);
|
||||
});
|
||||
|
|
@ -849,7 +873,7 @@ describe("invariants", () => {
|
|||
|
||||
it("rejects out-of-order model call indices", () => {
|
||||
expectCorruption(
|
||||
[created(), requested(1, [user("hello")])],
|
||||
[created(), requested(1, [])],
|
||||
/out of order/,
|
||||
);
|
||||
});
|
||||
|
|
@ -858,9 +882,9 @@ describe("invariants", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, assistantText("a")),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
],
|
||||
/out of order/,
|
||||
);
|
||||
|
|
@ -868,7 +892,7 @@ describe("invariants", () => {
|
|||
|
||||
it("rejects concurrent unresolved model requests", () => {
|
||||
expectCorruption(
|
||||
[created(), requested(0, [user("hello")]), requested(1, [user("hello")])],
|
||||
[created(), requested(0, [{ kind: "input" }]), requested(1, [])],
|
||||
/concurrent unresolved model call requests/,
|
||||
);
|
||||
});
|
||||
|
|
@ -888,7 +912,7 @@ describe("invariants", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, assistantText("a")),
|
||||
completed(0, assistantText("b")),
|
||||
],
|
||||
|
|
@ -900,7 +924,7 @@ describe("invariants", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
callFailed(0),
|
||||
completed(0, assistantText("a")),
|
||||
],
|
||||
|
|
@ -913,9 +937,9 @@ describe("invariants", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
requested(1, [user("hello"), call0]),
|
||||
requested(1, [{ kind: "assistant", modelCallIndex: 0 }]),
|
||||
],
|
||||
/while tool calls are unresolved/,
|
||||
);
|
||||
|
|
@ -928,11 +952,11 @@ describe("invariants", () => {
|
|||
created({
|
||||
config: { autoPermission: false, humanAvailable: true, maxModelCalls: 1 },
|
||||
}),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
invocation("tc1"),
|
||||
result("tc1", "echo"),
|
||||
requested(1, [user("hello"), call0, toolMsg("tc1", "echo")]),
|
||||
requested(1, [{ kind: "assistant", modelCallIndex: 0 }, { kind: "toolResult", toolCallId: "tc1" }]),
|
||||
],
|
||||
/exceeds maxModelCalls/,
|
||||
);
|
||||
|
|
@ -942,7 +966,7 @@ describe("invariants", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(
|
||||
0,
|
||||
assistantCalls(toolCallPart("tc1", "echo"), toolCallPart("tc1", "echo")),
|
||||
|
|
@ -957,18 +981,18 @@ describe("invariants", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
invocation("tc1"),
|
||||
result("tc1", "echo"),
|
||||
requested(1, [user("hello"), call0, toolMsg("tc1", "echo")]),
|
||||
requested(1, [{ kind: "assistant", modelCallIndex: 0 }, { kind: "toolResult", toolCallId: "tc1" }]),
|
||||
completed(1, assistantCalls(toolCallPart("tc1", "echo"))),
|
||||
],
|
||||
/duplicate tool call id/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects tool-result ordering that differs from original call order", () => {
|
||||
it("rejects request references whose tool-result order differs from the batch", () => {
|
||||
const call0 = assistantCalls(
|
||||
toolCallPart("tc1", "echo"),
|
||||
toolCallPart("tc2", "echo"),
|
||||
|
|
@ -976,26 +1000,25 @@ describe("invariants", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
invocation("tc1"),
|
||||
result("tc1", "echo"),
|
||||
invocation("tc2"),
|
||||
result("tc2", "echo"),
|
||||
requested(1, [
|
||||
user("hello"),
|
||||
call0,
|
||||
toolMsg("tc2", "echo"),
|
||||
toolMsg("tc1", "echo"),
|
||||
{ kind: "assistant", modelCallIndex: 0 },
|
||||
{ kind: "toolResult", toolCallId: "tc2" },
|
||||
{ kind: "toolResult", toolCallId: "tc1" },
|
||||
]),
|
||||
],
|
||||
/tool-result order differs/,
|
||||
/references do not match/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects permission records targeting unknown tool calls", () => {
|
||||
expectCorruption(
|
||||
[created(), requested(0, [user("hello")]), completed(0, assistantText("a")), permRequired("ghost", "echo")],
|
||||
[created(), requested(0, [{ kind: "input" }]), completed(0, assistantText("a")), permRequired("ghost", "echo")],
|
||||
/unknown tool call/,
|
||||
);
|
||||
});
|
||||
|
|
@ -1005,7 +1028,7 @@ describe("invariants", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
permRequired("tc1", "echo"),
|
||||
permRequired("tc1", "echo"),
|
||||
|
|
@ -1019,7 +1042,7 @@ describe("invariants", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
permClassified("tc1", "allow"),
|
||||
],
|
||||
|
|
@ -1032,7 +1055,7 @@ describe("invariants", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
permResolved("tc1", "allow"),
|
||||
],
|
||||
|
|
@ -1045,7 +1068,7 @@ describe("invariants", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
permRequired("tc1", "echo"),
|
||||
permResolved("tc1", "allow"),
|
||||
|
|
@ -1060,7 +1083,7 @@ describe("invariants", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
permRequired("tc1", "echo"),
|
||||
invocation("tc1"),
|
||||
|
|
@ -1074,7 +1097,7 @@ describe("invariants", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
permRequired("tc1", "echo"),
|
||||
permResolved("tc1", "deny"),
|
||||
|
|
@ -1089,7 +1112,7 @@ describe("invariants", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
invocation("tc1"),
|
||||
invocation("tc1"),
|
||||
|
|
@ -1103,7 +1126,7 @@ describe("invariants", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
invocation("tc1", echoTool, { execution: "async" }),
|
||||
],
|
||||
|
|
@ -1114,7 +1137,7 @@ describe("invariants", () => {
|
|||
it("rejects progress without invocation", () => {
|
||||
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
|
||||
expectCorruption(
|
||||
[created(), requested(0, [user("hello")]), completed(0, call0), progress("tc1")],
|
||||
[created(), requested(0, [{ kind: "input" }]), completed(0, call0), progress("tc1")],
|
||||
/progress without invocation/,
|
||||
);
|
||||
});
|
||||
|
|
@ -1124,7 +1147,7 @@ describe("invariants", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
invocation("tc1"),
|
||||
result("tc1", "echo"),
|
||||
|
|
@ -1139,7 +1162,7 @@ describe("invariants", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
invocation("tc1"),
|
||||
result("tc1", "echo"),
|
||||
|
|
@ -1152,7 +1175,7 @@ describe("invariants", () => {
|
|||
it("rejects sync-sourced results without invocation", () => {
|
||||
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
|
||||
expectCorruption(
|
||||
[created(), requested(0, [user("hello")]), completed(0, call0), result("tc1", "echo", "sync")],
|
||||
[created(), requested(0, [{ kind: "input" }]), completed(0, call0), result("tc1", "echo", "sync")],
|
||||
/sync tool result without invocation/,
|
||||
);
|
||||
});
|
||||
|
|
@ -1162,7 +1185,7 @@ describe("invariants", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
invocation("tc1"),
|
||||
result("tc1", "echo", "async"),
|
||||
|
|
@ -1176,7 +1199,7 @@ describe("invariants", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, assistantText("a")),
|
||||
stepEvent(0),
|
||||
],
|
||||
|
|
@ -1187,7 +1210,7 @@ describe("invariants", () => {
|
|||
it("rejects completion while tool calls remain unresolved", () => {
|
||||
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
|
||||
expectCorruption(
|
||||
[created(), requested(0, [user("hello")]), completed(0, call0), turnCompletedEv()],
|
||||
[created(), requested(0, [{ kind: "input" }]), completed(0, call0), turnCompletedEv()],
|
||||
/completion while tool calls lack terminal results/,
|
||||
);
|
||||
});
|
||||
|
|
@ -1197,7 +1220,7 @@ describe("invariants", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
invocation("tc1"),
|
||||
result("tc1", "echo"),
|
||||
|
|
@ -1214,14 +1237,14 @@ describe("invariants", () => {
|
|||
it("rejects terminal failure while tool calls remain unresolved", () => {
|
||||
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
|
||||
expectCorruption(
|
||||
[created(), requested(0, [user("hello")]), completed(0, call0), turnFailedEv()],
|
||||
[created(), requested(0, [{ kind: "input" }]), completed(0, call0), turnFailedEv()],
|
||||
/failure while tool calls lack terminal results/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects terminal events while a model call is unsettled", () => {
|
||||
expectCorruption(
|
||||
[created(), requested(0, [user("hello")]), turnCancelledEv()],
|
||||
[created(), requested(0, [{ kind: "input" }]), turnCancelledEv()],
|
||||
/cancellation while a model call is unsettled/,
|
||||
);
|
||||
});
|
||||
|
|
@ -1229,7 +1252,7 @@ describe("invariants", () => {
|
|||
it("rejects any event after a terminal event", () => {
|
||||
const base = [
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, assistantText("a")),
|
||||
];
|
||||
for (const terminal of [turnCompletedEv(), turnFailedEv(), turnCancelledEv()]) {
|
||||
|
|
@ -1244,7 +1267,7 @@ describe("invariants", () => {
|
|||
expectCorruption(
|
||||
[
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, assistantText("a")),
|
||||
turnCompletedEv(),
|
||||
turnFailedEv(),
|
||||
|
|
@ -1259,14 +1282,14 @@ describe("derivations", () => {
|
|||
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
|
||||
const pending = reduceTurn([
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
permRequired("tc1", "echo"),
|
||||
suspendedEv([{ id: "tc1", name: "echo" }], []),
|
||||
]);
|
||||
expect(deriveTurnStatus(pending)).toBe("suspended");
|
||||
|
||||
const idle = reduceTurn([created(), requested(0, [user("hello")]), completed(0, call0)]);
|
||||
const idle = reduceTurn([created(), requested(0, [{ kind: "input" }]), completed(0, call0)]);
|
||||
expect(deriveTurnStatus(idle)).toBe("idle");
|
||||
});
|
||||
|
||||
|
|
@ -1277,17 +1300,16 @@ describe("derivations", () => {
|
|||
);
|
||||
const state = reduceTurn([
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
invocation("tc1"),
|
||||
invocation("tc2"),
|
||||
result("tc2", "echo", "sync", { n: 2 }),
|
||||
result("tc1", "echo", "sync", "plain text"),
|
||||
requested(1, [
|
||||
user("hello"),
|
||||
call0,
|
||||
toolMsg("tc1", "echo", "plain text"),
|
||||
toolMsg("tc2", "echo", '{"n":2}'),
|
||||
{ kind: "assistant", modelCallIndex: 0 },
|
||||
{ kind: "toolResult", toolCallId: "tc1" },
|
||||
{ kind: "toolResult", toolCallId: "tc2" },
|
||||
]),
|
||||
completed(1, assistantText("done")),
|
||||
turnCompletedEv(),
|
||||
|
|
@ -1304,9 +1326,9 @@ describe("derivations", () => {
|
|||
it("omits failed model calls from the transcript", () => {
|
||||
const state = reduceTurn([
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
callFailed(0, "interrupted"),
|
||||
requested(1, [user("hello")]),
|
||||
requested(1, []),
|
||||
completed(1, assistantText("done")),
|
||||
turnCompletedEv(),
|
||||
]);
|
||||
|
|
@ -1316,7 +1338,7 @@ describe("derivations", () => {
|
|||
it("transcript excludes the context prefix", () => {
|
||||
const state = reduceTurn([
|
||||
created({ context: { previousTurnId: PREV_TURN_ID } }),
|
||||
requested(0, [user("hello")], {
|
||||
requested(0, [{ kind: "input" }], {
|
||||
contextRef: { previousTurnId: PREV_TURN_ID },
|
||||
}),
|
||||
completed(0, assistantText("done")),
|
||||
|
|
@ -1330,7 +1352,7 @@ describe("derivations", () => {
|
|||
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
|
||||
const state = reduceTurn([
|
||||
created(),
|
||||
requested(0, [user("hello")]),
|
||||
requested(0, [{ kind: "input" }]),
|
||||
completed(0, call0),
|
||||
]);
|
||||
expect(() => turnTranscript(state)).toThrowError(/unresolved/);
|
||||
|
|
|
|||
|
|
@ -119,16 +119,30 @@ export const TurnCreated = z.object({
|
|||
}),
|
||||
});
|
||||
|
||||
// `messages` contains current-turn messages only; when the turn's context is
|
||||
// a reference, `contextRef` repeats it and the resolved prefix is implicit
|
||||
// (deterministically materializable because referenced turns are immutable).
|
||||
// When context is inline, `contextRef` is absent and `messages` begins with
|
||||
// the inline context.
|
||||
// A model request is a list of REFERENCES into the turn's own events; every
|
||||
// referenced byte exists exactly once in the file. The request records only
|
||||
// what is NEW since the previous model call:
|
||||
// call 0: [{context}?, {input}] (context only when inline and nonempty;
|
||||
// cross-turn prefixes ride contextRef)
|
||||
// call N: [{assistant: N-1}, ...that batch's toolResults in source order]
|
||||
// re-issue after an interrupted call: []
|
||||
// The system prompt and tool set are NOT repeated here — they are byte-
|
||||
// identical to turn_created.agent.resolved by construction. The exact
|
||||
// provider payload is rebuilt deterministically by the request composer
|
||||
// (core), which is the same code path the loop sends through.
|
||||
export const ModelRequestMessageRef = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("context") }),
|
||||
z.object({ kind: z.literal("input") }),
|
||||
z.object({
|
||||
kind: z.literal("assistant"),
|
||||
modelCallIndex: z.number().int().nonnegative(),
|
||||
}),
|
||||
z.object({ kind: z.literal("toolResult"), toolCallId: z.string() }),
|
||||
]);
|
||||
|
||||
export const ModelRequest = z.object({
|
||||
systemPrompt: z.string(),
|
||||
contextRef: TurnContextRef.optional(),
|
||||
messages: z.array(ConversationMessage),
|
||||
tools: z.array(ToolDescriptor),
|
||||
messages: z.array(ModelRequestMessageRef),
|
||||
parameters: z.record(z.string(), z.json()),
|
||||
});
|
||||
|
||||
|
|
@ -491,32 +505,44 @@ function applyModelCallRequested(
|
|||
}
|
||||
|
||||
const context = state.definition.context;
|
||||
if (isContextRef(context)) {
|
||||
if (event.request.contextRef?.previousTurnId !== context.previousTurnId) {
|
||||
fail("model request contextRef inconsistent with turn context");
|
||||
let expectedRefs: Array<z.infer<typeof ModelRequestMessageRef>>;
|
||||
if (event.modelCallIndex === 0) {
|
||||
if (isContextRef(context)) {
|
||||
if (event.request.contextRef?.previousTurnId !== context.previousTurnId) {
|
||||
fail("model request contextRef inconsistent with turn context");
|
||||
}
|
||||
expectedRefs = [{ kind: "input" }];
|
||||
} else {
|
||||
if (event.request.contextRef !== undefined) {
|
||||
fail("model request has contextRef but turn context is inline");
|
||||
}
|
||||
expectedRefs =
|
||||
context.length > 0
|
||||
? [{ kind: "context" }, { kind: "input" }]
|
||||
: [{ kind: "input" }];
|
||||
}
|
||||
} else if (event.request.contextRef !== undefined) {
|
||||
fail("model request has contextRef but turn context is inline");
|
||||
} else {
|
||||
if (event.request.contextRef !== undefined) {
|
||||
fail("model request has contextRef on a non-initial model call");
|
||||
}
|
||||
const previous = state.modelCalls[event.modelCallIndex - 1];
|
||||
expectedRefs =
|
||||
previous.response !== undefined
|
||||
? [
|
||||
{ kind: "assistant", modelCallIndex: previous.index },
|
||||
...batchToolCalls(state, previous.index).map((tc) => ({
|
||||
kind: "toolResult" as const,
|
||||
toolCallId: tc.toolCallId,
|
||||
})),
|
||||
]
|
||||
: []; // re-issue after an interrupted call adds nothing new
|
||||
}
|
||||
|
||||
// Model-facing tool-result order must match original call order: the tool
|
||||
// messages in the request (past any inline context prefix) must be the
|
||||
// concatenated batches of prior completed calls, in source order.
|
||||
const prefixLength = isContextRef(context) ? 0 : context.length;
|
||||
const toolMessageIds = event.request.messages
|
||||
.slice(prefixLength)
|
||||
.filter((m) => m.role === "tool")
|
||||
.map((m) => m.toolCallId);
|
||||
const expectedIds = state.modelCalls
|
||||
.filter((call) => call.response !== undefined)
|
||||
.flatMap((call) =>
|
||||
batchToolCalls(state, call.index).map((tc) => tc.toolCallId),
|
||||
if (JSON.stringify(event.request.messages) !== JSON.stringify(expectedRefs)) {
|
||||
fail(
|
||||
`model request references do not match the transcript: expected ${JSON.stringify(
|
||||
expectedRefs,
|
||||
)}, got ${JSON.stringify(event.request.messages)}`,
|
||||
);
|
||||
if (
|
||||
toolMessageIds.length !== expectedIds.length ||
|
||||
toolMessageIds.some((id, i) => id !== expectedIds[i])
|
||||
) {
|
||||
fail("model request tool-result order differs from original call order");
|
||||
}
|
||||
|
||||
state.modelCalls.push({
|
||||
|
|
@ -932,6 +958,69 @@ function toolResultContent(result: z.infer<typeof ToolResult>): string {
|
|||
return typeof output === "string" ? output : JSON.stringify(output);
|
||||
}
|
||||
|
||||
// The canonical model-facing tool message for a resolved tool call.
|
||||
export function toolResultMessage(
|
||||
toolCall: ToolCallState,
|
||||
): z.infer<typeof ConversationMessage> {
|
||||
if (!toolCall.result) {
|
||||
throw new Error(
|
||||
`tool call ${toolCall.toolCallId} has no terminal result`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
role: "tool",
|
||||
content: toolResultContent(toolCall.result),
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
};
|
||||
}
|
||||
|
||||
// Resolves one model call's request references to structural messages (the
|
||||
// NEW portion that call added relative to the previous one). Concatenating
|
||||
// calls 0..N yields the full current-turn conversation for call N; the
|
||||
// request composer (core) prepends the resolved cross-turn prefix and
|
||||
// encodes to the provider wire form.
|
||||
export function requestMessagesFor(
|
||||
state: TurnState,
|
||||
modelCallIndex: number,
|
||||
): Array<z.infer<typeof ConversationMessage>> {
|
||||
const call = state.modelCalls[modelCallIndex];
|
||||
if (!call) {
|
||||
throw new Error(`no model call at index ${modelCallIndex}`);
|
||||
}
|
||||
return call.request.messages.flatMap((ref) => {
|
||||
switch (ref.kind) {
|
||||
case "context": {
|
||||
const context = state.definition.context;
|
||||
if (!Array.isArray(context)) {
|
||||
throw new Error("context ref on a turn without inline context");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
case "input":
|
||||
return [state.definition.input];
|
||||
case "assistant": {
|
||||
const response = state.modelCalls[ref.modelCallIndex]?.response;
|
||||
if (response === undefined) {
|
||||
throw new Error(
|
||||
`assistant ref to unsettled model call ${ref.modelCallIndex}`,
|
||||
);
|
||||
}
|
||||
return [response];
|
||||
}
|
||||
case "toolResult": {
|
||||
const toolCall = state.toolCalls.find(
|
||||
(tc) => tc.toolCallId === ref.toolCallId,
|
||||
);
|
||||
if (!toolCall) {
|
||||
throw new Error(`toolResult ref to unknown call ${ref.toolCallId}`);
|
||||
}
|
||||
return [toolResultMessage(toolCall)];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The messages this turn contributed to the conversation: its input plus, per
|
||||
// completed model call, the assistant response and its tool results in source
|
||||
// order. Failed model calls contribute nothing. The turn's context prefix is
|
||||
|
|
@ -955,12 +1044,7 @@ export function turnTranscript(
|
|||
`turnTranscript requires terminal tool results; ${toolCall.toolCallId} is unresolved`,
|
||||
);
|
||||
}
|
||||
messages.push({
|
||||
role: "tool",
|
||||
content: toolResultContent(toolCall.result),
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
});
|
||||
messages.push(toolResultMessage(toolCall));
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue