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:
Ramnique Singh 2026-07-02 12:46:23 +05:30
parent a58493b545
commit 3822bf53da
18 changed files with 524 additions and 227 deletions

View file

@ -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,

View file

@ -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)

View file

@ -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),
]

View file

@ -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…')
})