diff --git a/console/web/src/components/chat/ChatView.tsx b/console/web/src/components/chat/ChatView.tsx index 600521ad1..16ffcbc02 100644 --- a/console/web/src/components/chat/ChatView.tsx +++ b/console/web/src/components/chat/ChatView.tsx @@ -13,7 +13,6 @@ import { } from '@/hooks/use-harness-status' import { useLiveAnnouncer } from '@/hooks/use-live-announcer' import type { ChatBackend } from '@/lib/backend' -import { translateUiHistoryForBackend } from '@/lib/backend/history' import type { CompactResult } from '@/lib/backend/types' import { useConversationsCtxOptional } from '@/lib/conversations-context' import { formatStopReason } from '@/lib/format-stop-reason' @@ -171,15 +170,6 @@ export function ChatView({ }) }, [sessionId]) - /* Read the messages snapshot through a ref inside handleSubmit so - * the callback identity is stable across token-by-token re-renders. - * Pre-fix, listing `conversation.messages` in the deps array - * rebuilt handleSubmit on every assistant/thought delta, which - * cascaded into Composer rebuilding and `LexicalShell` re-binding - * its `KEY_ENTER_COMMAND` listener once per token. */ - const messagesRef = useRef(conversation.messages) - messagesRef.current = conversation.messages - const handleSubmit = useCallback( async (payload: ComposerSubmitPayload) => { if (harnessBlockedRef.current) return @@ -193,10 +183,6 @@ export function ChatView({ return } - // Snapshot prior history BEFORE appending the new user msg — - // run::start overwrites flat state with whatever we send. - const priorHistory = translateUiHistoryForBackend(messagesRef.current) - const userMsg: UserMessage = { id: uid(), role: 'user', @@ -232,7 +218,6 @@ export function ChatView({ const result = await backend.compactSession( sessionId, model, - priorHistory, contextWindow, ) if (result.status === 'ok') { @@ -281,7 +266,7 @@ export function ChatView({ payload.text || '(attachments only)', conversation.mode, model, - { signal: controller.signal, sessionId, history: priorHistory }, + { signal: controller.signal, sessionId }, )) { switch (event.kind) { case 'thought-start': { diff --git a/console/web/src/lib/backend/history.test.ts b/console/web/src/lib/backend/history.test.ts deleted file mode 100644 index b970184c8..000000000 --- a/console/web/src/lib/backend/history.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { Message } from '@/types/chat' -import { translateUiHistoryForBackend } from './history' - -const user = (content: string, createdAt = 1): Message => ({ - id: `u-${createdAt}`, - role: 'user', - content, - createdAt, -}) - -const assistant = ( - content: string, - model?: string, - createdAt = 2, -): Message => ({ - id: `a-${createdAt}`, - role: 'assistant', - content, - model: model as Message extends { role: 'assistant' } - ? Message['model'] - : never, - createdAt, -}) - -describe('translateUiHistoryForBackend', () => { - it('round-trips text user + assistant messages in order', () => { - const out = translateUiHistoryForBackend([ - user('hello', 1), - assistant('hi there', 'anthropic::claude-haiku-4-5', 2), - user('follow up', 3), - ]) - expect(out).toHaveLength(3) - expect(out[0]).toMatchObject({ - role: 'user', - content: [{ type: 'text', text: 'hello' }], - timestamp: 1, - }) - expect(out[1]).toMatchObject({ - role: 'assistant', - content: [{ type: 'text', text: 'hi there' }], - stop_reason: 'end', - model: 'anthropic::claude-haiku-4-5', - provider: 'anthropic', - timestamp: 2, - }) - expect(out[2]).toMatchObject({ - role: 'user', - content: [{ type: 'text', text: 'follow up' }], - timestamp: 3, - }) - }) - - it('drops thoughts, function-calls, and system notices', () => { - const out = translateUiHistoryForBackend([ - user('hi', 1), - { - id: 't1', - role: 'thought', - content: 'thinking…', - durationMs: 100, - createdAt: 2, - }, - { - id: 'fc1', - role: 'function-call', - functionId: 'engine::echo', - input: { x: 1 }, - output: { y: 2 }, - createdAt: 3, - }, - { - id: 's1', - role: 'system', - content: 'compacted — 1k tokens', - tone: 'info', - createdAt: 4, - }, - assistant('done', 'anthropic::claude-haiku-4-5', 5), - ]) - expect(out.map((m) => m.role)).toEqual(['user', 'assistant']) - }) - - it('drops assistant turns with empty content (aborted/errored)', () => { - const out = translateUiHistoryForBackend([ - user('hi', 1), - assistant('', undefined, 2), - user('try again', 3), - ]) - expect(out.map((m) => m.role)).toEqual(['user', 'user']) - }) - - it('infers provider from heuristic model ids without `::`', () => { - const out = translateUiHistoryForBackend([ - assistant('a', 'claude-haiku-4-5', 1), - assistant('b', 'gemini-2.0-flash', 2), - assistant('c', 'gpt-4o', 3), - ]) - expect(out).toHaveLength(3) - if (out[0]?.role === 'assistant') expect(out[0].provider).toBe('anthropic') - if (out[1]?.role === 'assistant') expect(out[1].provider).toBe('google') - if (out[2]?.role === 'assistant') expect(out[2].provider).toBe('openai') - }) - - it('handles assistant turns with no model id (defaults to empty provider)', () => { - const out = translateUiHistoryForBackend([assistant('hello', undefined, 1)]) - expect(out).toHaveLength(1) - if (out[0]?.role === 'assistant') { - expect(out[0].model).toBe('') - expect(out[0].provider).toBe('') - } - }) - - it('returns empty array for empty input', () => { - expect(translateUiHistoryForBackend([])).toEqual([]) - }) - - describe('compaction markers', () => { - const compactionMarker = ( - summaryText: string, - createdAt = 100, - ): Message => ({ - id: `c-${createdAt}`, - role: 'system', - kind: 'compaction', - content: 'compacted', - tone: 'info', - summaryText, - tokensBefore: 12_345, - createdAt, - }) - - it('replaces pre-marker history with one assistant ', () => { - const out = translateUiHistoryForBackend([ - user('first turn', 1), - assistant('first answer', 'anthropic::claude-haiku-4-5', 2), - user('second turn', 3), - assistant('second answer', 'anthropic::claude-haiku-4-5', 4), - compactionMarker('Discussed turns 1-2 about X and Y.', 5), - ]) - // Pre-marker user+assistant turns are SHED; the marker itself becomes - // a single assistant block. - expect(out).toHaveLength(1) - expect(out[0]).toMatchObject({ - role: 'assistant', - stop_reason: 'end', - timestamp: 5, - }) - const block = out[0]?.role === 'assistant' ? out[0].content[0] : null - if (block && block.type === 'text') { - expect(block.text).toContain('') - expect(block.text).toContain('Discussed turns 1-2 about X and Y.') - expect(block.text).toContain('') - } else { - throw new Error('expected first content block to be text') - } - }) - - it('keeps post-marker messages and ships them after the summary', () => { - const out = translateUiHistoryForBackend([ - user('shed me', 1), - assistant('shed me too', 'anthropic::claude-haiku-4-5', 2), - compactionMarker('Summary of the shed turns.', 3), - user('survives', 4), - assistant('also survives', 'anthropic::claude-haiku-4-5', 5), - ]) - expect(out.map((m) => m.role)).toEqual(['assistant', 'user', 'assistant']) - const summary = out[0]?.role === 'assistant' ? out[0].content[0] : null - if (summary && summary.type === 'text') { - expect(summary.text).toContain('Summary of the shed turns.') - } else { - throw new Error('expected summary text block') - } - expect(out[1]).toMatchObject({ - role: 'user', - content: [{ type: 'text', text: 'survives' }], - }) - }) - - it('uses only the LAST marker when several compactions stacked up', () => { - const out = translateUiHistoryForBackend([ - user('ancient', 1), - compactionMarker('first summary (covers ancient)', 2), - user('older', 3), - assistant('older answer', 'anthropic::claude-haiku-4-5', 4), - compactionMarker('second summary (covers older too)', 5), - user('current', 6), - ]) - expect(out).toHaveLength(2) - const summary = out[0]?.role === 'assistant' ? out[0].content[0] : null - if (summary && summary.type === 'text') { - expect(summary.text).toContain('second summary') - expect(summary.text).not.toContain('first summary') - } else { - throw new Error('expected summary text block') - } - expect(out[1]).toMatchObject({ role: 'user' }) - }) - - it('falls back to plain translation when marker has no summaryText', () => { - const marker: Message = { - id: 'm1', - role: 'system', - kind: 'compaction', - content: 'compacted', - tone: 'info', - createdAt: 5, - } - const out = translateUiHistoryForBackend([ - user('a', 1), - marker, - user('b', 6), - ]) - // Marker emits nothing (no summaryText); pre-marker user is shed; only - // post-marker user survives. Pre-marker shedding is the important - // invariant — we never want to ship summarised content alongside the - // raw turns it replaces. - expect(out).toHaveLength(1) - expect(out[0]).toMatchObject({ - role: 'user', - content: [{ type: 'text', text: 'b' }], - }) - }) - }) -}) diff --git a/console/web/src/lib/backend/history.ts b/console/web/src/lib/backend/history.ts deleted file mode 100644 index 84e517dde..000000000 --- a/console/web/src/lib/backend/history.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Round-trips user + assistant text only. Function-call traces are dropped - * because their `function_call_id` pairing doesn't survive across runs and - * Anthropic rejects orphan tool_result blocks. - */ - -import type { Message } from '@/types/chat' -import type { - AgentMessage, - AssistantMessage, - UserMessage, -} from '@/types/iii-agent-event' - -export function translateUiHistoryForBackend( - messages: readonly Message[], -): AgentMessage[] { - // Stacked compactions: only the most recent summary needs to ship since - // the anchored prompt in summarize.ts already folds the prior one in. - let lastCompactIdx = -1 - for (let i = messages.length - 1; i >= 0; i--) { - const m = messages[i] - if (m.role === 'system' && m.kind === 'compaction') { - lastCompactIdx = i - break - } - } - - const out: AgentMessage[] = [] - const start = lastCompactIdx >= 0 ? lastCompactIdx : 0 - - if (lastCompactIdx >= 0) { - const marker = messages[lastCompactIdx] as Extract< - Message, - { role: 'system' } - > - const summary = marker.summaryText ?? '' - if (summary.length > 0) { - // Wire-identical to harness's buildSummaryMessage. - out.push({ - role: 'assistant', - content: [ - { - type: 'text', - text: `\n${summary}\n`, - }, - ], - stop_reason: 'end', - model: '', - provider: '', - timestamp: marker.createdAt, - }) - } - } - - for ( - let i = start + (lastCompactIdx >= 0 ? 1 : 0); - i < messages.length; - i++ - ) { - const m = messages[i] - if (m.role === 'user') out.push(toUserMessage(m)) - else if (m.role === 'assistant') { - const asst = toAssistantMessage(m) - if (asst) out.push(asst) - } - } - return out -} - -function toUserMessage(m: Extract): UserMessage { - return { - role: 'user', - content: [{ type: 'text', text: m.content }], - timestamp: m.createdAt, - } -} - -function toAssistantMessage( - m: Extract, -): AssistantMessage | null { - // Empty-content assistant messages confuse providers; the wire shape - // requires at least one text block. - if (!m.content || m.content.length === 0) return null - return { - role: 'assistant', - content: [{ type: 'text', text: m.content }], - stop_reason: 'end', - model: m.model ?? '', - provider: providerForModel(m.model), - timestamp: m.createdAt, - } -} - -// Mirrors resolveRunParams in real.ts. -function providerForModel(model: string | undefined): string { - if (!model) return '' - const i = model.indexOf('::') - if (i > 0) return model.slice(0, i) - if (model.startsWith('claude')) return 'anthropic' - if (model.startsWith('gemini')) return 'google' - return 'openai' -} diff --git a/console/web/src/lib/backend/real.ts b/console/web/src/lib/backend/real.ts index ec676f9d6..719b0cb7c 100644 --- a/console/web/src/lib/backend/real.ts +++ b/console/web/src/lib/backend/real.ts @@ -7,7 +7,7 @@ import { parseCatalogModelKey } from '@/lib/catalog-model-key' import { getIiiClient } from '@/lib/iii-client' import { newMessageId } from '@/lib/session-id' import type { Mode, ModelId } from '@/types/chat' -import type { AgentEvent, AgentMessage } from '@/types/iii-agent-event' +import type { AgentEvent } from '@/types/iii-agent-event' import { startSessionEventsSubscription } from './session-events-live' import { createAgentEventTranslator } from './translate' import type { @@ -108,7 +108,6 @@ async function* realStream( model: modelId, mode, messages: [ - ...(opts?.history ?? []), { role: 'user', content: [{ type: 'text', text: prompt }], @@ -173,38 +172,11 @@ async function realResolveApproval( async function realCompactSession( sessionId: string, model: ModelId, - history?: AgentMessage[], contextWindow?: number, ): Promise { const { provider, model: modelId } = resolveRunParams(model) const client = await getIiiClient() try { - // Reconcile session-tree with the UI's history before compacting so a - // stale tree (only the first turn mirrored) doesn't yield a spurious - // 'empty' when the UI has plenty to summarise. No-op when the tree - // already has equal-or-more entries. - let reconcileFailed = false - if (history && history.length > 0) { - await client - .call('session-tree::ensure', { session_id: sessionId }) - .catch(() => {}) - await client - .call('session-tree::reconcile', { - session_id: sessionId, - state_snapshot: history, - }) - .catch((err) => { - // A failed reconcile can yield a spurious 'empty' from a stale tree. - reconcileFailed = true - if (import.meta.env.DEV) { - console.warn( - '[compact_session] reconcile failed; compacting against current session-tree', - err, - ) - } - }) - } - // Passing limit.context lets the server skip the models::get lookup. // We don't know max_output here; 4096 is the same conservative default // the server falls back to when models::get returns nothing. @@ -234,21 +206,11 @@ async function realCompactSession( session_id: sessionId, model: modelPayload, }) - // Empty + reconcileFailed → likely stale tree; tell the user to retry. - const surfaceEmpty = (): CompactResult => - reconcileFailed - ? { - status: 'error', - message: - 'compact: could not sync session history to server; retry /compact', - } - : { status: 'empty' } - if (resp?.status === 'ok') { const tokensBefore = typeof resp.tokens_before === 'number' ? resp.tokens_before : 0 // Surface zero-token "ok" as semantic empty. - if (tokensBefore === 0) return surfaceEmpty() + if (tokensBefore === 0) return { status: 'empty' } // Fallback placeholder for engines that predate summary_text on the // wire; without it the marker has no to ship. const summaryText = @@ -270,7 +232,7 @@ async function realCompactSession( : 'unknown summariser error' return { status: 'overflow', message } } - if (resp?.status === 'empty') return surfaceEmpty() + if (resp?.status === 'empty') return { status: 'empty' } return { status: 'error', message: `unexpected status: ${String(resp?.status ?? 'null')}`, diff --git a/console/web/src/lib/backend/session-events-live.ts b/console/web/src/lib/backend/session-events-live.ts index 7158d77bd..ec6aeb2f8 100644 --- a/console/web/src/lib/backend/session-events-live.ts +++ b/console/web/src/lib/backend/session-events-live.ts @@ -13,8 +13,8 @@ * The handler is named with the `iii::` prefix (`is_iii_builtin_function_id`), * so the spans produced by DELIVERING this trigger are tagged * `iii.function.kind=internal` — hidden from the Traces view by the default - * `include_internal:false` query and skipped by the engine's trigger - * loop-break, matching the `traces-live.ts` approach. Without this, every + * `include_internal:false` query and skipped by the engine's trigger/stream + * loop-break, matching the `traces-stream.ts` approach. Without this, every * delivery would flood the trace list with `session_event` spans. * * The iii-browser-sdk replays both registered functions and triggers on diff --git a/console/web/src/lib/backend/types.ts b/console/web/src/lib/backend/types.ts index 62a1cfbf4..03f32df7b 100644 --- a/console/web/src/lib/backend/types.ts +++ b/console/web/src/lib/backend/types.ts @@ -1,5 +1,4 @@ import type { Mode, ModelId } from '@/types/chat' -import type { AgentMessage } from '@/types/iii-agent-event' /** * The streaming contract every ChatBackend honors. The order is: @@ -82,16 +81,6 @@ export interface ChatStreamOptions { * `active ?? draftId ?? newSessionId()` plumbing. */ sessionId?: string - /** - * Prior conversation turns to ship along with the new user prompt. - * Without this, `run::start` overwrites the orchestrator's flat - * message state with only the latest user message and the assistant - * loses all context from earlier user submissions. ChatView builds - * this from `conversation.messages` minus the just-appended user - * turn. Real backend prepends it to the payload's `messages` array; - * mock backend ignores. - */ - history?: AgentMessage[] } export type CompactResult = @@ -126,14 +115,13 @@ export interface ChatBackend { decision: 'allow' | 'deny', ): Promise /** - * Powers `/compact`. `history` is reconciled into session-tree first so a - * stale mirror doesn't yield a spurious 'empty'. `contextWindow` skips - * the server's `models::get` lookup when known. + * Powers `/compact`. Compacts the session-tree (the single source of + * truth) directly. `contextWindow` skips the server's `models::get` + * lookup when known. */ compactSession?( sessionId: string, model: ModelId, - history?: AgentMessage[], contextWindow?: number, ): Promise } diff --git a/console/web/src/lib/traces-live.test.ts b/console/web/src/lib/traces-live.test.ts deleted file mode 100644 index c98c77a0e..000000000 --- a/console/web/src/lib/traces-live.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -import type { QueryClient } from '@tanstack/react-query' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { IIIConnectionState, IiiClient } from '@/lib/iii-client' -import { - makeTracesChangedHandler, - startTracesSubscription, -} from './traces-live' - -function fakeQueryClient() { - const invalidateQueries = vi.fn() - return { - client: { invalidateQueries } as unknown as QueryClient, - invalidateQueries, - } -} - -function fakeClient() { - const triggers: Array<{ - type: string - function_id: string - config?: unknown - }> = [] - let spanHandler: ((p: unknown) => void) | null = null - let connListener: ((s: IIIConnectionState) => void) | null = null - const offSignal = vi.fn() - const offConn = vi.fn() - const triggerUnregister = vi.fn() - - const on = vi.fn((fn: string, handler: (p: unknown) => void) => { - if (fn === 'iii::console::traces_changed') spanHandler = handler - return offSignal - }) - const registerTrigger = vi.fn( - (input: { type: string; function_id: string; config?: unknown }) => { - triggers.push(input) - return triggerUnregister - }, - ) - const addConnectionStateListener = vi.fn( - (handler: (s: IIIConnectionState) => void) => { - connListener = handler - return offConn - }, - ) - - const client = { - browserId: 'console-test', - on, - registerTrigger, - addConnectionStateListener, - call: vi.fn(), - dispose: vi.fn(async () => {}), - } as unknown as IiiClient - - return { - client, - on, - registerTrigger, - triggers, - offSignal, - offConn, - triggerUnregister, - fireSpan: () => spanHandler?.(undefined), - fireConn: (s: IIIConnectionState) => connListener?.(s), - } -} - -function fakeDoc(initial: 'visible' | 'hidden' = 'visible') { - let visibilityState = initial - let handler: (() => void) | null = null - const addEventListener = vi.fn((type: string, h: () => void) => { - if (type === 'visibilitychange') handler = h - }) - const removeEventListener = vi.fn() - const doc = { - get visibilityState() { - return visibilityState - }, - addEventListener, - removeEventListener, - } - return { - doc: doc as unknown as Document, - addEventListener, - removeEventListener, - setVisibility: (s: 'visible' | 'hidden') => { - visibilityState = s - }, - fireVisibilityChange: () => handler?.(), - } -} - -describe('makeTracesChangedHandler', () => { - it('invalidates both trace query keys when not paused', () => { - const { client, invalidateQueries } = fakeQueryClient() - const handler = makeTracesChangedHandler(client, { current: false }) - - handler() - - expect(invalidateQueries).toHaveBeenCalledTimes(2) - expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['traces'] }) - expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: ['traceGroups'], - }) - }) - - it('does nothing while paused', () => { - const { client, invalidateQueries } = fakeQueryClient() - const handler = makeTracesChangedHandler(client, { current: true }) - - handler() - - expect(invalidateQueries).not.toHaveBeenCalled() - }) - - it('reads the pause flag live from the ref', () => { - const { client, invalidateQueries } = fakeQueryClient() - const ref = { current: true } - const handler = makeTracesChangedHandler(client, ref) - - handler() - expect(invalidateQueries).not.toHaveBeenCalled() - - ref.current = false - handler() - expect(invalidateQueries).toHaveBeenCalledTimes(2) - }) - - it('runs the onExtra callback when not paused (e.g. reload open trace detail)', () => { - const { client } = fakeQueryClient() - const onExtra = vi.fn() - const handler = makeTracesChangedHandler( - client, - { current: false }, - onExtra, - ) - - handler() - - expect(onExtra).toHaveBeenCalledTimes(1) - }) - - it('skips onExtra while paused', () => { - const { client } = fakeQueryClient() - const onExtra = vi.fn() - const handler = makeTracesChangedHandler(client, { current: true }, onExtra) - - handler() - - expect(onExtra).not.toHaveBeenCalled() - }) -}) - -describe('startTracesSubscription', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - afterEach(() => { - vi.useRealTimers() - }) - - it('registers the span handler and binds a trace trigger on start', () => { - const { client, on, triggers } = fakeClient() - startTracesSubscription(client, () => {}) - - expect(on).toHaveBeenCalledWith( - 'iii::console::traces_changed', - expect.any(Function), - ) - expect(triggers).toEqual([ - { - type: 'trace', - function_id: 'iii::console::traces_changed::console-test', - config: {}, - }, - ]) - }) - - it('coalesces a burst of per-span ticks into a single refetch', () => { - const { client, fireSpan } = fakeClient() - const onSignal = vi.fn() - startTracesSubscription(client, onSignal, { coalesceMs: 400 }) - - fireSpan() - fireSpan() - fireSpan() - expect(onSignal).not.toHaveBeenCalled() // still debouncing - - vi.advanceTimersByTime(400) - expect(onSignal).toHaveBeenCalledTimes(1) - }) - - it('fans out again on a span that arrives after the window flushed', () => { - const { client, fireSpan } = fakeClient() - const onSignal = vi.fn() - startTracesSubscription(client, onSignal, { coalesceMs: 400 }) - - fireSpan() - vi.advanceTimersByTime(400) - fireSpan() - vi.advanceTimersByTime(400) - - expect(onSignal).toHaveBeenCalledTimes(2) - }) - - it('re-syncs (refetches) on reconnect without re-registering the trigger', () => { - const { client, registerTrigger, fireConn } = fakeClient() - const onSignal = vi.fn() - startTracesSubscription(client, onSignal) - - fireConn('connected') - - // SDK replays the registered trigger itself — we must not double-register. - expect(registerTrigger).toHaveBeenCalledTimes(1) - expect(onSignal).toHaveBeenCalledTimes(1) - }) - - it('does not re-sync on non-connected transitions', () => { - const { client, fireConn } = fakeClient() - const onSignal = vi.fn() - startTracesSubscription(client, onSignal) - - fireConn('reconnecting') - fireConn('disconnected') - - expect(onSignal).not.toHaveBeenCalled() - }) - - it('cleans up the handler, listener, trigger, and pending timer on stop', () => { - const { client, offSignal, offConn, triggerUnregister, fireSpan } = - fakeClient() - const onSignal = vi.fn() - const stop = startTracesSubscription(client, onSignal, { coalesceMs: 400 }) - - fireSpan() // arm the debounce timer - stop() - vi.advanceTimersByTime(400) - - expect(offSignal).toHaveBeenCalledTimes(1) - expect(offConn).toHaveBeenCalledTimes(1) - expect(triggerUnregister).toHaveBeenCalledTimes(1) - expect(onSignal).not.toHaveBeenCalled() // timer was cleared - }) - - it('re-syncs when the tab becomes visible again', () => { - const { client } = fakeClient() - const doc = fakeDoc('visible') - const onSignal = vi.fn() - startTracesSubscription(client, onSignal, { doc: doc.doc }) - - doc.fireVisibilityChange() - - expect(onSignal).toHaveBeenCalledTimes(1) - }) - - it('does not re-sync on a visibilitychange that leaves the tab hidden', () => { - const { client } = fakeClient() - const doc = fakeDoc('hidden') - const onSignal = vi.fn() - startTracesSubscription(client, onSignal, { doc: doc.doc }) - - doc.fireVisibilityChange() - - expect(onSignal).not.toHaveBeenCalled() - }) - - it('removes the visibilitychange listener on stop', () => { - const { client } = fakeClient() - const doc = fakeDoc('visible') - const stop = startTracesSubscription(client, () => {}, { doc: doc.doc }) - - stop() - - expect(doc.removeEventListener).toHaveBeenCalledWith( - 'visibilitychange', - expect.any(Function), - ) - }) -}) diff --git a/console/web/src/lib/traces-live.ts b/console/web/src/lib/traces-live.ts deleted file mode 100644 index 93ade66c5..000000000 --- a/console/web/src/lib/traces-live.ts +++ /dev/null @@ -1,221 +0,0 @@ -/** - * Live-refresh wiring for the devtools Traces view, driven by the engine - * `trace` trigger (the iii observability worker). - * - * Every span that lands in the engine's in-memory trace store fires the - * `trace` trigger — the same client-agnostic mechanism as the `log` trigger, - * available to any iii client. This module registers a browser-local handler, - * binds a `trace` trigger to it, and — after a short trailing debounce — - * invalidates the Traces React Query caches so the page refetches on real span - * activity instead of polling `engine::traces::*` on a 3s timer. - * - * The engine fires once PER SPAN (mirroring the `log` trigger), so a busy turn - * produces a burst; the debounce collapses that burst into a single refetch. - * - * The imperative core (`startTracesSubscription`) and the pure invalidation - * handler (`makeTracesChangedHandler`) are framework-free so they unit-test - * without a DOM; `useTracesLiveRefresh` is the thin React wrapper. - */ - -import type { QueryClient } from '@tanstack/react-query' -import { useQueryClient } from '@tanstack/react-query' -import { useEffect, useRef } from 'react' -import { getIiiClient, type IiiClient } from '@/lib/iii-client' - -/** - * Browser-local function the engine `trace` trigger invokes. The `iii::` - * prefix marks it engine-internal (`is_iii_builtin_function_id`), so the spans - * produced by DELIVERING this trigger are tagged `iii.function.kind=internal` - * — hidden from the Traces view by the default `include_internal:false` query, - * and skipped by the engine's trigger loop-break. Without this, the trigger's - * own delivery calls flood the trace list with `traces_changed` spans. - */ -const TRACES_CHANGED_FN = 'iii::console::traces_changed' -/** Engine trigger type registered by the observability worker. */ -const TRACE_TRIGGER_TYPE = 'trace' -/** Trailing-edge debounce: collapse a burst of per-span ticks into one refetch. */ -const DEFAULT_COALESCE_MS = 400 - -/** Dev-only trace-stream diagnostics. Silent in production builds. */ -function dlog(msg: string, data?: unknown): void { - if (import.meta.env?.DEV) { - console.debug(`[traces-live] ${msg}`, data ?? '') - } -} - -/** - * Build the signal handler that refetches the Traces queries. Pause is read - * live from a ref so toggling pause never re-creates the subscription. We skip - * refetching while the tab is hidden to avoid background work; the - * `visibilitychange` re-sync wired up in `startTracesSubscription` catches up - * on return. (The app-wide QueryClient disables `refetchOnWindowFocus`, so the - * visibility listener — not focus — is the recovery path.) - */ -export function makeTracesChangedHandler( - qc: QueryClient, - isPausedRef: { current: boolean }, - onExtra?: () => void, -): () => void { - return () => { - if (isPausedRef.current) { - dlog('signal ignored (paused)') - return - } - if ( - typeof document !== 'undefined' && - document.visibilityState === 'hidden' - ) { - dlog('signal ignored (tab hidden)') - return - } - dlog('signal received → invalidating traces queries') - qc.invalidateQueries({ queryKey: ['traces'] }) - qc.invalidateQueries({ queryKey: ['traceGroups'] }) - // Extra refresh hook — e.g. silently reload the open trace's detail tree, - // which isn't a React Query cache and so isn't covered by the invalidations - // above. Shares the pause/hidden gating. - onExtra?.() - } -} - -/** - * Register a browser-local handler, bind an engine `trace` trigger to it, and - * re-sync on reconnect / tab-visible. Returns a cleanup that clears the - * debounce timer, unregisters the handler + connection listener, and - * unregisters the trigger. - * - * The iii-browser-sdk replays BOTH registered functions and registered - * triggers on reconnect (see `onSocketOpen`), so we do NOT manually - * re-register on `'connected'` — that would create a duplicate trigger. We - * only re-sync via `onSignal()` so a gap (or a cold initial fetch that raced - * the WS connect) recovers without a polling fallback. - * - * Per-span ticks are coalesced by a trailing debounce; reconnect/visibility - * re-syncs call `onSignal` directly (immediate). `onSignal` itself honors the - * pause/hidden gates, so direct re-syncs stay correct. - */ -export function startTracesSubscription( - client: Pick< - IiiClient, - 'browserId' | 'on' | 'registerTrigger' | 'addConnectionStateListener' - >, - onSignal: () => void, - opts: { - coalesceMs?: number - doc?: Pick< - Document, - 'addEventListener' | 'removeEventListener' | 'visibilityState' - > - } = {}, -): () => void { - const coalesceMs = opts.coalesceMs ?? DEFAULT_COALESCE_MS - const doc = - opts.doc ?? (typeof document !== 'undefined' ? document : undefined) - - // Trailing-edge debounce: the engine fires once per span, so collapse a - // burst into a single refetch. - let timer: ReturnType | null = null - const tick = () => { - if (timer) clearTimeout(timer) - timer = setTimeout(() => { - timer = null - onSignal() - }, coalesceMs) - } - - // The engine `trace` trigger calls this browser-local function per span. - const off = client.on(TRACES_CHANGED_FN, tick) - // `on()` registers under `::`; the trigger must target that id. - const functionId = `${TRACES_CHANGED_FN}::${client.browserId}` - - const offTrigger = client.registerTrigger({ - type: TRACE_TRIGGER_TYPE, - function_id: functionId, - config: {}, - }) - dlog('trace trigger registered', { functionId }) - - const offConn = client.addConnectionStateListener((state) => { - dlog('connection state', state) - if (state !== 'connected') return - // Handler + trigger are auto-replayed by the SDK; just re-sync to recover - // spans that landed while the socket was down (and cold-start races). - onSignal() - }) - - let offVisibility: (() => void) | undefined - if (doc) { - const onVisible = () => { - if (doc.visibilityState !== 'visible') return - dlog('tab visible → re-syncing traces queries') - onSignal() - } - doc.addEventListener('visibilitychange', onVisible) - offVisibility = () => doc.removeEventListener('visibilitychange', onVisible) - } - - return () => { - if (timer) { - clearTimeout(timer) - timer = null - } - off() - offConn() - offVisibility?.() - try { - offTrigger() - } catch { - // SDK already disposed; nothing to do. - } - } -} - -/** - * Subscribe the Traces page to live span activity via the engine `trace` - * trigger for the lifetime of the component. The shared `getIiiClient()` - * singleton is NOT disposed on unmount (it's app-wide), so the explicit - * cleanup is required. - * - * `onSignal` runs on each (non-paused, visible) signal alongside the list - * refetch — the page uses it to silently reload the open trace's detail tree, - * which is fetched imperatively (not a React Query cache) and so isn't covered - * by the query invalidations. Read live from a ref so it never re-subscribes. - */ -export function useTracesLiveRefresh({ - isPaused, - onSignal, -}: { - isPaused: boolean - onSignal?: () => void -}): void { - const qc = useQueryClient() - const isPausedRef = useRef(isPaused) - const onSignalRef = useRef(onSignal) - useEffect(() => { - isPausedRef.current = isPaused - }, [isPaused]) - useEffect(() => { - onSignalRef.current = onSignal - }, [onSignal]) - - useEffect(() => { - let stop: (() => void) | undefined - let disposed = false - void (async () => { - const client = await getIiiClient() - if (disposed) return - stop = startTracesSubscription( - client, - makeTracesChangedHandler(qc, isPausedRef, () => - onSignalRef.current?.(), - ), - ) - })() - return () => { - disposed = true - stop?.() - } - // Pause + onSignal are read via refs, so the subscription is set up once - // per mount. - }, [qc]) -} diff --git a/console/web/src/lib/traces-stream.test.ts b/console/web/src/lib/traces-stream.test.ts new file mode 100644 index 000000000..ff070e862 --- /dev/null +++ b/console/web/src/lib/traces-stream.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it, vi } from 'vitest' +import type { IiiClient } from '@/lib/iii-client' +import type { StoredSpan } from '@/pages/Traces/api/traces' +import { + extractStreamSpans, + isAppendableTraceList, + mergeTraceListSpans, + startTraceListStream, + startTraceSpansStream, +} from './traces-stream' + +const NS = 1_000_000 + +function span(overrides: Partial = {}): StoredSpan { + return { + trace_id: 't-1', + span_id: 's-1', + name: 'call x', + start_time_unix_nano: 1_700_000_000_000 * NS, + end_time_unix_nano: 1_700_000_000_010 * NS, + status: 'OK', + attributes: [], + events: [], + links: [], + service_name: 'svc', + ...overrides, + } +} + +/** A `stream::send` Event frame, as the engine serializes StreamWrapperMessage. */ +function sendFrame(streamName: string, groupId: string, spans: StoredSpan[]) { + return { + type: 'stream', + timestamp: 0, + streamName, + groupId, + id: null, + event: { type: 'event', event: { type: 'spans', data: { spans } } }, + } +} + +function fakeClient() { + const triggers: Array<{ + type: string + function_id: string + config?: unknown + }> = [] + const handlers = new Map void>() + const offHandlers = new Map>() + const triggerUnregister = vi.fn() + + const on = vi.fn((fn: string, handler: (p: unknown) => void) => { + handlers.set(fn, handler) + const off = vi.fn() + offHandlers.set(fn, off) + return off + }) + const registerTrigger = vi.fn( + (input: { type: string; function_id: string; config?: unknown }) => { + triggers.push(input) + return triggerUnregister + }, + ) + + const client = { + browserId: 'console-test', + on, + registerTrigger, + addConnectionStateListener: vi.fn(() => vi.fn()), + call: vi.fn(), + dispose: vi.fn(async () => {}), + } as unknown as IiiClient + + return { + client, + on, + registerTrigger, + triggers, + triggerUnregister, + offHandlers, + fire: (fn: string, frame: unknown) => handlers.get(fn)?.(frame), + } +} + +describe('extractStreamSpans', () => { + it('reads the spans out of a `send` Event frame (nested event.event.data)', () => { + const spans = [span({ span_id: 'a' }), span({ span_id: 'b' })] + const out = extractStreamSpans( + sendFrame('iii:devtools:trace-rows', 'all', spans), + ) + expect(out.map((s) => s.span_id)).toEqual(['a', 'b']) + }) + + it('falls back to the flat Create/Update shape ({ event: { data } })', () => { + const spans = [span({ span_id: 'c' })] + const frame = { + streamName: 's', + groupId: 'g', + event: { type: 'create', data: { spans } }, + } + expect(extractStreamSpans(frame).map((s) => s.span_id)).toEqual(['c']) + }) + + it('returns [] for malformed / empty / missing-spans frames', () => { + expect(extractStreamSpans(null)).toEqual([]) + expect(extractStreamSpans({})).toEqual([]) + expect(extractStreamSpans({ event: {} })).toEqual([]) + expect( + extractStreamSpans({ + event: { type: 'event', event: { type: 'spans', data: {} } }, + }), + ).toEqual([]) + expect( + extractStreamSpans({ + event: { + type: 'event', + event: { type: 'spans', data: { spans: 'nope' } }, + }, + }), + ).toEqual([]) + }) +}) + +describe('mergeTraceListSpans', () => { + it('seeds from an empty list', () => { + const out = mergeTraceListSpans([], [span({ trace_id: 'a' })], 500) + expect(out).toHaveLength(1) + expect(out[0].trace_id).toBe('a') + }) + + it('dedupes by trace_id (one row per trace)', () => { + const out = mergeTraceListSpans( + [span({ trace_id: 'a', span_id: 'old', start_time_unix_nano: 1 * NS })], + [span({ trace_id: 'a', span_id: 'new', start_time_unix_nano: 2 * NS })], + 500, + ) + expect(out).toHaveLength(1) + expect(out[0].span_id).toBe('new') + }) + + it('prefers a root span over a non-root for the same trace', () => { + const out = mergeTraceListSpans( + [span({ trace_id: 'a', span_id: 'child', parent_span_id: 'p' })], + [span({ trace_id: 'a', span_id: 'root' })], + 500, + ) + expect(out[0].span_id).toBe('root') + }) + + it('sorts newest-first and caps the list', () => { + const incoming = [ + span({ trace_id: 'a', start_time_unix_nano: 1 }), + span({ trace_id: 'b', start_time_unix_nano: 3 }), + span({ trace_id: 'c', start_time_unix_nano: 2 }), + ] + const out = mergeTraceListSpans([], incoming, 2) + expect(out.map((s) => s.trace_id)).toEqual(['b', 'c']) + }) + + it('does not mutate its inputs', () => { + const existing = [span({ trace_id: 'a' })] + const incoming = [span({ trace_id: 'b' })] + mergeTraceListSpans(existing, incoming, 500) + expect(existing).toHaveLength(1) + expect(incoming).toHaveLength(1) + }) +}) + +describe('isAppendableTraceList', () => { + it('appends the default unfiltered view (sort defaults are present, not counted)', () => { + // The filter builder always emits the default sort, so an "empty" view + // still carries sort_by/sort_order — these must NOT block append. + expect( + isAppendableTraceList({ sort_by: 'start_time', sort_order: 'desc' }, ''), + ).toBe(true) + expect(isAppendableTraceList({}, '')).toBe(true) + }) + + it('refetches (no append) when a search is active', () => { + expect(isAppendableTraceList({}, 'harness::trigger')).toBe(false) + }) + + it('refetches when any content filter is set', () => { + expect(isAppendableTraceList({ service_name: 'svc' }, '')).toBe(false) + expect(isAppendableTraceList({ name: 'GET /x' }, '')).toBe(false) + expect(isAppendableTraceList({ status: 'error' }, '')).toBe(false) + expect(isAppendableTraceList({ min_duration_ms: 5 }, '')).toBe(false) + expect(isAppendableTraceList({ start_time: 1 }, '')).toBe(false) + expect(isAppendableTraceList({ search_all_spans: true }, '')).toBe(false) + expect(isAppendableTraceList({ attributes: [['k', 'v']] }, '')).toBe(false) + }) + + it('refetches when sorted by anything other than newest-first', () => { + expect( + isAppendableTraceList({ sort_by: 'duration', sort_order: 'desc' }, ''), + ).toBe(false) + expect( + isAppendableTraceList({ sort_by: 'start_time', sort_order: 'asc' }, ''), + ).toBe(false) + }) +}) + +describe('startTraceListStream', () => { + it('registers the rows handler and a stream trigger on the global group', () => { + const { client, on, triggers } = fakeClient() + startTraceListStream(client, () => {}) + + expect(on).toHaveBeenCalledWith( + 'iii::console::trace_rows', + expect.any(Function), + ) + expect(triggers).toEqual([ + { + type: 'stream', + function_id: 'iii::console::trace_rows::console-test', + config: { stream_name: 'iii:devtools:trace-rows', group_id: 'all' }, + }, + ]) + }) + + it('delivers extracted spans to onSpans (and ignores empty frames)', () => { + const { client, fire } = fakeClient() + const onSpans = vi.fn() + startTraceListStream(client, onSpans) + + fire( + 'iii::console::trace_rows', + sendFrame('iii:devtools:trace-rows', 'all', []), + ) + expect(onSpans).not.toHaveBeenCalled() + + const spans = [span({ trace_id: 'z' })] + fire( + 'iii::console::trace_rows', + sendFrame('iii:devtools:trace-rows', 'all', spans), + ) + expect(onSpans).toHaveBeenCalledTimes(1) + expect(onSpans.mock.calls[0][0].map((s: StoredSpan) => s.trace_id)).toEqual( + ['z'], + ) + }) + + it('unregisters the handler and trigger on cleanup', () => { + const { client, offHandlers, triggerUnregister } = fakeClient() + const stop = startTraceListStream(client, () => {}) + stop() + expect(offHandlers.get('iii::console::trace_rows')).toHaveBeenCalledTimes(1) + expect(triggerUnregister).toHaveBeenCalledTimes(1) + }) +}) + +describe('startTraceSpansStream', () => { + it('registers the spans handler and a trigger scoped to the trace group', () => { + const { client, on, triggers } = fakeClient() + startTraceSpansStream(client, 'trace-42', () => {}) + + expect(on).toHaveBeenCalledWith( + 'iii::console::trace_spans', + expect.any(Function), + ) + expect(triggers).toEqual([ + { + type: 'stream', + function_id: 'iii::console::trace_spans::console-test', + config: { + stream_name: 'iii:devtools:trace-spans', + group_id: 'trace-42', + }, + }, + ]) + }) + + it('filters delivered spans to the subscribed trace (defense-in-depth)', () => { + const { client, fire } = fakeClient() + const onSpans = vi.fn() + startTraceSpansStream(client, 'trace-42', onSpans) + + const spans = [ + span({ trace_id: 'trace-42', span_id: 'keep' }), + span({ trace_id: 'other', span_id: 'drop' }), + ] + fire( + 'iii::console::trace_spans', + sendFrame('iii:devtools:trace-spans', 'trace-42', spans), + ) + + expect(onSpans).toHaveBeenCalledTimes(1) + expect(onSpans.mock.calls[0][0].map((s: StoredSpan) => s.span_id)).toEqual([ + 'keep', + ]) + }) + + it('unregisters the handler and trigger on cleanup', () => { + const { client, offHandlers, triggerUnregister } = fakeClient() + const stop = startTraceSpansStream(client, 'trace-42', () => {}) + stop() + expect(offHandlers.get('iii::console::trace_spans')).toHaveBeenCalledTimes( + 1, + ) + expect(triggerUnregister).toHaveBeenCalledTimes(1) + }) +}) diff --git a/console/web/src/lib/traces-stream.ts b/console/web/src/lib/traces-stream.ts new file mode 100644 index 000000000..1de99dd1a --- /dev/null +++ b/console/web/src/lib/traces-stream.ts @@ -0,0 +1,210 @@ +/** + * Live span streams for the devtools Traces view — the real-time APPEND feed + * that replaces the old `traces-live` (trace-trigger → debounce → refetch) + * model. The engine observability worker pushes span data onto two ephemeral + * iii streams via `stream::send`; the browser subscribes with scoped + * `type:'stream'` triggers and merges pushed spans into state, the same + * "engine pushes data, client appends" pattern as `session-events-live.ts`. + * + * - `iii:devtools:trace-rows` / group `all` → root rows for the LIST + * (one global firehose; every list view joins the single `all` group). + * - `iii:devtools:trace-spans` / group `` → every span of one trace + * for the DETAIL waterfall (joined only for the selected trace). + * + * The in-memory trace store stays the source of truth: each surface does ONE + * seed read, then appends from its stream. A dropped broadcast frame is not + * fatal — a single re-seed on reconnect self-heals (the caller wires that). + * + * Handlers are named with the `iii::` prefix (`is_iii_builtin_function_id`), so + * the spans produced by DELIVERING a frame are `iii.function.kind=internal` and + * are excluded by the engine's stream loop-break (`is_trace_stream_delivery`), + * so deliveries never re-enter the trace feed. + */ + +import type { IiiClient } from '@/lib/iii-client' +import type { StoredSpan, TracesFilterParams } from '@/pages/Traces/api/traces' + +/** iii:: prefix → engine-internal → delivery spans hidden + stream loop-break. */ +const TRACE_ROWS_FN = 'iii::console::trace_rows' +const TRACE_SPANS_FN = 'iii::console::trace_spans' +/** Stream names the observability worker pushes onto (mirror the engine consts). */ +const TRACE_ROWS_STREAM = 'iii:devtools:trace-rows' +const TRACE_SPANS_STREAM = 'iii:devtools:trace-spans' +/** The single group every list subscriber joins (the list is a global firehose). */ +const TRACE_ROWS_GROUP = 'all' + +/** Dev-only stream diagnostics. Silent in production builds. */ +function dlog(msg: string, data?: unknown): void { + if (import.meta.env?.DEV) { + console.debug(`[traces-stream] ${msg}`, data ?? '') + } +} + +/** + * Pull the `{ spans: [...] }` payload out of a raw stream frame. + * + * A `stream::send` Event frame serializes as + * `{ streamName, groupId, event: { type: 'event', event: { type, data } } }` + * — the payload is nested one level deeper than the `Create`/`Update` shape + * (`{ event: { type, data } }`) that `set`/`update` produce. We read the Event + * shape first and fall back to the flat-`data` shape so the extractor is + * resilient to either producer. + */ +export function extractStreamSpans(frame: unknown): StoredSpan[] { + if (!frame || typeof frame !== 'object') return [] + const obj = frame as Record + + const outer = + obj.event && typeof obj.event === 'object' + ? (obj.event as Record) + : null + if (!outer) return [] + + // Event variant: outer = { type: 'event', event: { type, data } }. + // Create/Update variant: outer = { type: 'create', data }. + const inner = + outer.event && typeof outer.event === 'object' + ? (outer.event as Record) + : outer + const data = 'data' in inner ? inner.data : null + if (!data || typeof data !== 'object') return [] + + const spans = (data as Record).spans + return Array.isArray(spans) ? (spans as StoredSpan[]) : [] +} + +/** + * Merge a batch of streamed root spans into the existing list of trace rows, + * keyed by `trace_id` (one row per trace). The newest representative wins: + * a root span is preferred over a non-root, and among equals the later start + * time wins. The result is sorted newest-first and capped — a live session + * would otherwise grow the list without bound. + * + * Pure and immutable: returns a fresh array; never mutates the inputs. + */ +export function mergeTraceListSpans( + existing: ReadonlyArray, + incoming: ReadonlyArray, + cap: number, +): StoredSpan[] { + const byTrace = new Map() + for (const span of existing) byTrace.set(span.trace_id, span) + for (const span of incoming) { + const current = byTrace.get(span.trace_id) + if (!current) { + byTrace.set(span.trace_id, span) + continue + } + const spanIsRoot = !span.parent_span_id + const currentIsRoot = !current.parent_span_id + if (spanIsRoot && !currentIsRoot) { + byTrace.set(span.trace_id, span) + } else if ( + spanIsRoot === currentIsRoot && + span.start_time_unix_nano >= current.start_time_unix_nano + ) { + byTrace.set(span.trace_id, span) + } + } + const spans = [...byTrace.values()].sort( + (a, b) => b.start_time_unix_nano - a.start_time_unix_nano, + ) + return spans.length > cap ? spans.slice(0, cap) : spans +} + +/** + * Whether the list can be live-APPENDED from the stream, vs. needing a refetch. + * + * Append is only correct for the default "newest traces, unfiltered" view: + * - No CONTENT filter (service/name/status/duration/time/attributes/search) — + * a streamed row might not match it, so a filtered view must refetch. + * - The default `start_time` / `desc` sort — append keeps the list newest-first, + * which only matches that ordering (a duration/asc sort + limit would be + * violated by prepending new rows). `sort_by`/`sort_order` are always present + * (the filter builder emits the defaults), so they're compared, not counted. + */ +export function isAppendableTraceList( + filterParams: TracesFilterParams, + search: string, +): boolean { + if (search) return false + const fp = filterParams + if (fp.service_name || fp.name || fp.status) return false + if (fp.attributes && fp.attributes.length > 0) return false + if (fp.min_duration_ms != null || fp.max_duration_ms != null) return false + if (fp.start_time != null || fp.end_time != null) return false + if (fp.search_all_spans) return false + if (fp.sort_by && fp.sort_by !== 'start_time') return false + if (fp.sort_order && fp.sort_order !== 'desc') return false + return true +} + +/** + * Subscribe the LIST to the global `trace-rows` stream. `onSpans` receives each + * pushed batch of root spans (already extracted). Returns a cleanup that + * unregisters the handler and the trigger. + */ +export function startTraceListStream( + client: Pick, + onSpans: (spans: StoredSpan[]) => void, +): () => void { + const off = client.on(TRACE_ROWS_FN, (frame: unknown) => { + const spans = extractStreamSpans(frame) + if (spans.length > 0) onSpans(spans) + }) + + // `on()` registers under `::`; the trigger must target that id. + const functionId = `${TRACE_ROWS_FN}::${client.browserId}` + const offTrigger = client.registerTrigger({ + type: 'stream', + function_id: functionId, + config: { stream_name: TRACE_ROWS_STREAM, group_id: TRACE_ROWS_GROUP }, + }) + dlog('trace-rows stream subscribed', { functionId }) + + return () => { + off() + try { + offTrigger() + } catch { + // SDK already disposed; nothing to do. + } + } +} + +/** + * Subscribe the DETAIL to one trace's `trace-spans` stream (scoped by + * `group_id = traceId`, so only this trace's span activity arrives). `onSpans` + * receives each pushed batch of spans for the trace. Returns a cleanup that + * unregisters the handler and the trigger — call it before subscribing to a + * different trace. + */ +export function startTraceSpansStream( + client: Pick, + traceId: string, + onSpans: (spans: StoredSpan[]) => void, +): () => void { + const off = client.on(TRACE_SPANS_FN, (frame: unknown) => { + const spans = extractStreamSpans(frame) + // The trigger is group-scoped, but guard against mis-delivery defensively. + const scoped = spans.filter((s) => s.trace_id === traceId) + if (scoped.length > 0) onSpans(scoped) + }) + + const functionId = `${TRACE_SPANS_FN}::${client.browserId}` + const offTrigger = client.registerTrigger({ + type: 'stream', + function_id: functionId, + config: { stream_name: TRACE_SPANS_STREAM, group_id: traceId }, + }) + dlog('trace-spans stream subscribed', { functionId, traceId }) + + return () => { + off() + try { + offTrigger() + } catch { + // SDK already disposed; nothing to do. + } + } +} diff --git a/console/web/src/pages/Traces/components/FlameGraph.tsx b/console/web/src/pages/Traces/components/FlameGraph.tsx index e393b7e68..55968a1e4 100644 --- a/console/web/src/pages/Traces/components/FlameGraph.tsx +++ b/console/web/src/pages/Traces/components/FlameGraph.tsx @@ -223,10 +223,11 @@ export function FlameGraph({ const [viewState, dispatch] = useReducer(viewReducer, initialViewState) const { hoveredNode, tooltipPos, zoomLevel, panOffset } = viewState - // Same defaults as the waterfall view: critical-path-only is on, engine - // routing is hidden. The engine-routing toggle is persisted via the - // shared hook so the preference survives view swaps. - const [showCriticalPath, setShowCriticalPath] = useState(true) + // Same defaults as the waterfall view: critical-path-only is OFF (show the + // full tree first; opt into the hot-path filter), engine routing is hidden. + // The engine-routing toggle is persisted via the shared hook so the + // preference survives view swaps. + const [showCriticalPath, setShowCriticalPath] = useState(false) const [showEngineRouting, setShowEngineRouting] = useShowEngineRouting() const ROW_HEIGHT = 26 diff --git a/console/web/src/pages/Traces/components/WaterfallChart.tsx b/console/web/src/pages/Traces/components/WaterfallChart.tsx index d82970cf4..cb072b1e1 100644 --- a/console/web/src/pages/Traces/components/WaterfallChart.tsx +++ b/console/web/src/pages/Traces/components/WaterfallChart.tsx @@ -247,7 +247,7 @@ type DisplayAction = const initialDisplayState: DisplayState = { expandedIds: new Set(), - showCriticalPath: true, + showCriticalPath: false, } // Note: `hoveredSpanId` used to live here, but mouse-sweep over the diff --git a/console/web/src/pages/Traces/hooks/useTraceData.ts b/console/web/src/pages/Traces/hooks/useTraceData.ts index 536e91f51..e28ae302f 100644 --- a/console/web/src/pages/Traces/hooks/useTraceData.ts +++ b/console/web/src/pages/Traces/hooks/useTraceData.ts @@ -1,6 +1,16 @@ -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useRef, useState } from 'react' -import { fetchTraces, type TracesFilterParams } from '../api/traces' +import { getIiiClient } from '@/lib/iii-client' +import { + isAppendableTraceList, + mergeTraceListSpans, + startTraceListStream, +} from '@/lib/traces-stream' +import { + fetchTraces, + type TracesFilterParams, + type TracesResponse, +} from '../api/traces' import { dedupeToTraceRoots, fingerprintTraceList, @@ -26,6 +36,7 @@ export interface UseTraceDataOptions { filterParams: TracesFilterParams showSystem: boolean debouncedSearch: string + isPaused: boolean } export interface UseTraceDataReturn { @@ -43,6 +54,7 @@ export function useTraceData({ filterParams, showSystem, debouncedSearch, + isPaused, }: UseTraceDataOptions): UseTraceDataReturn { const [traceGroups, setTraceListItems] = useState([]) const [hasOtelConfigured, setHasOtelConfigured] = useState(false) @@ -70,10 +82,11 @@ export function useTraceData({ limit: DEFAULT_TRACE_LIMIT, include_internal: showSystem, }), - // Live updates arrive via `useTracesLiveRefresh` (the engine `trace` - // trigger), which invalidates the ['traces'] key — no polling interval. - // Initial mount fetch + manual Refresh + signal-driven invalidation cover - // refresh; reconnect/tab-visible re-sync handles cold-start races. + // This query is the one-time SEED read. Live updates arrive by APPEND over + // the engine `iii:devtools:trace-rows` stream (see the stream effect + // below), which merges new rows straight into this cache — no polling + // interval. Reconnect / tab-visible re-seed once to self-heal dropped + // frames; manual Refresh re-reads on demand. refetchInterval: false, staleTime: 1000, }) @@ -118,6 +131,96 @@ export function useTraceData({ } }, [tracesData]) + // ── Live append over the engine `trace-rows` stream ────────────────────── + // The engine pushes new root rows as spans close. The UNFILTERED list merges + // them directly into the seed query's cache (pure append, no refetch); a + // filtered/searched list — and the group-by aggregate, which can't be + // appended — refetches on activity instead (the engine already coalesces to + // ~one push per window, so this is not a poll). Pause / tab-hidden freeze it; + // reconnect and tab-visible re-seed once to recover anything dropped while + // away. Subscribes once for the hook's lifetime (params are read via refs). + const qc = useQueryClient() + const mergeKeyRef = useRef<{ key: unknown[]; unfiltered: boolean }>({ + key: [], + unfiltered: false, + }) + mergeKeyRef.current = { + key: ['traces', filterParams, showSystem, debouncedSearch], + unfiltered: isAppendableTraceList(filterParams, debouncedSearch), + } + const isPausedRef = useRef(isPaused) + useEffect(() => { + isPausedRef.current = isPaused + }, [isPaused]) + + useEffect(() => { + let stop: (() => void) | undefined + let disposed = false + + const isHidden = () => + typeof document !== 'undefined' && document.visibilityState === 'hidden' + const reseed = () => { + qc.invalidateQueries({ queryKey: ['traces'] }) + qc.invalidateQueries({ queryKey: ['traceGroups'] }) + } + + void (async () => { + const client = await getIiiClient() + if (disposed) return + + const offStream = startTraceListStream(client, (spans) => { + if (isPausedRef.current || isHidden()) return + const { key, unfiltered } = mergeKeyRef.current + if (unfiltered) { + qc.setQueryData(key, (old) => { + const merged = mergeTraceListSpans( + old?.spans ?? [], + spans, + DEFAULT_TRACE_LIMIT, + ) + return { + spans: merged, + total: merged.length, + offset: 0, + limit: DEFAULT_TRACE_LIMIT, + } + }) + } else { + qc.invalidateQueries({ queryKey: ['traces'] }) + } + // The group-by aggregate can't be appended; refetch it on activity. + qc.invalidateQueries({ queryKey: ['traceGroups'] }) + }) + + const offConn = client.addConnectionStateListener((state) => { + if (state === 'connected' && !isPausedRef.current) reseed() + }) + + let offVisibility: (() => void) | undefined + if (typeof document !== 'undefined') { + const onVisible = () => { + if (document.visibilityState === 'visible' && !isPausedRef.current) { + reseed() + } + } + document.addEventListener('visibilitychange', onVisible) + offVisibility = () => + document.removeEventListener('visibilitychange', onVisible) + } + + stop = () => { + offStream() + offConn() + offVisibility?.() + } + })() + + return () => { + disposed = true + stop?.() + } + }, [qc]) + const flushPendingTraces = () => { if (pendingTracesRef.current) { setTraceListItems(pendingTracesRef.current) diff --git a/console/web/src/pages/Traces/hooks/useTraceGroups.ts b/console/web/src/pages/Traces/hooks/useTraceGroups.ts index 2e513c9ac..94ff6a370 100644 --- a/console/web/src/pages/Traces/hooks/useTraceGroups.ts +++ b/console/web/src/pages/Traces/hooks/useTraceGroups.ts @@ -56,8 +56,10 @@ export function useTraceGroups({ limit: DEFAULT_GROUP_LIMIT, include_internal: includeInternal, }), - // Live updates arrive via `useTracesLiveRefresh` (the engine `trace` - // trigger), which invalidates the ['traceGroups'] key — no polling. + // Live updates: `useTraceData`'s `trace-rows` stream effect invalidates + // the ['traceGroups'] key on span activity (the aggregate can't be + // appended like the flat list), so the group view refetches reactively — + // no polling interval. refetchInterval: false, staleTime: 1000, retry: (failureCount, err) => { diff --git a/console/web/src/pages/Traces/index.tsx b/console/web/src/pages/Traces/index.tsx index 641f37017..7e4b619dc 100644 --- a/console/web/src/pages/Traces/index.tsx +++ b/console/web/src/pages/Traces/index.tsx @@ -16,9 +16,10 @@ import { ErrorBoundary } from '@/components/ui/ErrorBoundary' import { Pagination } from '@/components/ui/Pagination' import { Skeleton } from '@/components/ui/Skeleton' import { StatusPanel } from '@/components/ui/StatusPanel' -import { useTracesLiveRefresh } from '@/lib/traces-live' +import { getIiiClient } from '@/lib/iii-client' +import { startTraceSpansStream } from '@/lib/traces-stream' import { cn } from '@/lib/utils' -import { fetchTraceTree, type TraceGroup } from './api/traces' +import { fetchTraces, type StoredSpan, type TraceGroup } from './api/traces' import { FlameGraph } from './components/FlameGraph' import { FlowView } from './components/FlowView' import { ServiceBreakdown } from './components/ServiceBreakdown' @@ -35,7 +36,7 @@ import { useResizablePanels } from './hooks/useResizablePanels' import { useTraceData } from './hooks/useTraceData' import { useTraceFilters } from './hooks/useTraceFilters' import { - treeToWaterfallData, + toWaterfallData, type VisualizationSpan, type WaterfallData, } from './lib/traceTransform' @@ -91,6 +92,7 @@ export function Traces() { filterParams, showSystem, debouncedSearch, + isPaused, }) const totalPages = Math.max( @@ -137,10 +139,26 @@ export function Traces() { containerRef, }) - // `silent` reload (used by the live-refresh signal) updates the waterfall in - // place without the loading spinner / blank-out / error states, so the open - // trace's detail streams in new spans without flicker. A transient empty or - // failed read is ignored, keeping the current view rather than clearing it. + // Live detail: the selected trace's spans are seeded once (one flat read), + // then APPENDED from the engine `trace-spans` stream and rebuilt into the + // waterfall via `toWaterfallData`. Accumulated by `span_id` so re-delivered + // spans dedupe. Frozen while paused. + const detailSpansRef = useRef>(new Map()) + const isPausedRef = useRef(isPaused) + useEffect(() => { + isPausedRef.current = isPaused + }, [isPaused]) + + const rebuildDetail = useCallback((traceId: string): WaterfallData | null => { + const wf = toWaterfallData([...detailSpansRef.current.values()], traceId) + if (wf) setWaterfallData(wf) + return wf + }, []) + + // `silent` reload (the live append path) updates the waterfall in place + // without the loading spinner / blank-out / error states, so the open trace + // streams in new spans without flicker. A transient empty / failed read is + // ignored, keeping the current view rather than clearing it. const loadTraceSpans = useCallback( async (traceId: string, opts?: { silent?: boolean }) => { const silent = opts?.silent ?? false @@ -150,12 +168,20 @@ export function Traces() { setWaterfallData(null) } try { - const data = await fetchTraceTree(traceId) - if (data.roots?.length) { - const wf = treeToWaterfallData(data.roots) - if (wf) setWaterfallData(wf) - else if (!silent) setSpansError('failed to process span data') - } else if (!silent) { + // Seed the full trace, NON-internal — matching what the engine pushes + // on the `trace-spans` stream (the subscriber excludes internal spans), + // so the seed and the live increments are consistent. `limit` is a + // practical ceiling; a pathologically large trace (>limit spans) seeds + // partially and fills in as the stream delivers the remainder. + const { spans } = await fetchTraces({ + trace_id: traceId, + search_all_spans: true, + include_internal: false, + limit: 10000, + }) + detailSpansRef.current = new Map(spans.map((s) => [s.span_id, s])) + const wf = rebuildDetail(traceId) + if (!wf && !silent) { setSpansError('no span data available for this trace') } } catch (err) { @@ -168,18 +194,44 @@ export function Traces() { if (!silent) setIsLoadingSpans(false) } }, - [], + [rebuildDetail], ) - // Live-refresh: refetch the trace list on the engine `trace` trigger, and - // silently reload the open trace's detail tree so it streams new spans - // without a reselect. Suspended while paused / tab hidden (see the hook). - useTracesLiveRefresh({ - isPaused, - onSignal: () => { - if (selectedTraceId) loadTraceSpans(selectedTraceId, { silent: true }) + // Merge a pushed batch of spans for the open trace into the waterfall. + const appendDetailSpans = useCallback( + (traceId: string, spans: StoredSpan[]) => { + if (spans.length === 0) return + for (const s of spans) detailSpansRef.current.set(s.span_id, s) + rebuildDetail(traceId) }, - }) + [rebuildDetail], + ) + + // Subscribe the open trace to its scoped `trace-spans` stream: only this + // trace's span activity arrives, appending without a reselect or refetch. + // Re-subscribes when the selection changes; frozen while paused. + // + // `active` is shared with the handler so a stream frame still in flight when + // the selection changes is dropped: without it, the unregistered-but-running + // handler would append the OLD trace's spans into `detailSpansRef` (already + // reset for the NEW trace) and rebuild the wrong waterfall. + useEffect(() => { + if (!selectedTraceId) return + let stop: (() => void) | undefined + let active = true + void (async () => { + const client = await getIiiClient() + if (!active) return + stop = startTraceSpansStream(client, selectedTraceId, (spans) => { + if (!active || isPausedRef.current) return + appendDetailSpans(selectedTraceId, spans) + }) + })() + return () => { + active = false + stop?.() + } + }, [selectedTraceId, appendDetailSpans]) const selectTrace = useCallback( (traceId: string | null) => { @@ -187,6 +239,7 @@ export function Traces() { setSelectedSpan(null) setWaterfallData(null) setSpansError(null) + detailSpansRef.current = new Map() if (traceId) { loadTraceSpans(traceId) } diff --git a/console/web/src/types/chat.ts b/console/web/src/types/chat.ts index f3527ec51..c951ff17f 100644 --- a/console/web/src/types/chat.ts +++ b/console/web/src/types/chat.ts @@ -72,9 +72,9 @@ export interface FunctionCallMessage extends BaseMessage { } /** - * `kind: 'compaction'` represents collapsed history; messages before it - * must NOT be reshipped on subsequent `run::start` calls. The translator - * (`translateUiHistoryForBackend`) honours this barrier. + * `kind: 'compaction'` renders the collapsed-history marker in the + * transcript. The session-tree is the single source of truth for what the + * provider sees, so this marker is purely presentational. */ export interface SystemMessage extends BaseMessage { role: 'system' diff --git a/harness/docs/architecture.md b/harness/docs/architecture.md index a3ae879e0..172dd4c70 100644 --- a/harness/docs/architecture.md +++ b/harness/docs/architecture.md @@ -21,7 +21,7 @@ workers. | harness | [src/harness/](harness/src/harness/) | Meta-worker; loads `iii-permissions.yaml`, exposes `harness::trigger` (WS ingestion bridge — see [Telemetry & trace correlation](#telemetry--trace-correlation)) / `policy::check_permissions` / `ui::*` / `harness::provider::{register,resolve,list}`. Owns the provider registry + the `harness` entry in the `configuration` worker (credentials, settings, permissions — see [storage.md](harness/docs/storage.md)). | [workers/harness.md](harness/docs/workers/harness.md) | | turn-orchestrator | [src/turn-orchestrator/](harness/src/turn-orchestrator/) | Durable FSM driving each agent turn; `dispatchWithHook` approval chokepoint. | [workers/turn-orchestrator.md](harness/docs/workers/turn-orchestrator.md) | | approval-gate | [src/approval-gate/](harness/src/approval-gate/) | Registers `approval::resolve`; persists decisions to scope `approvals`. Wake via `turn::on_approval` state trigger. Default mode from `harness` config `permissions.default_mode`. | [workers/approval-gate.md](harness/docs/workers/approval-gate.md) | -| session | [src/session/](harness/src/session/) | Branching session storage (`session-tree::*`) plus per-session inbox queues (`session-inbox::*`). | [workers/session.md](harness/docs/workers/session.md) | +| session | [src/session/](harness/src/session/) | Branching session storage (`session-tree::*`). | [workers/session.md](harness/docs/workers/session.md) | | llm-budget | [src/llm-budget/](harness/src/llm-budget/) | Workspace + agent LLM spend caps with alerts, forecast, period rollover. | [workers/llm-budget.md](harness/docs/workers/llm-budget.md) | | hook-fanout | [src/hook-fanout/](harness/src/hook-fanout/) | Generic publish-and-collect primitive over a stream topic. | [workers/hook-fanout.md](harness/docs/workers/hook-fanout.md) | | models-catalog | [src/models-catalog/](harness/src/models-catalog/) | Model-capability catalogue in iii state (provider-registered only; no embedded seed or fallback), refreshed by `provider::::refresh_models`. | [workers/models-catalog.md](harness/docs/workers/models-catalog.md) | @@ -69,7 +69,6 @@ flowchart LR client -- "harness::trigger(run::start, ...)" --> harness harness -- "iii.trigger run::start" --> turnOrch - client -- "ui::subscribe" --> harness turnOrch -- "provider::*::stream" --> provAnth turnOrch -- "provider::*::stream" --> provOAI @@ -77,7 +76,7 @@ flowchart LR turnOrch -- "provider::*::stream" --> provLms turnOrch -- "provider::*::stream" --> provLlama turnOrch -- "consultBefore: policy::check_permissions" --> harness - turnOrch -- "session-tree::* mirror" --> session + turnOrch -- "session-tree::* read/append" --> session turnOrch -- "state::* persistence" --> state client -- "approval::resolve" --> approval @@ -94,8 +93,6 @@ flowchart LR state -- "agent::events stream (scoped trigger)" --> client state -- "agent::events stream" --> compact - state -- "state trigger (scope=turn_state)" --> harness - harness -- "ui::sessions::changed::" --> client compact -- "session-tree::compact" --> session ``` diff --git a/harness/docs/workers/context-compaction.md b/harness/docs/workers/context-compaction.md index 997ca07b0..82600b477 100644 --- a/harness/docs/workers/context-compaction.md +++ b/harness/docs/workers/context-compaction.md @@ -191,12 +191,13 @@ When `compact_now` runs: 1. The entry matching `last_user_message_id` is extracted from the message list before it is passed to the summariser (so it is not summarised away). -2. After `summarizeAndAppend` completes, `replay.ts::reinjectReplay` re-adds - the extracted user message to the session tree. -3. A synthetic assistant prompt ("Continue if you have next steps, or stop and +2. That user message already sits on the active path as the compaction node's + parent, so it needs no reinjection — `context-view.ts` reconstructs the + window as `[summary, ...tail]`, which already ends with it. +3. A synthetic user prompt ("Continue if you have next steps, or stop and ask for clarification.") is appended via `session-tree::append_synthetic` - so the model picks up where it left off. -4. `CompactNowResult.auto_continued` is `true` when this replay happened. + as a child of the compaction so the model picks up where it left off. +4. `CompactNowResult.auto_continued` is `true` when a replay target existed. ## Backward compatibility @@ -235,7 +236,7 @@ Compaction-related keys use dedicated scopes (key = `session_id`): | `prune_lease` | Same nonce-and-readback pattern, separate scope so the prune path does not block async compaction. | | `last_compaction_at` | Wall-clock ms of the most recent successful compaction. Stamped by `stampLastCompaction`. | -Flat transcript rewrites use scope `messages`, key `session_id` (see [flat-state.ts](harness/src/context-compaction/flat-state.ts)). +Compaction appends a `Compaction` entry to `session-tree` (and optionally replay + synthetic continue). The turn FSM reconstructs the compacted provider window at read time via [context-view.ts](harness/src/turn-orchestrator/state-runtime/context-view.ts); there is no separate flat `messages` scope. ## Observability @@ -277,15 +278,15 @@ Worker manifest deps (`iii.worker.yaml`): | `src/context-compaction/config.ts` | Reads all `COMPACT_*` env vars. | | `src/context-compaction/handler-async.ts` | Async TurnEnd path: envelope decode, overflow check, lease, prune, summarise. | | `src/context-compaction/handler-sync.ts` | Sync pre-turn path: lease-with-wait, extract replay, prune, summarise, reinject. | -| `src/context-compaction/handler-pipeline.ts` | Shared prune → summarise → flat-state rewrite pipeline used by both handlers. | -| `src/context-compaction/flat-state.ts` | Rewrites scope `messages` after compaction so the next turn reads the new flat transcript. | +| `src/context-compaction/handler-pipeline.ts` | Shared prune → summarise pipeline used by both handlers. | +| `src/context-compaction/flat-state.ts` | `buildSummaryMessage` helper for the compacted provider window. | | `src/context-compaction/model-resolver.ts` | Shared model-resolution helpers: `fetchModelLimit` (catalog lookup) and `resolveModelFromSession` (session-scan + catalog lookup). | | `src/context-compaction/prune.ts` | Tool-output pruning (`prune`). | | `src/context-compaction/summarize.ts` | `summarizeAndAppend`: load → select tail → summarise → append Compaction entry. | | `src/context-compaction/overflow.ts` | `usable`, `isOverflow`, `preserveRecentBudget` — model-adaptive math. | | `src/context-compaction/selection.ts` | `selectWithEntryIds`, `completedCompactions` — tail selection with entry ID tracking. | | `src/context-compaction/template.ts` | `SUMMARY_TEMPLATE` + `buildPrompt` — structured prompt construction. | -| `src/context-compaction/replay.ts` | `extractReplayTarget` + `reinjectReplay` — user-message replay for sync path. | +| `src/context-compaction/replay.ts` | `extractReplayTarget` — locates the last user message so it is excluded from the summary on the sync path. | | `src/context-compaction/lease.ts` | `acquireLease`, `acquireLeaseWithWait`, `releaseLease`, `stampLastCompaction`. | | `src/context-compaction/stream-collect.ts` | Drives `provider::::stream` via in-process channel and collects the final message. | | `src/context-compaction/strip-media.ts` | Strips images and truncates tool outputs before sending to the summariser. | diff --git a/harness/docs/workers/harness.md b/harness/docs/workers/harness.md index d33da29dd..56478513e 100644 --- a/harness/docs/workers/harness.md +++ b/harness/docs/workers/harness.md @@ -13,21 +13,21 @@ engine URL and the permissions file path, loads `chokidar` so policy changes apply without a restart. It does NOT participate in the durable run loop and registers no triggers -that drive transitions; its only fan-out trigger is the passive sessions +that drive transitions; its only fan-out trigger is the passive models-catalog state trigger. ## Registered functions - `harness::trigger` — Browser kickoff for a chat turn: take `{session_id?, message_id?, payload}` (where `payload` is a flat `run::start` payload), forward `payload` to `run::start`, and return the result wrapped in an HTTP-style `{status_code, headers, body}` envelope. The target function id is always `run::start` — clients don't choose it. Routing through this hop (instead of calling `run::start` directly) lets the harness span wrapper seed `iii.session.id` / `iii.message.id` baggage from the outer body (see [architecture.md § Telemetry & trace correlation](harness/docs/architecture.md#telemetry--trace-correlation)). -- `ui::subscribe` — Register a browser's interest in a session (or all sessions if session_id is null). -- `ui::unsubscribe` — Remove a browser's subscription to a session (or its all-sessions sub if session_id is null). +- `ui::models::subscribe` — Register a browser's interest in model-catalog changes (`ui::models::changed::` pushes). +- `ui::models::unsubscribe` — Remove a browser's model-catalog change subscription. - `harness::fs::read_inline` — Read a host file via shell::fs::read, drain its channel, and return a `{content:[{text}], details:{size, truncated, bytes_read}}` envelope (max 256 KiB inline by default). - `policy::check_permissions` — Evaluate a function call against the current `iii-permissions.yaml`. Returns `{ decision: "allow" | "deny" | "needs_approval", rule_id?, matched_constraint? }`. -- `harness::fanout::session_created` — Internal handler invoked by the sessions state trigger; fans the new session id out to every all-sessions subscriber via `ui::sessions::changed::`. Gates in-handler on the `state:created` marker. +- `harness::fanout::models_changed` — Internal handler invoked by the models-catalog state trigger; debounces writes and fans out `ui::models::changed::` to every subscribed browser. ## Triggers -- **State trigger** on `scope: turn_state` (no `condition_function_id`) → `harness::fanout::session_created`. Lives in [src/harness/fanout/sessions-poll.ts](harness/src/harness/fanout/sessions-poll.ts). The handler gates on `state:created` events where key = session id — the first persist of a turn record signals session creation. (This replaced the earlier `session_index` marker scope.) +- **State trigger** on `scope: models` (no `condition_function_id`) → `harness::fanout::models_changed`. Lives in [src/harness/fanout/models-changed.ts](harness/src/harness/fanout/models-changed.ts). The handler debounces models-scope state writes and pushes `ui::models::changed::` to every browser that called `ui::models::subscribe`. The harness no longer fans `agent::events` out to browsers: each browser subscribes directly to the engine `agent::events` stream with a @@ -37,10 +37,10 @@ harness meta-worker no longer re-pushes it. ## State keys -The harness reads state but doesn't own any keys. The sessions state -trigger observes `turn_state` scope `state:created` events — those entries are -owned by the orchestrator (see -[workers/turn-orchestrator.md](harness/docs/workers/turn-orchestrator.md)). +The harness reads state but doesn't own any keys. The models-catalog state +trigger observes `models` scope writes — those entries are owned by the +models-catalog worker (see +[workers/models-catalog.md](harness/docs/workers/models-catalog.md)). ## Configuration @@ -75,13 +75,13 @@ From [src/harness/iii.worker.yaml](harness/src/harness/iii.worker.yaml): | [src/harness/register.ts](harness/src/harness/register.ts) | Composes the worker's bus surface; called by both `main.ts` and the composite [src/index.ts](harness/src/index.ts). | | [src/harness/config.ts](harness/src/harness/config.ts) | Loads `engine_url` + `permissions_path` from `config.yaml`. | | [src/harness/trigger.ts](harness/src/harness/trigger.ts) | `harness::trigger` handler — WS ingestion bridge for browser-originated chat turns. Forwards the flat `payload` to `run::start` (target function id hard-coded, not client-supplied); the wrapping `instrumentHandler` (see `runtime/otel.ts`) reads `session_id`/`message_id` from the outer body and seeds baggage. | -| [src/harness/ui-subscribe.ts](harness/src/harness/ui-subscribe.ts) | In-memory `FanoutState` plus `ui::subscribe` / `ui::unsubscribe`. | +| [src/harness/ui-subscribe.ts](harness/src/harness/ui-subscribe.ts) | In-memory `FanoutState` plus `ui::models::subscribe` / `ui::models::unsubscribe`. | | [src/harness/fs.ts](harness/src/harness/fs.ts) | `harness::fs::read_inline` — wraps `shell::fs::read` and inlines the channel into the legacy `{content, details}` envelope. | | [src/harness/policy/check-permissions.ts](harness/src/harness/policy/check-permissions.ts) | `registerPolicy` — registers `policy::check_permissions` and maps a `Decision` to the wire reply (`allow` / `deny` / `needs_approval`). | | [src/harness/policy/handle.ts](harness/src/harness/policy/handle.ts) | `PermissionsHandle` + `loadAndWatch` — loads `iii-permissions.yaml`, holds the current `Permissions`, and hot-reloads it via a debounced `chokidar` watcher. | | [src/harness/policy/permissions.ts](harness/src/harness/policy/permissions.ts) | `Permissions` — parses the YAML into compiled rules and evaluates a call via `check(function_id, args)` (first match wins → `Decision`). | | [src/harness/policy/compile.ts](harness/src/harness/policy/compile.ts) | `compileRule` / `matchFunctionId` / `matchConstraints` — compiles a `RuleSpec` into a `CompiledRule`, matches a `function_id` by exact equality or `*` glob, and evaluates `equals` / `matches` (regex) arg constraints. | | [src/harness/policy/types.ts](harness/src/harness/policy/types.ts) | `RuleSpec`, `ConstraintSpec`, `Decision`, `MatchedConstraint` types for `iii-permissions.yaml` rules and evaluation results. | -| [src/harness/fanout/index.ts](harness/src/harness/fanout/index.ts) | Spawns the sessions fan-out pump. | -| [src/harness/fanout/sessions-poll.ts](harness/src/harness/fanout/sessions-poll.ts) | State-trigger handler on scope `turn_state` that fans new session ids to every all-sessions subscriber via `ui::sessions::changed::`. | +| [src/harness/fanout/index.ts](harness/src/harness/fanout/index.ts) | Spawns the models-catalog fan-out pump. | +| [src/harness/fanout/models-changed.ts](harness/src/harness/fanout/models-changed.ts) | State-trigger handler on scope `models` that debounces catalog writes and fans `ui::models::changed::` to subscribed browsers. | | [src/harness/iii.worker.yaml](harness/src/harness/iii.worker.yaml) | iii worker manifest (dependencies, install/start scripts). | diff --git a/harness/docs/workers/session.md b/harness/docs/workers/session.md index 7ac2feefd..e9b18131b 100644 --- a/harness/docs/workers/session.md +++ b/harness/docs/workers/session.md @@ -1,7 +1,6 @@ # session -Session storage (`session-tree::*` parent-linked tree + `session-inbox::*` -queues) on the iii bus. +Session storage (`session-tree::*` parent-linked tree) on the iii bus. ## Purpose @@ -12,14 +11,10 @@ compactor writes summary entries, and the UI loads the active path. A session can be forked at any entry (producing a sibling branch sharing history up to that point) or cloned (deep copy with new ids). -Alongside the tree, each session has named in-process inboxes -(`session-inbox::*`) for cross-worker fan-in — small lists of opaque -payloads partitioned by `(name, session_id)`. - -Two backends ship: `IiiStateSessionStore` (the default) stores entries -under iii state scopes `session_tree:` (per-entry) and -`session_tree_meta` (per-session metadata); `InMemoryStore` is the test -backend. +Production storage uses `IiiStateSessionStore`: entries under iii state +scopes `session_tree:` (per-entry) and `session_tree_meta` +(per-session metadata). Unit tests use `InMemoryStore` directly (not a +worker config option). ## Registered functions @@ -34,19 +29,12 @@ backend. - `session-tree::ensure` — Idempotently ensure a session exists with the given id. - `session-tree::append` — Append an AgentMessage entry to a session. - `session-tree::messages` — Load every AgentMessage on the active path of a session, paired with its entry_id, oldest first. -- `session-tree::reconcile` — Mirror missing messages from a state-snapshot into session-tree. - `session-tree::list` — List sessions with optional pagination and ordering. - `session-tree::compactions` — Return all Compaction entries for a session, sorted by timestamp ascending. -- `session-tree::append_synthetic` — Append a synthetic user-role message entry to a session (used by the context-compaction replay path). +- `session-tree::append_synthetic` — Append a synthetic user-role message entry to a session (used for the post-compaction continue nudge). - `session-tree::update_part` — Replace the content of a `function_result` message entry with compacted output. - `session-tree::update_parts` — Batch variant of `update_part`; loads target entries once and rewrites all of them. -### `session-inbox::*` - -- `session-inbox::push` — Append an item to a session-scoped inbox. -- `session-inbox::peek` — Read all items in a session-scoped inbox without mutating. -- `session-inbox::drain` — Atomically read and clear all items in a session-scoped inbox. - ## Triggers None — this worker is a pure storage surface. @@ -65,24 +53,6 @@ None — this worker is a pure storage surface. resumed approval replies in the correct transcript position when their ids are non-monotonic relative to wall-clock order. -`session-inbox::*` (under the configured `session.state_scope`, default -`inbox`): - -| Scope | Key | Value | -|---|---|---| -| `inbox` | `/` | An append-only JSON array of opaque items. | - -## Configuration - -From the `session` section of [config.yaml](harness/config.yaml): - -- `store_backend` (default `iii_state`; alternative `memory`) — which - `SessionStore` implementation `register()` instantiates. -- `state_scope` (default `inbox`) — iii state scope used by - `session-inbox::*`. Note: the tree backend uses its own hard-coded - scopes (`session_tree:*`, `session_tree_meta`); only the inbox honours - this setting. - ## Dependencies From [src/session/iii.worker.yaml](harness/src/session/iii.worker.yaml): @@ -93,12 +63,9 @@ From [src/session/iii.worker.yaml](harness/src/session/iii.worker.yaml): | File | Purpose | |---|---| | [src/session/main.ts](harness/src/session/main.ts) | Binary entry point (`iii-session`). | -| [src/session/register.ts](harness/src/session/register.ts) | Picks the backend and wires both sub-surfaces. | -| [src/session/config.ts](harness/src/session/config.ts) | Loads the `session` config section. | +| [src/session/register.ts](harness/src/session/register.ts) | Registers `session-tree::*` on `IiiStateSessionStore`. | | [src/session/tree/register.ts](harness/src/session/tree/register.ts) | Registers all 15 `session-tree::*` functions; exports `FUNCTION_IDS`. | | [src/session/tree/operations.ts](harness/src/session/tree/operations.ts) | Pure tree algorithms: create, fork, clone, compact, active path, messages, reconcile, tree, export_html, list. | | [src/session/tree/store.ts](harness/src/session/tree/store.ts) | `SessionStore` interface + `InMemoryStore` + `IiiStateSessionStore`. | | [src/session/tree/types.ts](harness/src/session/tree/types.ts) | `SessionEntry` (`message` / `custom_message` / `branch_summary` / `compaction`, each with an explicit `timestamp`), `SessionMeta`, `TreeNode`, `ReconcileResult`, `SessionError`, plus the `entryTimestamp` helper used by the `(timestamp, id)` sort. | -| [src/session/inbox/handlers.ts](harness/src/session/inbox/handlers.ts) | Registers the three `session-inbox::*` functions. | -| [src/session/inbox/key.ts](harness/src/session/inbox/key.ts) | `inboxKey(name, session_id) → "/"` under scope `inbox`. | | [src/session/iii.worker.yaml](harness/src/session/iii.worker.yaml) | Worker manifest. | diff --git a/harness/docs/workers/turn-orchestrator.md b/harness/docs/workers/turn-orchestrator.md index cbcf7946e..2f0d86390 100644 --- a/harness/docs/workers/turn-orchestrator.md +++ b/harness/docs/workers/turn-orchestrator.md @@ -39,7 +39,7 @@ unreachable → deny with a `gate_unavailable` `DenialEnvelope`. - `turn::assistant_streaming` — FSM step: stream the turn over a provider channel; on completion emit `message_complete`, persist the assistant message (dup-guarded), route to `function_execute` / `steering_check` / `stopped` (via `finishSession`). - `turn::function_execute` — FSM step: own the full function lifecycle via `rec.work`; build batch from `rec.last_assistant`, run each call (skip already-executed and awaiting-approval ids), checkpoint per-call via `writeRecord`; if `pending` → append to `awaiting_approval` and keep dispatching the remaining calls (pending does not block siblings); park to `function_awaiting_approval` when any call awaits approval; finalize results into messages + emit `turn_end` when the batch completes → `steering_check` / `stopped` (via `finishSession`). - `turn::function_awaiting_approval` — FSM step: on each wake, read decisions for individual `awaiting_approval[]` entries; execute each resolved call immediately (`allow` → dispatch pre-approved; `deny`/`aborted` → synthetic denial); remove resolved entries; stay parked while any remain; when none remain → `finalizeBatch` if complete else `function_execute`. -- `turn::steering_check` — FSM step: drain `steering`/`followup` inboxes, enforce `max_turns` cap (emits synthetic `max_turns` message + `turn_end` → `stopped` via `finishSession`), route to `assistant_streaming` / `stopped`. +- `turn::steering_check` — FSM step: after tool batch or text-only assistant, continue to `assistant_streaming` when `function_results` remain (unless `max_turns`), else `turn_end` → `stopped`. - `turn::get_state` — One-shot reader returning a lean `TurnStateView` (from `schemas.ts:toView`) for a session. UI clients call this on reload to recover in-progress modals (e.g. `function_awaiting_approval`) without reading iii state directly. Returns `null` for unknown sessions. ## Triggers @@ -60,7 +60,7 @@ The 7 states from [state.ts](harness/src/turn-orchestrator/state.ts): | `assistant_streaming` | [assistant-streaming/process.ts](harness/src/turn-orchestrator/assistant-streaming/process.ts) | Increment `turn_count`; create channel; trigger provider stream; relay `message_update` deltas; on completion call `finalizeAssistantTurn` which emits `message_complete`, persists the assistant message (dup-guarded), then routes → `function_execute` (has calls) / `steering_check` (no calls) / `stopped` via `finishSession` (error/aborted). | | `function_execute` | [function-execute/process.ts](harness/src/turn-orchestrator/function-execute/process.ts) | Build batch from `rec.last_assistant` (or reuse existing `rec.work`); for each call: emit `function_execution_start`, skip if already executed or awaiting approval, dispatch via `dispatchWithHook`; if `pending` → append to `awaiting_approval` and continue other calls; park to `function_awaiting_approval` when any call awaits; otherwise commit result (silent `writeRecord` checkpoint) + emit `function_execution_end`; after batch: fold results into messages + emit `turn_end` → `steering_check` / `stopped` via `finishSession`. | | `function_awaiting_approval` | [function-awaiting-approval/process.ts](harness/src/turn-orchestrator/function-awaiting-approval/process.ts) | On each wake: for each `awaiting_approval[]` entry with a decision, execute immediately (`allow` → pre-approved dispatch; `deny`/`aborted` → synthetic denial); remove resolved entries; stay parked while any remain; when none remain → `finalizeBatch` if complete else `function_execute`. | -| `steering_check` | [steering-check/process.ts](harness/src/turn-orchestrator/steering-check/process.ts) | Priority route: steering msg → `assistant_streaming` (unless `max_turns` reached); followup msg → `assistant_streaming` (unless `max_turns` reached); function results present → `assistant_streaming` (unless `max_turns` reached); else emit `turn_end` once → `stopped` via `finishSession`. `max_turns` path emits a synthetic `message_complete` + `turn_end`. | +| `steering_check` | [steering-check/process.ts](harness/src/turn-orchestrator/steering-check/process.ts) | `function_results` present → `assistant_streaming` (unless `max_turns` reached); else emit `turn_end` once → `stopped` via `finishSession`. `max_turns` path emits a synthetic `message_complete` + `turn_end`. | | `stopped` | (no handler) | Terminal. Idempotent. Session teardown (`agent_end`) happens inline via `TurnStatePorts.finishSession` before entering this state. | | `failed` | (set by `runTransition` on unexpected throw) | Terminal. Carries `error: {kind, message}` on the record. Emits `message_complete{stop_reason:'error'}` + `agent_end` so the UI sees the reason. A handler may throw `TransientError` to use the queue's retry/DLQ instead. | @@ -84,11 +84,13 @@ Session-scoped iii state uses semantic scopes from | Scope | Key | Purpose | |---|---|---| | `turn_state` | `` | Serialised `TurnStateRecord` (incl. `work?: TurnWork` and `error?: {kind, message}`). | -| `messages` | `` | Active path `AgentMessage[]`; mirrored into `session-tree::*` on every save (inline in `TurnStore.saveMessages` / `appendMessages`). | | `run_request` | `` | The `run::start` payload enriched by `provisioning` to include `function_schemas: [agentTriggerTool()]` and the assembled `system_prompt`. Typed as `RunRequest` ([run-request.ts](harness/src/turn-orchestrator/run-request.ts)). | -| `session_tree_mirror_len` | `` | High-water mark so the session-tree messages mirror is incremental. | | `event_counter` | `` | Monotonic counter for `agent::events` sequence numbers. | +Conversation history lives in `session-tree::*` only. `TurnStore.loadMessages` reconstructs the provider-facing window from the tree + latest compaction entry ([context-view.ts](harness/src/turn-orchestrator/state-runtime/context-view.ts)); `appendMessages` writes via `session-tree::append`, threading each message onto the active tip. `run::start` seeds a new session by appending its initial messages the same way — there is no separate reconcile/repair path. + +**Session-tree call discipline** (keeps the per-turn `session-tree::` RPC count flat): `session-tree::ensure` runs exactly once per run — `TurnStore.ensureSession` is called at the top of `run::start`, and `loadMessages`/`appendMessages` deliberately do **not** re-ensure (every later turn step is provably preceded by `run::start`, so re-ensuring on each read/write was pure overhead). Within `assistant_streaming` the window loaded by `prepareStreamContext` is threaded into `persistAssistantIfNew` for the re-entry dedup instead of reloading. `agent_end` is a turn-end **signal**: the transcript reaches the UI incrementally via `message_update`/`message_complete` and is re-read from `session-tree` on reload, so `finishSession`/`failTransition` emit `agent_end` with empty `messages` rather than reloading the whole session to fill a field no consumer reads. + Keys that no longer exist: `function_prepared`, `function_executed`, `function_schemas` (standalone), `tool_prepared`, `tool_executed`, `tool_schemas`, `sandbox_id`, `last_compaction_at`, diff --git a/harness/src/context-compaction/flat-state.ts b/harness/src/context-compaction/flat-state.ts index a0295dfef..dbf7dc1a3 100644 --- a/harness/src/context-compaction/flat-state.ts +++ b/harness/src/context-compaction/flat-state.ts @@ -1,11 +1,8 @@ /** - * Rewrite flat transcript messages in scope `messages`. + * Build the assistant summary message used in the compacted provider window. */ -import type { ISdk } from '../runtime/iii.js'; -import { stateSet } from '../runtime/state.js'; -import { MESSAGES_SCOPE } from '../turn-orchestrator/state.js'; -import type { AgentMessage, AssistantMessage } from '../types/agent-message.js'; +import type { AssistantMessage } from '../types/agent-message.js'; export function buildSummaryMessage(summary_text: string): AssistantMessage { return { @@ -25,11 +22,3 @@ export function buildSummaryMessage(summary_text: string): AssistantMessage { timestamp: Date.now(), }; } - -export async function rewriteFlatMessages( - iii: ISdk, - session_id: string, - messages: AgentMessage[], -): Promise { - await stateSet(iii, MESSAGES_SCOPE, session_id, messages); -} diff --git a/harness/src/context-compaction/handler-async.ts b/harness/src/context-compaction/handler-async.ts index 8cf11844a..518fafdb0 100644 --- a/harness/src/context-compaction/handler-async.ts +++ b/harness/src/context-compaction/handler-async.ts @@ -10,7 +10,6 @@ import { logger } from '../runtime/otel.js'; import { compactionConfig } from './config.js'; import { isSummarizeOk, - persistCompactionFlatState, publishCompactionDone, runSummarizeCompaction, } from './handler-pipeline.js'; @@ -111,7 +110,7 @@ async function resolveModelFromEvent( } export async function handleAsync(iii: ISdk, frame: unknown): Promise { - return withSpan('compaction.async', {}, async () => { + return withSpan('compaction::async', {}, async () => { const payload = extractEventPayload(frame); if (!payload) return; @@ -169,12 +168,6 @@ export async function handleAsync(iii: ISdk, frame: unknown): Promise { setCurrentSpanAttribute('used_prior_summary', isSummarizeOk(result)); if (isSummarizeOk(result)) { - await persistCompactionFlatState( - iii, - payload.session_id, - result.summary_text, - result.tail_messages, - ); await publishCompactionDone(iii, payload.session_id, 'async', result); } } catch (err) { diff --git a/harness/src/context-compaction/handler-pipeline.ts b/harness/src/context-compaction/handler-pipeline.ts index 7613d7e3f..c2fa4cf6e 100644 --- a/harness/src/context-compaction/handler-pipeline.ts +++ b/harness/src/context-compaction/handler-pipeline.ts @@ -1,13 +1,11 @@ /** - * Shared prune → summarize → flat-state rewrite path for sync and async handlers. + * Shared prune → summarize path for sync and async compaction handlers. */ import { logger } from '../runtime/otel.js'; import type { ISdk } from '../runtime/iii.js'; import { emit } from '../turn-orchestrator/events.js'; -import type { AgentMessage } from '../types/agent-message.js'; import { compactionConfig } from './config.js'; -import { buildSummaryMessage, rewriteFlatMessages } from './flat-state.js'; import type { ModelLimit } from './overflow.js'; import { prune } from './prune.js'; import { @@ -29,8 +27,8 @@ export interface CompactionDonePayload { /** * Best-effort: a publish failure is logged but never thrown — the - * caller has already done the load-bearing work (rewriting flat - * state) and the UI marker is a nice-to-have. + * caller has already done the load-bearing work (tree compaction) + * and the UI marker is a nice-to-have. */ async function emitCompactionDone( iii: ISdk, @@ -85,18 +83,6 @@ export async function runSummarizeCompaction( }); } -export async function persistCompactionFlatState( - iii: ISdk, - session_id: string, - summary_text: string, - tail_messages: AgentMessage[], - extra?: AgentMessage[], -): Promise { - const messages: AgentMessage[] = [buildSummaryMessage(summary_text), ...tail_messages]; - if (extra) messages.push(...extra); - await rewriteFlatMessages(iii, session_id, messages); -} - export async function publishCompactionDone( iii: ISdk, session_id: string, diff --git a/harness/src/context-compaction/handler-sync.ts b/harness/src/context-compaction/handler-sync.ts index 206dfe7d5..e156b17cd 100644 --- a/harness/src/context-compaction/handler-sync.ts +++ b/harness/src/context-compaction/handler-sync.ts @@ -10,14 +10,10 @@ import type { ISdk } from '../runtime/iii.js'; import { logger } from '../runtime/otel.js'; import type { AgentMessage } from '../types/agent-message.js'; import { compactionConfig } from './config.js'; -import { - persistCompactionFlatState, - publishCompactionDone, - runSummarizeCompaction, -} from './handler-pipeline.js'; +import { publishCompactionDone, runSummarizeCompaction } from './handler-pipeline.js'; import { acquireLeaseWithWait, releaseLease } from './lease.js'; import type { ModelLimit } from './overflow.js'; -import { type MessageWithEntryId, extractReplayTarget, reinjectReplay } from './replay.js'; +import { type MessageWithEntryId, extractReplayTarget } from './replay.js'; export type CompactNowInput = { session_id: string; @@ -99,29 +95,20 @@ export async function handleSync(iii: ISdk, input: CompactNowInput): Promise({ function_id: 'session-tree::append_synthetic', payload: { session_id: input.session_id, text: 'Continue if you have next steps, or stop and ask for clarification.', - metadata: { compaction_continue: true }, - parent_id: lastEntryId, + parent_id: result.compaction_entry_id || null, }, timeoutMs: 10_000, }); } - await persistCompactionFlatState( - iii, - input.session_id, - result.summary_text, - result.tail_messages, - replay ? [replay.message] : undefined, - ); - await publishCompactionDone(iii, input.session_id, 'sync', result); return { diff --git a/harness/src/context-compaction/replay.ts b/harness/src/context-compaction/replay.ts index 18fc9eb8e..2e583335a 100644 --- a/harness/src/context-compaction/replay.ts +++ b/harness/src/context-compaction/replay.ts @@ -1,4 +1,3 @@ -import type { ISdk } from '../runtime/iii.js'; import type { AgentMessage } from '../types/agent-message.js'; export type MessageWithEntryId = { entry_id: string; message: AgentMessage }; @@ -16,21 +15,3 @@ export function extractReplayTarget( truncatedMessages: entries.slice(0, idx), }; } - -export async function reinjectReplay( - iii: ISdk, - session_id: string, - replay: MessageWithEntryId, - parent_id: string | null, -): Promise { - const resp = await iii.trigger({ - function_id: 'session-tree::append', - payload: { - session_id, - parent_id, - message: replay.message, - }, - timeoutMs: 10_000, - }); - return resp?.entry_id ?? parent_id; -} diff --git a/harness/src/harness/fanout/index.ts b/harness/src/harness/fanout/index.ts index 87a659038..215f5ee0e 100644 --- a/harness/src/harness/fanout/index.ts +++ b/harness/src/harness/fanout/index.ts @@ -1,18 +1,15 @@ import type { ISdk } from '../../runtime/iii.js'; import type { FanoutState } from '../ui-subscribe.js'; import { spawnModelsChanged } from './models-changed.js'; -import { spawnSessionsPoll } from './sessions-poll.js'; export type FanoutPumps = { shutdown(): Promise; }; export function spawnPumps(iii: ISdk, state: FanoutState): FanoutPumps { - const stopSessions = spawnSessionsPoll(iii, state); const stopModels = spawnModelsChanged(iii, state); return { async shutdown() { - stopSessions(); stopModels(); }, }; diff --git a/harness/src/harness/fanout/sessions-poll.ts b/harness/src/harness/fanout/sessions-poll.ts deleted file mode 100644 index 2355c68c6..000000000 --- a/harness/src/harness/fanout/sessions-poll.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { ISdk, Trigger } from '../../runtime/iii.js'; -import { logger } from '../../runtime/otel.js'; -import { TURN_STATE_SCOPE } from '../../turn-orchestrator/state.js'; -import type { FanoutState } from '../ui-subscribe.js'; - -export const SESSION_CREATED_HANDLER_FN_ID = 'harness::fanout::session_created'; - -/** - * A new session is signalled by the first `state:created` write on scope - * `turn_state` (key = session id). The state trigger matches that scope in - * engine — no `condition_function_id` RPC per turn_state update — so this - * handler is the sole gate: it acts only on `state:created`. - */ -function sessionCreatedId(event: unknown): string | null { - const obj = (event ?? {}) as Record; - if (obj.event_type !== 'state:created') return null; - const key = typeof obj.key === 'string' ? obj.key : ''; - return key.length > 0 ? key : null; -} - -export function spawnSessionsPoll(iii: ISdk, state: FanoutState): () => void { - const handlerRef = iii.registerFunction( - SESSION_CREATED_HANDLER_FN_ID, - async (event: unknown) => { - const session_id = sessionCreatedId(event); - if (!session_id) return null; - const payload = { added: [session_id], removed: [] as string[] }; - for (const browser_id of state.allSubscribers()) { - iii - .trigger({ - function_id: `ui::sessions::changed::${browser_id}`, - payload, - timeoutMs: 2_000, - }) - .catch((err) => logger.debug('ui::sessions::changed failed', { err: String(err) })); - } - return null; - }, - { - description: - 'Internal: fans out a newly-created session id to ui::sessions::changed::.', - }, - ); - - let trigger: Trigger | null = null; - try { - trigger = iii.registerTrigger({ - type: 'state', - function_id: SESSION_CREATED_HANDLER_FN_ID, - config: { scope: TURN_STATE_SCOPE }, - }); - } catch (err) { - logger.warn('sessions state trigger registration failed', { err: String(err) }); - } - - return () => { - try { - trigger?.unregister(); - } catch {} - try { - handlerRef.unregister(); - } catch {} - }; -} diff --git a/harness/src/harness/main.ts b/harness/src/harness/main.ts index be9978abf..69227753c 100644 --- a/harness/src/harness/main.ts +++ b/harness/src/harness/main.ts @@ -5,6 +5,6 @@ import { register } from './register.js'; await bootstrapWorker({ name: 'harness', description: - 'Meta-worker: ui::subscribe/unsubscribe, harness::fs::read_inline, policy::check_permissions.', + 'Meta-worker: ui::models::subscribe/unsubscribe, harness::fs::read_inline, policy::check_permissions.', register: (iii, ctx) => register(iii, ctx), }); diff --git a/harness/src/harness/ui-subscribe.ts b/harness/src/harness/ui-subscribe.ts index 761300e8a..54c75cdf2 100644 --- a/harness/src/harness/ui-subscribe.ts +++ b/harness/src/harness/ui-subscribe.ts @@ -1,41 +1,14 @@ /** - * In-memory subscription registry + `ui::subscribe` / `ui::unsubscribe` - * function registrations. Mirrors the fanout-state half of - * `harness/src/fanout.rs`. + * In-memory model-catalog subscriber registry plus + * `ui::models::subscribe` / `ui::models::unsubscribe` function registrations. */ import { unwrapBody } from '../runtime/handler.js'; import type { ISdk } from '../runtime/iii.js'; -const ALL_SESSIONS = '__all__'; - export class FanoutState { - // browser_id -> set of session ids (or "__all__") - private readonly subs = new Map>(); - // browser_id set interested in model-catalog changes. Kept separate from - // session subs so the agent-events pump (which evicts on a missing - // ui::session::event handler) can't tear down a browser's model - // subscription, and so model pushes never fan out as session events. private readonly modelSubs = new Set(); - subscribe(browser_id: string, session_id: string | null): void { - const key = session_id ?? ALL_SESSIONS; - let set = this.subs.get(browser_id); - if (!set) { - set = new Set(); - this.subs.set(browser_id, set); - } - set.add(key); - } - - unsubscribe(browser_id: string, session_id: string | null): void { - const key = session_id ?? ALL_SESSIONS; - const set = this.subs.get(browser_id); - if (!set) return; - set.delete(key); - if (set.size === 0) this.subs.delete(browser_id); - } - subscribeModels(browser_id: string): void { this.modelSubs.add(browser_id); } @@ -48,68 +21,9 @@ export class FanoutState { modelSubscribers(): string[] { return [...this.modelSubs]; } - - evictBrowser(browser_id: string): void { - this.subs.delete(browser_id); - this.modelSubs.delete(browser_id); - } - - browserCount(): number { - return this.subs.size; - } - - /** Browsers subscribed to `session_id` (or to all sessions). */ - subscribersFor(session_id: string): string[] { - const out: string[] = []; - for (const [browser_id, set] of this.subs) { - if (set.has(session_id) || set.has(ALL_SESSIONS)) out.push(browser_id); - } - return out; - } - - /** Browsers subscribed to all sessions. */ - allSubscribers(): string[] { - const out: string[] = []; - for (const [browser_id, set] of this.subs) { - if (set.has(ALL_SESSIONS)) out.push(browser_id); - } - return out; - } } export function registerSubscriptions(iii: ISdk, state: FanoutState): void { - iii.registerFunction( - 'ui::subscribe', - async (input: unknown) => { - const body = unwrapBody(input); - const browser_id = typeof body.browser_id === 'string' ? body.browser_id : null; - if (!browser_id) throw new Error('missing browser_id'); - const session_id = typeof body.session_id === 'string' ? body.session_id : null; - state.subscribe(browser_id, session_id); - return { ok: true, total_browsers: state.browserCount() }; - }, - { - description: - "Register a browser's interest in a session (or all sessions if session_id is null).", - }, - ); - - iii.registerFunction( - 'ui::unsubscribe', - async (input: unknown) => { - const body = unwrapBody(input); - const browser_id = typeof body.browser_id === 'string' ? body.browser_id : null; - if (!browser_id) throw new Error('missing browser_id'); - const session_id = typeof body.session_id === 'string' ? body.session_id : null; - state.unsubscribe(browser_id, session_id); - return { ok: true, total_browsers: state.browserCount() }; - }, - { - description: - "Remove a browser's subscription to a session (or its all-sessions sub if session_id is null).", - }, - ); - iii.registerFunction( 'ui::models::subscribe', async (input: unknown) => { diff --git a/harness/src/index.ts b/harness/src/index.ts index 9e510e287..c0f2576a0 100644 --- a/harness/src/index.ts +++ b/harness/src/index.ts @@ -38,7 +38,7 @@ const WORKERS: readonly WorkerDefinition[] = [ { name: 'harness', description: - 'Meta-worker: ui::subscribe/unsubscribe, harness::fs::read_inline, policy::check_permissions.', + 'Meta-worker: ui::models::subscribe/unsubscribe, harness::fs::read_inline, policy::check_permissions.', register: (iii, ctx) => registerHarness(iii, ctx), }, { @@ -59,8 +59,7 @@ const WORKERS: readonly WorkerDefinition[] = [ }, { name: 'session', - description: - 'Session storage (parent-id tree under session-tree::*) and per-session inbox (session-inbox::*) backed by iii state.', + description: 'Session storage (parent-id tree under session-tree::*) backed by iii state.', register: (iii, ctx) => registerSession(iii, ctx), }, { diff --git a/harness/src/session/config.ts b/harness/src/session/config.ts deleted file mode 100644 index 10592c7a6..000000000 --- a/harness/src/session/config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { getSection, getString } from '../runtime/config.js'; - -export type SessionConfig = { - store_backend: 'memory' | 'iii_state'; - state_scope: string; -}; - -export function loadSessionConfig(cfg: Record): SessionConfig { - const section = getSection(cfg, 'session'); - const backend = getString(section, 'store_backend', 'iii_state'); - return { - store_backend: backend === 'memory' ? 'memory' : 'iii_state', - state_scope: getString(section, 'state_scope', 'inbox'), - }; -} diff --git a/harness/src/session/iii.worker.yaml b/harness/src/session/iii.worker.yaml index 3108e53eb..f4f6f7872 100644 --- a/harness/src/session/iii.worker.yaml +++ b/harness/src/session/iii.worker.yaml @@ -4,7 +4,7 @@ language: node deploy: binary manifest: package.json bin: iii-session -description: Session storage (session-tree::* parent-linked tree + session-inbox::* queues) on the iii bus. +description: Session storage (session-tree::* parent-linked tree) on the iii bus. runtime: kind: node diff --git a/harness/src/session/inbox/handlers.ts b/harness/src/session/inbox/handlers.ts deleted file mode 100644 index 4a3e9f81b..000000000 --- a/harness/src/session/inbox/handlers.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * `session-inbox::push|peek|drain` handlers. Mirrors - * `session/src/inbox/handler.rs`. - */ - -import { requireString } from '../../runtime/handler.js'; -import type { ISdk } from '../../runtime/iii.js'; -import { logger } from '../../runtime/otel.js'; -import { stateGet, stateUpdate } from '../../runtime/state.js'; -import { inboxKey } from './key.js'; - -export const PUSH_ID = 'session-inbox::push'; -export const DRAIN_ID = 'session-inbox::drain'; -export const PEEK_ID = 'session-inbox::peek'; - -export type InboxConfig = { state_scope: string }; - -export function registerInbox(iii: ISdk, cfg: InboxConfig): void { - iii.registerFunction( - PUSH_ID, - async (payload: unknown) => { - const obj = (payload ?? {}) as Record; - const name = requireString(obj, 'name'); - const session_id = requireString(obj, 'session_id'); - if (!('item' in obj)) throw new Error('missing required field: item'); - await stateUpdate(iii, cfg.state_scope, inboxKey(name, session_id), [ - { type: 'append', value: obj.item, path: '' }, - ]); - return { ok: true }; - }, - { description: 'Append an item to a session-scoped inbox.' }, - ); - - iii.registerFunction( - PEEK_ID, - async (payload: unknown) => { - const obj = (payload ?? {}) as Record; - const name = requireString(obj, 'name'); - const session_id = requireString(obj, 'session_id'); - const value = await stateGet(iii, cfg.state_scope, inboxKey(name, session_id)); - const items = value === null ? [] : value; - return { items }; - }, - { description: 'Read all items in a session-scoped inbox without mutating.' }, - ); - - iii.registerFunction( - DRAIN_ID, - async (payload: unknown) => { - const obj = (payload ?? {}) as Record; - const name = requireString(obj, 'name'); - const session_id = requireString(obj, 'session_id'); - const resp = await stateUpdate(iii, cfg.state_scope, inboxKey(name, session_id), [ - { type: 'set', value: [], path: '' }, - ]); - if (!resp) { - logger.warn('inbox drain: state::update failed; returning empty', { - name, - session_id, - }); - return { items: [] }; - } - return { items: resp.old_value ?? [] }; - }, - { description: 'Atomically read and clear all items in a session-scoped inbox.' }, - ); -} diff --git a/harness/src/session/inbox/key.ts b/harness/src/session/inbox/key.ts deleted file mode 100644 index cd1ea5803..000000000 --- a/harness/src/session/inbox/key.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function inboxKey(name: string, session_id: string): string { - return `${session_id}/${name}`; -} diff --git a/harness/src/session/main.ts b/harness/src/session/main.ts index e339a2416..6968f5577 100644 --- a/harness/src/session/main.ts +++ b/harness/src/session/main.ts @@ -4,7 +4,6 @@ import { register } from './register.js'; await bootstrapWorker({ name: 'session', - description: - 'Session storage (parent-id tree under session-tree::*) and per-session inbox (session-inbox::*) backed by iii state.', + description: 'Session storage (parent-id tree under session-tree::*) backed by iii state.', register: (iii, ctx) => register(iii, ctx), }); diff --git a/harness/src/session/register.ts b/harness/src/session/register.ts index d714f9210..340ab00d7 100644 --- a/harness/src/session/register.ts +++ b/harness/src/session/register.ts @@ -1,15 +1,7 @@ -import { loadConfig } from '../runtime/config.js'; import type { ISdk } from '../runtime/iii.js'; -import { loadSessionConfig } from './config.js'; -import { registerInbox } from './inbox/handlers.js'; import { registerTree } from './tree/register.js'; -import { IiiStateSessionStore, InMemoryStore, type SessionStore } from './tree/store.js'; +import { IiiStateSessionStore } from './tree/store.js'; -export async function register(iii: ISdk, ctx: { configPath: string }): Promise { - const cfg = await loadConfig(ctx.configPath); - const sessionCfg = loadSessionConfig(cfg); - const store: SessionStore = - sessionCfg.store_backend === 'memory' ? new InMemoryStore() : new IiiStateSessionStore(iii); - registerTree(iii, store); - registerInbox(iii, { state_scope: sessionCfg.state_scope }); +export async function register(iii: ISdk, _ctx: { configPath: string }): Promise { + registerTree(iii, new IiiStateSessionStore(iii)); } diff --git a/harness/src/session/tree/operations.ts b/harness/src/session/tree/operations.ts index 66b5aedb0..69eb7c7ef 100644 --- a/harness/src/session/tree/operations.ts +++ b/harness/src/session/tree/operations.ts @@ -12,7 +12,6 @@ import { type ListOrder, type ListSessionsResult, type MessageWithEntryId, - type ReconcileResult, type SessionEntry, SessionError, type SessionListRow, @@ -70,11 +69,16 @@ export async function appendMessage( parent_id: string | null, message: AgentMessage, ): Promise { + let resolvedParent = parent_id; + if (resolvedParent === null) { + const path = await activePath(store, session_id); + resolvedParent = path.at(-1) ?? null; + } const id = randomUUID(); const entry: SessionEntry = { type: 'message', id, - parent_id, + parent_id: resolvedParent, message, timestamp: Date.now(), }; @@ -131,36 +135,13 @@ export async function loadMessagesWithEntryIds( const out: MessageWithEntryId[] = []; for (const id of path) { const e = byId.get(id); - if (e?.type === 'message') out.push({ entry_id: id, message: e.message }); + if (e?.type === 'message') { + out.push({ entry_id: id, message: e.message }); + } } return out; } -export async function reconcile( - store: SessionStore, - session_id: string, - state_snapshot: AgentMessage[], -): Promise { - const treePairs = await loadMessagesWithEntryIds(store, session_id); - const tree_count_before = treePairs.length; - const state_count = state_snapshot.length; - if (state_snapshot.length <= treePairs.length) { - return { state_count, tree_count_before, tree_count_after: tree_count_before, repaired: 0 }; - } - let lastId: string | null = treePairs.at(-1)?.entry_id ?? null; - let repaired = 0; - for (const msg of state_snapshot.slice(treePairs.length)) { - lastId = await appendMessage(store, session_id, lastId, msg); - repaired++; - } - return { - state_count, - tree_count_before, - tree_count_after: tree_count_before + repaired, - repaired, - }; -} - export async function loadContext( store: SessionStore, session_id: string, diff --git a/harness/src/session/tree/register.ts b/harness/src/session/tree/register.ts index 270300aba..558c960ce 100644 --- a/harness/src/session/tree/register.ts +++ b/harness/src/session/tree/register.ts @@ -1,5 +1,5 @@ /** - * Register all 11 `session-tree::*` functions. Mirrors + * Register the `session-tree::*` functions. Mirrors * `session/src/tree/mod.rs::register_with_iii`. */ @@ -19,7 +19,6 @@ import { fork, listSessions, loadMessagesWithEntryIds, - reconcile, tree, updatePart as updatePartOp, updateParts as updatePartsOp, @@ -38,7 +37,6 @@ export const FUNCTION_IDS = { APPEND: 'session-tree::append', MESSAGES: 'session-tree::messages', LIST: 'session-tree::list', - RECONCILE: 'session-tree::reconcile', COMPACTIONS: 'session-tree::compactions', APPEND_SYNTHETIC: 'session-tree::append_synthetic', UPDATE_PART: 'session-tree::update_part', @@ -165,18 +163,6 @@ export function registerTree(iii: ISdk, store: SessionStore): void { }, ); - iii.registerFunction( - FUNCTION_IDS.RECONCILE, - async (payload: unknown) => { - const obj = (payload ?? {}) as Record; - const session_id = requireString(obj, 'session_id'); - const snapshot = obj.state_snapshot; - if (!Array.isArray(snapshot)) throw new Error('missing required field: state_snapshot'); - return await reconcile(store, session_id, snapshot as AgentMessage[]); - }, - { description: 'Mirror missing messages from a state-snapshot into session-tree' }, - ); - iii.registerFunction( FUNCTION_IDS.LIST, async (payload: unknown) => { diff --git a/harness/src/session/tree/store.ts b/harness/src/session/tree/store.ts index d6c2cf443..9140a71b7 100644 --- a/harness/src/session/tree/store.ts +++ b/harness/src/session/tree/store.ts @@ -1,7 +1,7 @@ /** * Session storage backend. * - * - `InMemoryStore` is the test/replay backend. + * - `InMemoryStore` is used by unit tests only (constructed directly, not via worker config). * - `IiiStateSessionStore` mirrors * `session/src/tree/store_iii_state.rs`. Storage layout: * diff --git a/harness/src/session/tree/types.ts b/harness/src/session/tree/types.ts index ff51f12aa..a38dc15e8 100644 --- a/harness/src/session/tree/types.ts +++ b/harness/src/session/tree/types.ts @@ -86,13 +86,6 @@ export type MessageWithEntryId = { message: AgentMessage; }; -export type ReconcileResult = { - state_count: number; - tree_count_before: number; - tree_count_after: number; - repaired: number; -}; - export type ListOrder = 'asc' | 'desc'; export type SessionListRow = { diff --git a/harness/src/turn-orchestrator/assistant-streaming/ports.ts b/harness/src/turn-orchestrator/assistant-streaming/ports.ts index 75688f299..5c6efbf90 100644 --- a/harness/src/turn-orchestrator/assistant-streaming/ports.ts +++ b/harness/src/turn-orchestrator/assistant-streaming/ports.ts @@ -75,7 +75,11 @@ export type AssistantStreamingPorts = TurnStatePorts & { message: AssistantMessage, body_streamed: boolean, ): Promise; - persistAssistantIfNew(session_id: string, asst: AssistantMessage): Promise; + persistAssistantIfNew( + session_id: string, + asst: AssistantMessage, + messages: AgentMessage[], + ): Promise; }; export function createStreamingPorts(iii: ISdk): AssistantStreamingPorts { @@ -115,8 +119,11 @@ export function createStreamingPorts(iii: ISdk): AssistantStreamingPorts { }); }, - async persistAssistantIfNew(session_id, asst) { - const messages = await base.loadMessages(session_id); + async persistAssistantIfNew(session_id, asst, messages) { + // Dedup against the window already loaded in prepareStreamContext: nothing + // is persisted between that load and here within one invocation, and + // isDuplicateAssistant only inspects the trailing entry — so reusing it is + // identical to a fresh reload and saves a full session round-trip. if (isDuplicateAssistant(messages, asst)) { logger.warn('finalizeAssistant: skipping duplicate assistant push (re-entry detected)', { session_id, diff --git a/harness/src/turn-orchestrator/assistant-streaming/run.ts b/harness/src/turn-orchestrator/assistant-streaming/run.ts index eb4cd6dd5..ec3ff783a 100644 --- a/harness/src/turn-orchestrator/assistant-streaming/run.ts +++ b/harness/src/turn-orchestrator/assistant-streaming/run.ts @@ -2,7 +2,7 @@ * Stream one provider turn, persist the assistant message, and route onward. */ -import type { AssistantMessage } from '../../types/agent-message.js'; +import type { AgentMessage, AssistantMessage } from '../../types/agent-message.js'; import { decide } from '../provider-router.js'; import { syntheticAssistant } from '../synthetic-assistant.js'; import { emitTurnEndOnce } from '../state-runtime/turn-end.js'; @@ -113,6 +113,7 @@ export async function finalizeAssistantTurn( ports: AssistantStreamingPorts, rec: AssistantStreamingTurnRecord, asst: AssistantMessage, + messages: AgentMessage[], ): Promise { await ports.emitMessageComplete(rec.session_id, asst, rec.assistant_body_streamed === true); @@ -124,7 +125,7 @@ export async function finalizeAssistantTurn( return; } - await ports.persistAssistantIfNew(rec.session_id, asst); + await ports.persistAssistantIfNew(rec.session_id, asst, messages); if (route.kind === 'function_execute') { rec.function_results = []; @@ -156,5 +157,5 @@ export async function runAssistantStreaming( }); } - await finalizeAssistantTurn(ports, rec, asst); + await finalizeAssistantTurn(ports, rec, asst, ctx.messages); } diff --git a/harness/src/turn-orchestrator/function-awaiting-approval/process.ts b/harness/src/turn-orchestrator/function-awaiting-approval/process.ts index 7b61e858e..a71fe2787 100644 --- a/harness/src/turn-orchestrator/function-awaiting-approval/process.ts +++ b/harness/src/turn-orchestrator/function-awaiting-approval/process.ts @@ -17,15 +17,20 @@ import type { TurnStateRecord } from '../state.js'; import { createAwaitingApprovalPorts } from './ports.js'; import { processResolvedApprovals, routeAfterApprovalProcessing } from './run.js'; +/** Enqueue one `turn::function_awaiting_approval` wake on the turn-step queue. */ +async function enqueueAwaitingApprovalWake(iii: ISdk, session_id: string): Promise { + await iii.trigger({ + function_id: 'turn::function_awaiting_approval', + payload: { session_id }, + action: TriggerAction.Enqueue({ queue: TURN_STEP_QUEUE }), + }); +} + export async function handleApprovalStateWrite(iii: ISdk, event: unknown): Promise { const parsed = ApprovalDecisionEventSchema.safeParse(event); if (!parsed.success) return; try { - await iii.trigger({ - function_id: 'turn::function_awaiting_approval', - payload: { session_id: parsed.data.session_id }, - action: TriggerAction.Enqueue({ queue: TURN_STEP_QUEUE }), - }); + await enqueueAwaitingApprovalWake(iii, parsed.data.session_id); } catch (err) { logger.warn('turn::on_approval: wake failed', { session_id: parsed.data.session_id, @@ -38,8 +43,18 @@ export async function handleAwaitingApproval(iii: ISdk, rec: TurnStateRecord): P const batch = parseFunctionBatchRecord(rec); const executePorts = createPorts(iii); const readPorts = createAwaitingApprovalPorts(iii); - await processResolvedApprovals(readPorts, executePorts, batch); + const resolved = await processResolvedApprovals(readPorts, executePorts, batch); await routeAfterApprovalProcessing(executePorts, batch); + + // A wake that resolved at least one call but left siblings parked kicks one + // fresh, uncontended wake. This wake's own re-scan already drains siblings + // whose decision landed mid-wake; the follow-up covers the remaining gap — + // a sibling still pending here whose contender wake was dropped (lost the + // lease race, exhausted retries) would otherwise orphan the batch. A wake + // that resolves nothing enqueues nothing, so this cannot storm. + if (resolved > 0 && batch.awaiting_approval.length > 0) { + await enqueueAwaitingApprovalWake(iii, batch.session_id); + } } export function register(iii: ISdk): void { diff --git a/harness/src/turn-orchestrator/function-awaiting-approval/run.ts b/harness/src/turn-orchestrator/function-awaiting-approval/run.ts index ebd5aabc7..9b15024e3 100644 --- a/harness/src/turn-orchestrator/function-awaiting-approval/run.ts +++ b/harness/src/turn-orchestrator/function-awaiting-approval/run.ts @@ -43,36 +43,56 @@ export function applyDecisionToPrepared( }; } +/** + * Apply every available approval decision to the parked batch, returning how + * many calls this wake executed. + * + * Re-scans until a full pass resolves nothing new: executing an approved call + * can write a sibling's decision as a side effect (a parallel approve-all), and + * that sibling — already read-and-skipped earlier in the same pass — would + * otherwise stay parked until its own wake fires. When that wake was dropped + * (it lost the lease race and exhausted the queue's retries), only a re-scan + * within this wake can drain it. + */ export async function processResolvedApprovals( readPorts: AwaitingApprovalPorts, executePorts: FunctionExecutePorts, rec: FunctionBatchTurnRecord, -): Promise { +): Promise { const work = rec.work; let awaiting = [...rec.awaiting_approval]; const executed = { ...work.executed }; + let resolvedCount = 0; - for (const entry of [...awaiting]) { - const callId = entry.function_call_id; + let resolvedThisPass = true; + while (resolvedThisPass) { + resolvedThisPass = false; - if (executed[callId]) { - awaiting = awaiting.filter((e) => e.function_call_id !== callId); - continue; - } + for (const entry of [...awaiting]) { + const callId = entry.function_call_id; + + if (executed[callId]) { + awaiting = awaiting.filter((e) => e.function_call_id !== callId); + continue; + } - const decision = await readPorts.readDecision(rec.session_id, callId); - if (!decision) continue; + const decision = await readPorts.readDecision(rec.session_id, callId); + if (!decision) continue; - const current = work.prepared.find((p) => p.call.id === callId)!; - const resolved = applyDecisionToPrepared(current, decision); - await runOneCall(executePorts, rec.session_id, resolved, executed, { skipStart: true }); + const current = work.prepared.find((p) => p.call.id === callId)!; + const resolved = applyDecisionToPrepared(current, decision); + await runOneCall(executePorts, rec.session_id, resolved, executed, { skipStart: true }); - awaiting = awaiting.filter((e) => e.function_call_id !== callId); - rec.work = { prepared: work.prepared, executed }; - await executePorts.checkpoint(rec); + awaiting = awaiting.filter((e) => e.function_call_id !== callId); + rec.work = { prepared: work.prepared, executed }; + await executePorts.checkpoint(rec); + resolvedCount += 1; + resolvedThisPass = true; + } } rec.awaiting_approval = awaiting; + return resolvedCount; } export async function routeAfterApprovalProcessing( diff --git a/harness/src/turn-orchestrator/run-start.ts b/harness/src/turn-orchestrator/run-start.ts index 59d9a6212..1746eb98a 100644 --- a/harness/src/turn-orchestrator/run-start.ts +++ b/harness/src/turn-orchestrator/run-start.ts @@ -18,12 +18,17 @@ export async function execute(iii: ISdk, payload: RunStartPayload): Promise { - const alreadyMirrored = parseMirrorLen( - await stateGet(iii, SESSION_TREE_MIRROR_LEN_SCOPE, session_id), - ); - if (messages.length <= alreadyMirrored) return; - - if (alreadyMirrored === 0) { - const ensured = await triggerSessionTree(iii, 'session-tree::ensure', { session_id }); - if (!ensured) return; - } - - let lastAppended: string | null = null; - if (alreadyMirrored > 0) { - const resp = await triggerSessionTree<{ messages?: Array<{ entry_id?: string }> }>( - iii, - 'session-tree::messages', - { session_id }, - ); - if (!resp) return; - const tail = resp.messages?.at(-1); - lastAppended = tail?.entry_id ?? null; - } - - for (const msg of messages.slice(alreadyMirrored)) { - const resp = await triggerSessionTree<{ entry_id?: string }>(iii, 'session-tree::append', { - session_id, - parent_id: lastAppended, - message: msg, - }); - if (!resp) return; - lastAppended = resp.entry_id ?? lastAppended; - } - - await stateSet(iii, SESSION_TREE_MIRROR_LEN_SCOPE, session_id, messages.length); -} - -async function triggerSessionTree( - iii: ISdk, - function_id: string, - payload: Record, -): Promise { - try { - return await iii.trigger({ function_id, payload }); - } catch (err) { - logger.warn(`${function_id} failed; session-tree mirror skipped`, { - session_id: payload.session_id, - err: String(err), - }); - return null; - } -} diff --git a/harness/src/turn-orchestrator/state-runtime/context-view.ts b/harness/src/turn-orchestrator/state-runtime/context-view.ts new file mode 100644 index 000000000..dd50012d3 --- /dev/null +++ b/harness/src/turn-orchestrator/state-runtime/context-view.ts @@ -0,0 +1,72 @@ +/** + * Reconstruct the provider-facing message window from session-tree state. + * + * No compaction: the raw active path. With compaction: the latest summary + * followed by the preserved tail (everything from `tail_start_id` onward). + */ + +import type { CompactionEntryRow } from '../../session/tree/operations.js'; +import type { MessageWithEntryId } from '../../session/tree/types.js'; +import { buildSummaryMessage } from '../../context-compaction/flat-state.js'; +import type { AgentMessage } from '../../types/agent-message.js'; +import type { ISdk } from '../../runtime/iii.js'; + +export type ContextViewCompaction = Pick< + CompactionEntryRow, + 'summary' | 'tail_start_id' | 'timestamp' +>; + +function latestCompaction(compactions: ContextViewCompaction[]): ContextViewCompaction | null { + if (compactions.length === 0) return null; + return [...compactions].sort((a, b) => a.timestamp - b.timestamp).at(-1) ?? null; +} + +/** Pure reconstruction from path-ordered messages and compaction rows. */ +export function buildContextView( + messages: MessageWithEntryId[], + compactions: ContextViewCompaction[], +): AgentMessage[] { + const compaction = latestCompaction(compactions); + if (!compaction) { + return messages.map((m) => m.message); + } + const found = compaction.tail_start_id + ? messages.findIndex((m) => m.entry_id === compaction.tail_start_id) + : 0; + const tailStart = found >= 0 ? found : 0; + return [ + buildSummaryMessage(compaction.summary), + ...messages.slice(tailStart).map((m) => m.message), + ]; +} + +type MessagesResponse = { + messages?: Array<{ entry_id: string; message: AgentMessage }>; +}; + +type CompactionsResponse = { + entries?: CompactionEntryRow[]; +}; + +export async function loadContextView(iii: ISdk, session_id: string): Promise { + const [messagesResp, compactionsResp] = await Promise.all([ + iii.trigger({ function_id: 'session-tree::messages', payload: { session_id } }), + iii.trigger({ function_id: 'session-tree::compactions', payload: { session_id } }), + ]); + + const messagesPayload = messagesResp as MessagesResponse | null; + const compactionsPayload = compactionsResp as CompactionsResponse | null; + + const entries: MessageWithEntryId[] = (messagesPayload?.messages ?? []).map((e) => ({ + entry_id: e.entry_id, + message: e.message, + })); + + const compactions: ContextViewCompaction[] = (compactionsPayload?.entries ?? []).map((c) => ({ + summary: c.summary, + tail_start_id: c.tail_start_id, + timestamp: c.timestamp, + })); + + return buildContextView(entries, compactions); +} diff --git a/harness/src/turn-orchestrator/state-runtime/ports.ts b/harness/src/turn-orchestrator/state-runtime/ports.ts index 3b47bab29..c7c117ae2 100644 --- a/harness/src/turn-orchestrator/state-runtime/ports.ts +++ b/harness/src/turn-orchestrator/state-runtime/ports.ts @@ -52,8 +52,11 @@ export function createTurnStatePorts(iii: ISdk, store?: TurnStore): TurnStatePor }, async finishSession(rec) { - const messages = await s.loadMessages(rec.session_id); - await emit(iii, rec.session_id, { type: 'agent_end', messages }); + // agent_end is a turn-end SIGNAL only. The transcript reaches the UI + // incrementally via message_update/message_complete and is re-read from + // session-tree on reload, so no consumer reads agent_end.messages. Emit it + // empty instead of reloading the whole session to fill an unused field. + await emit(iii, rec.session_id, { type: 'agent_end', messages: [] }); transitionTo(rec, 'stopped'); }, }; diff --git a/harness/src/turn-orchestrator/state-runtime/store.ts b/harness/src/turn-orchestrator/state-runtime/store.ts index c5834276a..c135249d8 100644 --- a/harness/src/turn-orchestrator/state-runtime/store.ts +++ b/harness/src/turn-orchestrator/state-runtime/store.ts @@ -3,17 +3,16 @@ * through `createTurnStore`. */ -import { z } from 'zod'; import { TriggerAction, type ISdk } from '../../runtime/iii.js'; import { stateGet, stateSet } from '../../runtime/state.js'; import { logger } from '../../runtime/otel.js'; import type { AgentMessage } from '../../types/agent-message.js'; -import { MESSAGES_SCOPE, RUN_REQUEST_SCOPE, TURN_STATE_SCOPE } from '../state.js'; +import { RUN_REQUEST_SCOPE, TURN_STATE_SCOPE } from '../state.js'; import { emit } from '../events.js'; import { type RunRequest, parseRunRequest } from '../run-request.js'; import { toView, type TurnStateView } from '../schemas.js'; -import { mirrorMessagesToSessionTree } from '../session-tree-mirror.js'; import { type TurnState, type TurnStateRecord, parseTurnStateRecord } from '../state.js'; +import { loadContextView } from './context-view.js'; /** * Turn-step wakes go to the engine's `default` queue. NOTE: engine.config.yaml @@ -49,27 +48,32 @@ export type TurnStore = { loadRecord(session_id: string): Promise; saveRecord(rec: TurnStateRecord, previous?: TurnStateRecord | null): Promise; writeRecord(rec: TurnStateRecord): Promise; + ensureSession(session_id: string): Promise; loadMessages(session_id: string): Promise; - saveMessages(session_id: string, messages: AgentMessage[]): Promise; appendMessages(session_id: string, msgs: AgentMessage[]): Promise; loadRunRequest(session_id: string): Promise; saveRunRequest(session_id: string, request: RunRequest): Promise; }; -const FlatMessagesSchema = z - .array(z.custom((v) => v != null && typeof v === 'object')) - .catch([]); - -/** @internal Exported for unit tests. */ -export function parseFlatMessages(raw: unknown): AgentMessage[] { - return FlatMessagesSchema.parse(raw ?? []); -} - const scopedGet = (iii: ISdk, scope: string, session_id: string) => stateGet(iii, scope, session_id); const scopedSet = (iii: ISdk, scope: string, session_id: string, value: unknown) => stateSet(iii, scope, session_id, value); +/** + * Create the session-tree record if absent. Idempotent, but invoked exactly + * once per run (at `run::start`) rather than wrapping every read/write — the + * `run::start` gateway always precedes any turn-store load/append for a session, + * so re-ensuring on each call was pure RPC overhead. + */ +async function ensureSessionTree(iii: ISdk, session_id: string): Promise { + await iii.trigger({ + function_id: 'session-tree::ensure', + payload: { session_id }, + timeoutMs: 10_000, + }); +} + async function emitTurnStateChanged( iii: ISdk, session_id: string, @@ -135,19 +139,22 @@ export function createTurnStore(iii: ISdk): TurnStore { } }, - async loadMessages(session_id) { - return parseFlatMessages(await scopedGet(iii, MESSAGES_SCOPE, session_id)); + async ensureSession(session_id) { + await ensureSessionTree(iii, session_id); }, - async saveMessages(session_id, messages) { - await scopedSet(iii, MESSAGES_SCOPE, session_id, messages); - await mirrorMessagesToSessionTree(iii, session_id, messages); + async loadMessages(session_id) { + return loadContextView(iii, session_id); }, async appendMessages(session_id, msgs) { - const messages = parseFlatMessages(await scopedGet(iii, MESSAGES_SCOPE, session_id)); - await scopedSet(iii, MESSAGES_SCOPE, session_id, [...messages, ...msgs]); - await mirrorMessagesToSessionTree(iii, session_id, [...messages, ...msgs]); + for (const message of msgs) { + await iii.trigger({ + function_id: 'session-tree::append', + payload: { session_id, message, parent_id: null }, + timeoutMs: 10_000, + }); + } }, async saveRunRequest(session_id, request) { diff --git a/harness/src/turn-orchestrator/state.ts b/harness/src/turn-orchestrator/state.ts index f8fc69956..32785f870 100644 --- a/harness/src/turn-orchestrator/state.ts +++ b/harness/src/turn-orchestrator/state.ts @@ -1,7 +1,8 @@ /** * TurnState + TurnStateRecord types and parsers. * - * Persistence uses semantic iii scopes (`turn_state`, `messages`, `run_request`, …) + * Persistence uses semantic iii scopes (`turn_state`, `run_request`, …). Conversation + * history lives in `session-tree::*` and is reconstructed at read time. * keyed by `session_id`. Recovery lists scope `turn_state` via {@link parseTurnStateRecord}. */ @@ -11,7 +12,6 @@ import type { ExecutedCall, FunctionBatchWork, PreparedCall } from './function-e /** Shared iii scope names for turn-orchestrator persistence (key = session_id). */ export const TURN_STATE_SCOPE = 'turn_state'; -export const MESSAGES_SCOPE = 'messages'; export const RUN_REQUEST_SCOPE = 'run_request'; export type TurnState = diff --git a/harness/src/turn-orchestrator/steering-check/ports.ts b/harness/src/turn-orchestrator/steering-check/ports.ts index a4bca3f77..fe6f42792 100644 --- a/harness/src/turn-orchestrator/steering-check/ports.ts +++ b/harness/src/turn-orchestrator/steering-check/ports.ts @@ -4,20 +4,10 @@ import type { ISdk } from '../../runtime/iii.js'; import type { AgentEvent } from '../../types/agent-event.js'; -import type { AgentMessage } from '../../types/agent-message.js'; import { emit } from '../events.js'; import { createTurnStatePorts, type TurnStatePorts } from '../state-runtime/ports.js'; -/** Decode session-inbox drain responses. */ -export function parseDrainItems(resp: unknown): AgentMessage[] { - if (resp && typeof resp === 'object' && Array.isArray((resp as { items?: unknown }).items)) { - return (resp as { items: AgentMessage[] }).items; - } - return []; -} - export type SteeringCheckPorts = TurnStatePorts & { - drainInbox(name: 'steering' | 'followup', session_id: string): Promise; emit(session_id: string, event: AgentEvent): Promise; }; @@ -27,18 +17,6 @@ export function createSteeringCheckPorts(iii: ISdk): SteeringCheckPorts { return { ...base, - async drainInbox(name, session_id) { - try { - const resp = await iii.trigger({ - function_id: 'session-inbox::drain', - payload: { name, session_id }, - }); - return parseDrainItems(resp); - } catch { - return []; - } - }, - emit(session_id, event) { return emit(iii, session_id, event); }, diff --git a/harness/src/turn-orchestrator/steering-check/process.ts b/harness/src/turn-orchestrator/steering-check/process.ts index e46243144..83c1d1333 100644 --- a/harness/src/turn-orchestrator/steering-check/process.ts +++ b/harness/src/turn-orchestrator/steering-check/process.ts @@ -1,5 +1,5 @@ /** - * Drain inboxes, route, apply steering_check outcomes, and register the FSM step. + * Route steering_check outcomes, apply transitions, and register the FSM step. */ import type { ISdk } from '../../runtime/iii.js'; @@ -28,7 +28,7 @@ export function register(iii: ISdk): void { }, { description: - 'Run one durable FSM transition for session in state steering_check: drain inboxes and route onward.', + 'Run one durable FSM transition for session in state steering_check: continue after tool results or end the turn.', }, ); } diff --git a/harness/src/turn-orchestrator/steering-check/run.ts b/harness/src/turn-orchestrator/steering-check/run.ts index e7e42ee31..8763ec12f 100644 --- a/harness/src/turn-orchestrator/steering-check/run.ts +++ b/harness/src/turn-orchestrator/steering-check/run.ts @@ -1,30 +1,21 @@ /** - * Drain inboxes, route steering_check outcomes, and apply transitions. + * Route steering_check outcomes and apply transitions. */ -import type { AgentMessage } from '../../types/agent-message.js'; import { syntheticAssistant } from '../synthetic-assistant.js'; import { emitTurnEndOnce, resumeToAssistantStreaming } from '../state-runtime/turn-end.js'; import type { SteeringCheckTurnRecord } from '../state.js'; import type { SteeringCheckPorts } from './ports.js'; -export type SteeringRoute = 'steering' | 'followup' | 'continue_after_function' | 'end_turn'; +export type SteeringRoute = 'continue_after_function' | 'end_turn'; export type SteeringCheckOutcome = | { kind: 'max_turns_reached' } - | { kind: 'resume_with_inbox'; inbox: AgentMessage[] } | { kind: 'continue_after_function' } | { kind: 'end_turn' }; -export function route( - has_steering: boolean, - has_followup: boolean, - has_function_results: boolean, -): SteeringRoute { - if (has_steering) return 'steering'; - if (has_followup) return 'followup'; - if (has_function_results) return 'continue_after_function'; - return 'end_turn'; +export function route(has_function_results: boolean): SteeringRoute { + return has_function_results ? 'continue_after_function' : 'end_turn'; } function maxTurnsReached(rec: SteeringCheckTurnRecord): boolean { @@ -51,28 +42,16 @@ async function endForMaxTurns( } export async function processSteeringCheck( - ports: SteeringCheckPorts, + _ports: SteeringCheckPorts, rec: SteeringCheckTurnRecord, ): Promise { - const steering = await ports.drainInbox('steering', rec.session_id); - const followup = steering.length > 0 ? [] : await ports.drainInbox('followup', rec.session_id); - - const decision = route(steering.length > 0, followup.length > 0, rec.function_results.length > 0); + const decision = route(rec.function_results.length > 0); - if ( - (decision === 'steering' || - decision === 'followup' || - decision === 'continue_after_function') && - maxTurnsReached(rec) - ) { + if (decision === 'continue_after_function' && maxTurnsReached(rec)) { return { kind: 'max_turns_reached' }; } switch (decision) { - case 'steering': - return { kind: 'resume_with_inbox', inbox: steering }; - case 'followup': - return { kind: 'resume_with_inbox', inbox: followup }; case 'continue_after_function': return { kind: 'continue_after_function' }; case 'end_turn': @@ -89,12 +68,6 @@ export async function applySteeringCheckOutcome( case 'max_turns_reached': await endForMaxTurns(ports, rec); return; - case 'resume_with_inbox': { - await emitTurnEndOnce(ports, rec); - await ports.appendMessages(rec.session_id, outcome.inbox); - resumeToAssistantStreaming(rec); - return; - } case 'continue_after_function': resumeToAssistantStreaming(rec); return; diff --git a/harness/tests/context-compaction/compaction-done-emit.test.ts b/harness/tests/context-compaction/compaction-done-emit.test.ts index 720bafbf6..499f6c75f 100644 --- a/harness/tests/context-compaction/compaction-done-emit.test.ts +++ b/harness/tests/context-compaction/compaction-done-emit.test.ts @@ -1,10 +1,10 @@ /** * Asserts that both the sync (handleSync) and async (handleAsync) compaction * paths publish a `compaction_done` AgentEvent on `agent::events` after a - * successful flat-state rewrite. The UI consumes this event to drop the + * successful tree compaction. The UI consumes this event to drop the * post-compaction CTX bar to its correct value. * - * Strategy: vi.mock the summarize + flat-state + prune + replay helpers so + * Strategy: vi.mock the summarize + prune + replay helpers so * the handler reaches the emit call deterministically without needing a * real provider stream. We capture stream::set payloads via a stub ISdk. */ @@ -23,22 +23,12 @@ vi.mock('../../src/context-compaction/summarize.js', () => ({ })), })); -vi.mock('../../src/context-compaction/flat-state.js', () => ({ - buildSummaryMessage: (text: string) => ({ - role: 'system', - kind: 'compaction', - content: [{ type: 'text', text }], - }), - rewriteFlatMessages: vi.fn(async () => undefined), -})); - vi.mock('../../src/context-compaction/prune.js', () => ({ prune: vi.fn(async () => ({ pruned_tokens: 0, pruned_parts: 0, scanned_parts: 0 })), })); vi.mock('../../src/context-compaction/replay.js', () => ({ extractReplayTarget: () => ({ replay: null, truncatedMessages: [] }), - reinjectReplay: vi.fn(async () => 'entry-replay'), })); vi.mock('../../src/context-compaction/model-resolver.js', () => ({ @@ -139,7 +129,7 @@ describe('handleSync emits compaction_done', () => { delete process.env.COMPACT_BUSY_TIMEOUT_MS; }); - it('publishes a compaction_done event on agent::events with mode="sync" after a successful rewrite', async () => { + it('publishes a compaction_done event on agent::events with mode="sync" after successful compaction', async () => { const { handleSync } = await import('../../src/context-compaction/handler-sync.js'); const { iii, streamSetCalls } = makeStubIii(); diff --git a/harness/tests/context-compaction/e2e/full-session.test.ts b/harness/tests/context-compaction/e2e/full-session.test.ts index 5a65f1077..d35a352be 100644 --- a/harness/tests/context-compaction/e2e/full-session.test.ts +++ b/harness/tests/context-compaction/e2e/full-session.test.ts @@ -6,23 +6,27 @@ * up to an InMemoryStore. Verifies the three structural guarantees that * the unit/integration tests cannot observe in isolation: * - * 1. The flat state at scope `messages`, key `` is - * rewritten to a reduced array: [summary-as-asst-msg, ...tail, replay]. + * 1. The reconstructed provider window (from session-tree + compactions) + * is reduced to: [summary-as-asst-msg, ...tail, continue-nudge]. * 2. The session tree's active path stays connected — the Compaction - * entry, replayed user message, and synthetic continue-prompt are - * chained via parent_id back to the pre-compaction tail. + * entry and continue-prompt are chained via parent_id back to the + * pre-compaction tail. * 3. The downstream provider input (what would land in `buildInput`) * no longer exceeds the model's usable token budget. */ import { describe, expect, it, vi } from 'vitest'; -import { payloadStoreKey, stateStoreKey } from '../../_helpers/stateStoreKey.js'; +import { payloadStoreKey } from '../../_helpers/stateStoreKey.js'; import { handleSync } from '../../../src/context-compaction/handler-sync.js'; import type { ISdk } from '../../../src/runtime/iii.js'; -import { MESSAGES_SCOPE } from '../../../src/turn-orchestrator/state.js'; +import { + compactionEntries, + loadMessagesWithEntryIds, +} from '../../../src/session/tree/operations.js'; import { registerTree } from '../../../src/session/tree/register.js'; import { InMemoryStore } from '../../../src/session/tree/store.js'; import type { SessionEntry } from '../../../src/session/tree/types.js'; +import { buildContextView } from '../../../src/turn-orchestrator/state-runtime/context-view.js'; import { runPreflight } from '../../../src/turn-orchestrator/preflight.js'; import type { AgentMessage } from '../../../src/types/agent-message.js'; @@ -67,17 +71,24 @@ function buildOverflowingMessages(turns: number): AgentMessage[] { return out; } +async function loadProviderWindow( + store: InMemoryStore, + session_id: string, +): Promise { + const messages = await loadMessagesWithEntryIds(store, session_id); + const compactions = await compactionEntries(store, session_id); + return buildContextView(messages, compactions); +} + // --------------------------------------------------------------------------- // Test ISdk: wires the real session-tree handlers to an InMemoryStore, // plus a stub `models::get`, a stub provider stream (returns a known -// summary), and an in-memory `state::*` so leases and the flat-state -// rewrite both work without touching the network. +// summary), and an in-memory `state::*` so leases work without the network. // --------------------------------------------------------------------------- type FunctionHandler = (payload: unknown) => Promise; function buildTestSdk(opts: { - flatMessages: AgentMessage[]; summaryText: string; /** Capture every provider stream invocation's `messages` payload. */ providerInvocations: AgentMessage[][]; @@ -87,9 +98,6 @@ function buildTestSdk(opts: { const handlers = new Map(); const store = new InMemoryStore(); - // Pre-seed flat state with the overflowing transcript. - stateStore.set(stateStoreKey(MESSAGES_SCOPE, opts.session_id), opts.flatMessages); - // Stub channel writer so streamAndCollect can deliver a synthetic done event. let channelCb: ((raw: string) => void) | null = null; const channel = { @@ -106,7 +114,7 @@ function buildTestSdk(opts: { const fn = req.function_id; const payload = req.payload; - // 1) state::* — back the lease / flat-state rewrite with stateStore. + // 1) state::* — back the lease with stateStore. if (fn === 'state::get') { const p = (payload ?? {}) as { scope: string; key: string }; const v = stateStore.get(payloadStoreKey(p)); @@ -254,36 +262,34 @@ async function seedSessionTreeFrom( // --------------------------------------------------------------------------- describe('e2e full-session compaction', () => { - it('preflight + sync compaction reduces flat state AND keeps tree connected', async () => { + it('preflight + sync compaction reduces provider window AND keeps tree connected', async () => { const SUMMARY = 'EARLIER TURNS SUMMARY: discussed lorem and friends.'; const overflowing = buildOverflowingMessages(30); const providerInvocations: AgentMessage[][] = []; - const { iii, store, stateStore } = buildTestSdk({ + const { iii, store } = buildTestSdk({ session_id: SESSION_ID, - flatMessages: overflowing, summaryText: SUMMARY, providerInvocations, }); - // Seed the session tree with the same transcript so replay extraction works. + // Seed the session tree with the transcript so replay extraction works. const entryIds = await seedSessionTreeFrom(iii, SESSION_ID, overflowing); const lastUserId = entryIds[entryIds.length - 1] ?? ''; // the final user msg - // Sanity check: the pre-compaction flat state matches the seed. - const beforeFlat = stateStore.get(stateStoreKey(MESSAGES_SCOPE, SESSION_ID)) as AgentMessage[]; - expect(beforeFlat.length).toBe(overflowing.length); + const beforeView = await loadProviderWindow(store, SESSION_ID); + expect(beforeView.length).toBe(overflowing.length); // Run preflight. The 30-turn fixture should overflow the 8k usable budget. const result = await runPreflight(iii, SESSION_ID, overflowing, PROVIDER_ID, MODEL_ID); expect(result).toBe('compacted'); - // --- Assertion 1: flat state is reduced and shaped correctly. --- - const afterFlat = stateStore.get(stateStoreKey(MESSAGES_SCOPE, SESSION_ID)) as AgentMessage[]; - expect(afterFlat.length).toBeLessThan(overflowing.length); + // --- Assertion 1: reconstructed window is reduced and shaped correctly. --- + const afterView = await loadProviderWindow(store, SESSION_ID); + expect(afterView.length).toBeLessThan(overflowing.length); // First message must be the summary-as-assistant-msg containing SUMMARY. - const first = afterFlat[0]; + const first = afterView[0]; expect(first?.role).toBe('assistant'); if (first?.role === 'assistant') { const text = first.content[0]; @@ -291,13 +297,21 @@ describe('e2e full-session compaction', () => { if (text?.type === 'text') expect(text.text).toContain(SUMMARY); } - // Last message is the replay (the final user message that triggered the turn). - const last = afterFlat[afterFlat.length - 1]; + // The window keeps the final user message (it stays on the path as the + // compaction's parent) and now ends with the continue nudge. + const finalUserInView = afterView.some( + (m) => + m.role === 'user' && + m.content[0]?.type === 'text' && + m.content[0].text.includes('final user message'), + ); + expect(finalUserInView).toBe(true); + const last = afterView[afterView.length - 1]; expect(last?.role).toBe('user'); if (last?.role === 'user') { const text = last.content[0]; if (text?.type === 'text') { - expect(text.text).toContain('final user message'); + expect(text.text).toMatch(/Continue if you have next steps/); } } @@ -308,7 +322,7 @@ describe('e2e full-session compaction', () => { const entries = (await store.loadEntries(SESSION_ID)) as SessionEntry[]; const byId = new Map(entries.map((e) => [e.id, e] as const)); - // The just-appended synthetic should be the active leaf. + // The just-appended continue nudge should be the active leaf. const leafId = path[path.length - 1]; const leaf = leafId ? byId.get(leafId) : undefined; expect(leaf?.type).toBe('message'); @@ -327,8 +341,8 @@ describe('e2e full-session compaction', () => { const hasCompactionOnPath = path.some((id) => byId.get(id)?.type === 'compaction'); expect(hasCompactionOnPath).toBe(true); - // The replay-user-msg must appear on the active path (it was reinjected - // with parent_id chained off the compaction entry). + // The final user message remains on the active path as the compaction's + // parent (no reinjection needed in the tree-only model). const userReplayOnPath = path .map((id) => byId.get(id)) .filter((e): e is Extract => e?.type === 'message') @@ -352,9 +366,8 @@ describe('e2e full-session compaction', () => { const tinyMessages: AgentMessage[] = [userMsg('hi'), asstMsg('hello'), userMsg('what is 2+2?')]; const providerInvocations: AgentMessage[][] = []; const SID = `${SESSION_ID}-small`; - const { iii, stateStore } = buildTestSdk({ + const { iii, store } = buildTestSdk({ session_id: SID, - flatMessages: tinyMessages, summaryText: SUMMARY, providerInvocations, }); @@ -373,8 +386,8 @@ describe('e2e full-session compaction', () => { else process.env.COMPACT_RESERVED_TOKENS = prev; } - // Flat state is untouched. - const after = stateStore.get(stateStoreKey(MESSAGES_SCOPE, SID)) as AgentMessage[]; + // Provider window is untouched (no compaction entry). + const after = await loadProviderWindow(store, SID); expect(after.length).toBe(tinyMessages.length); // Summariser was never invoked. diff --git a/harness/tests/context-compaction/integration/flow-sync.test.ts b/harness/tests/context-compaction/integration/flow-sync.test.ts index 56f9cb066..d11333a4c 100644 --- a/harness/tests/context-compaction/integration/flow-sync.test.ts +++ b/harness/tests/context-compaction/integration/flow-sync.test.ts @@ -2,8 +2,9 @@ * Integration tests: sync (pre-turn) compaction flow via handleSync. * * Tests: - * 1. Success path: status === 'ok', session-tree::append called for replay, - * session-tree::append_synthetic called with metadata.compaction_continue === true. + * 1. Success path: status === 'ok', no replay reinjection (the last user + * message stays on the path), session-tree::append_synthetic posts the + * continue nudge. * 2. Summariser stream returns error → status === 'overflow'. * 3. Lease held → status === 'busy'. */ @@ -150,7 +151,7 @@ function buildSyncMock(opts: { const mediumFixture = loadFixture('medium-with-tools'); describe('flow-sync: success path', () => { - it('returns ok, calls append for replay, and append_synthetic with compaction_continue', async () => { + it('returns ok and appends the continue nudge (no replay reinjection)', async () => { const fixtureMessages = mediumFixture.entries.map((e) => ({ entry_id: e.id, message: e.message, @@ -175,19 +176,15 @@ describe('flow-sync: success path', () => { expect(result.status).toBe('ok'); - // session-tree::append should have been called for replay reinsertion - expect(appendCalls).toHaveLength(1); - const appendPayload = appendCalls[0] as Record; - expect(appendPayload.session_id).toBe(mediumFixture.session_id); - expect(appendPayload.message).toBeDefined(); + // The last user message is already on the path as the compaction's + // parent; we no longer reinject it via session-tree::append. + expect(appendCalls).toHaveLength(0); - // session-tree::append_synthetic should have been called with compaction_continue: true + // append_synthetic posts the continue nudge as a child of the compaction. expect(appendSyntheticCalls).toHaveLength(1); const syntheticPayload = appendSyntheticCalls[0] as Record; expect(syntheticPayload.session_id).toBe(mediumFixture.session_id); - const metadata = syntheticPayload.metadata as Record; - expect(metadata).toBeDefined(); - expect(metadata.compaction_continue).toBe(true); + expect(String(syntheticPayload.text)).toContain('Continue'); }); }); diff --git a/harness/tests/context-compaction/replay.test.ts b/harness/tests/context-compaction/replay.test.ts index 160fc7240..0978cb018 100644 --- a/harness/tests/context-compaction/replay.test.ts +++ b/harness/tests/context-compaction/replay.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it, vi } from 'vitest'; -import { extractReplayTarget, reinjectReplay } from '../../src/context-compaction/replay.js'; +import { describe, expect, it } from 'vitest'; +import { extractReplayTarget } from '../../src/context-compaction/replay.js'; import type { AgentMessage } from '../../src/types/agent-message.js'; const user = (text = 'q'): { entry_id: string; message: AgentMessage } => ({ @@ -37,19 +37,3 @@ describe('extractReplayTarget', () => { expect(replay).toBeUndefined(); }); }); - -describe('reinjectReplay', () => { - it('writes the replay user message back via session-tree::append', async () => { - const trigger = vi.fn(async () => ({ entry_id: 'new' })); - const iii = { trigger } as unknown as Parameters[0]; - await reinjectReplay(iii, 'sid', user('q2')); - expect(trigger).toHaveBeenCalledTimes(1); - const call = trigger.mock.calls[0]![0] as { - function_id: string; - payload: { session_id: string; message: { role: string } }; - }; - expect(call.function_id).toBe('session-tree::append'); - expect(call.payload.session_id).toBe('sid'); - expect(call.payload.message.role).toBe('user'); - }); -}); diff --git a/harness/tests/harness/fanout/sessions-poll.test.ts b/harness/tests/harness/fanout/sessions-poll.test.ts deleted file mode 100644 index 08ecf3673..000000000 --- a/harness/tests/harness/fanout/sessions-poll.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { spawnSessionsPoll } from '../../../src/harness/fanout/sessions-poll.js'; -import { FanoutState } from '../../../src/harness/ui-subscribe.js'; -import { TURN_STATE_SCOPE } from '../../../src/turn-orchestrator/state.js'; -import type { ISdk } from '../../../src/runtime/iii.js'; - -type Handler = (event: unknown) => Promise; - -// Session-create fanout watches scope `turn_state`. The state trigger has NO -// condition_function_id, so the engine hands every write on that scope to the -// handler — the handler is the sole gate. These tests hammer that gate and -// the registration shape. -function setup(subscribers: string[] = []) { - const handlers = new Map(); - const triggers: Array<{ type?: string; function_id?: string; config?: Record }> = - []; - const sent: Array<{ function_id: string; payload: unknown }> = []; - const iii = { - registerFunction: vi.fn((id: string, h: Handler) => { - handlers.set(id, h); - return { unregister() {} }; - }), - registerTrigger: vi.fn((t) => { - triggers.push(t); - return { unregister() {} }; - }), - trigger: vi.fn(async (req: { function_id: string; payload: unknown }) => { - sent.push(req); - return null; - }), - } as unknown as ISdk; - - const state = new FanoutState(); - for (const b of subscribers) state.subscribe(b, null); - spawnSessionsPoll(iii, state); - return { handlers, triggers, sent }; -} - -const createEvent = (over: Record = {}) => ({ - event_type: 'state:created' as const, - scope: TURN_STATE_SCOPE, - key: 'sess-1', - old_value: null, - new_value: { session_id: 'sess-1', state: 'provisioning' }, - message_type: 'state', - ...over, -}); - -function changedCalls(sent: Array<{ function_id: string; payload: unknown }>) { - return sent.filter((s) => s.function_id.startsWith('ui::sessions::changed::')); -} - -describe('spawnSessionsPoll registration (eliminates the per-write predicate RPC)', () => { - it('registers a scope-only turn_state trigger with NO condition_function_id and no predicate fn', () => { - const { handlers, triggers } = setup(); - - expect([...handlers.keys()]).not.toContain('harness::session::is_create_event'); - expect([...handlers.keys()]).toContain('harness::fanout::session_created'); - - const t = triggers.find((x) => x.function_id === 'harness::fanout::session_created'); - expect(t?.type).toBe('state'); - expect(t?.config?.scope).toBe(TURN_STATE_SCOPE); - expect(t?.config?.condition_function_id).toBeUndefined(); - }); -}); - -describe('session_created handler (sole gate)', () => { - it('fans out the new session id to every all-sessions subscriber', async () => { - const { handlers, sent } = setup(['b1', 'b2']); - const handler = handlers.get('harness::fanout::session_created'); - - await handler?.(createEvent({ key: 'sess-1' })); - - const changed = changedCalls(sent); - expect(changed.map((c) => c.function_id).sort()).toEqual([ - 'ui::sessions::changed::b1', - 'ui::sessions::changed::b2', - ]); - expect(changed[0]?.payload).toEqual({ added: ['sess-1'], removed: [] }); - }); - - it.each([ - ['state:updated (not a new session)', { event_type: 'state:updated' }], - // The dangerous one: a delete must NOT report the session as "added". - ['state:deleted (removed session)', { event_type: 'state:deleted', new_value: null }], - ['empty key', { key: '' }], - ])('does NOT fan out on %s', async (_label, over) => { - const { handlers, sent } = setup(['b1']); - const handler = handlers.get('harness::fanout::session_created'); - - await handler?.(createEvent(over)); - - expect(changedCalls(sent)).toHaveLength(0); - }); -}); diff --git a/harness/tests/harness/ui-subscribe.test.ts b/harness/tests/harness/ui-subscribe.test.ts index 7304548d9..628a2e7d1 100644 --- a/harness/tests/harness/ui-subscribe.test.ts +++ b/harness/tests/harness/ui-subscribe.test.ts @@ -2,54 +2,20 @@ import { describe, expect, it } from 'vitest'; import { FanoutState } from '../../src/harness/ui-subscribe.js'; describe('FanoutState', () => { - it('subscribes per-browser to specific session', () => { + it('tracks model subscribers', () => { const s = new FanoutState(); - s.subscribe('b1', 's1'); - expect(s.subscribersFor('s1')).toEqual(['b1']); - expect(s.subscribersFor('other')).toEqual([]); - }); - - it('null session subscribes to all sessions', () => { - const s = new FanoutState(); - s.subscribe('b1', null); - expect(s.subscribersFor('any-session')).toContain('b1'); - expect(s.allSubscribers()).toEqual(['b1']); - }); - - it('unsubscribe removes the targeted entry', () => { - const s = new FanoutState(); - s.subscribe('b1', 's1'); - s.subscribe('b1', 's2'); - s.unsubscribe('b1', 's1'); - expect(s.subscribersFor('s1')).toEqual([]); - expect(s.subscribersFor('s2')).toEqual(['b1']); - }); - - it('evicts a browser entirely', () => { - const s = new FanoutState(); - s.subscribe('b1', null); - s.evictBrowser('b1'); - expect(s.browserCount()).toBe(0); - }); - - it('tracks model subscribers separately from session subs', () => { - const s = new FanoutState(); - s.subscribe('b1', 's1'); s.subscribeModels('b1'); expect(s.modelSubscribers()).toEqual(['b1']); - // Dropping the session sub must not tear down the model subscription — - // this isolation is what keeps the agent-events pump from evicting a - // model-only subscriber. - s.unsubscribe('b1', 's1'); - expect(s.modelSubscribers()).toEqual(['b1']); s.unsubscribeModels('b1'); expect(s.modelSubscribers()).toEqual([]); }); - it('evicting a browser also drops its model subscription', () => { + it('tracks multiple model subscribers independently', () => { const s = new FanoutState(); s.subscribeModels('b1'); - s.evictBrowser('b1'); - expect(s.modelSubscribers()).toEqual([]); + s.subscribeModels('b2'); + expect(s.modelSubscribers().sort()).toEqual(['b1', 'b2']); + s.unsubscribeModels('b1'); + expect(s.modelSubscribers()).toEqual(['b2']); }); }); diff --git a/harness/tests/integration/parallel-approval.e2e.test.ts b/harness/tests/integration/parallel-approval.e2e.test.ts index c82f9777f..395f95b62 100644 --- a/harness/tests/integration/parallel-approval.e2e.test.ts +++ b/harness/tests/integration/parallel-approval.e2e.test.ts @@ -10,6 +10,24 @@ afterEach(() => { vi.restoreAllMocks(); }); +/** Count `turn::function_awaiting_approval` re-wakes enqueued for a session. */ +function wakeEnqueues( + h: ReturnType, + sessionId: string, +): number { + const trigger = h.iii.trigger as unknown as { + mock: { + calls: Array<[{ function_id?: string; payload?: { session_id?: string }; action?: unknown }]>; + }; + }; + return trigger.mock.calls.filter( + ([arg]) => + arg?.function_id === 'turn::function_awaiting_approval' && + arg?.payload?.session_id === sessionId && + arg?.action != null, + ).length; +} + describe('parallel approval e2e', () => { it('dispatches later calls while earlier ones park without blocking the batch', async () => { const h = createParallelApprovalHarness(); @@ -189,6 +207,82 @@ describe('parallel approval e2e', () => { expect(turnEnds).toHaveLength(1); }); + it('drains a sibling approved mid-execution even when its own wake is dropped', async () => { + const h = createParallelApprovalHarness(); + // Both calls park for approval during execute. + vi.spyOn(agentTriggerModule, 'dispatchWithHook') + .mockResolvedValueOnce({ kind: 'pending' }) // execute fc-late → parks + .mockResolvedValueOnce({ kind: 'pending' }); // execute fc-driver → parks + // Approved calls execute via triggerPreApproved → triggerFunctionCall. + // Executing fc-driver approves fc-late as a side effect but writes the + // decision WITHOUT firing fc-late's wake — modeling a sibling approval whose + // own turn-step wake was dropped (retries exhausted against the lease the + // driving wake holds). fc-late was read first and skipped, so only a re-scan + // within the same wake can pick it up. + vi.spyOn(agentTriggerModule, 'triggerFunctionCall').mockImplementation(async (_iii, call) => { + if (call.id === 'fc-driver') { + h.stateStore.set('approvals/sess-drain/fc-late', { + decision: 'allow', + reason: null, + }); + } + return { + content: [{ type: 'text' as const, text: `${call.id}-ok` }], + details: {}, + terminate: false, + }; + }); + + h.seedExecute( + 'sess-drain', + makeAssistantWithCalls([ + { id: 'fc-late', functionId: 'shell::run' }, + { id: 'fc-driver', functionId: 'shell::run' }, + ]), + ); + await h.runExecute('sess-drain'); + expect( + h.loadTurnRecord('sess-drain')?.awaiting_approval?.map((e) => e.function_call_id), + ).toEqual(['fc-late', 'fc-driver']); + + await h.resolveApproval('sess-drain', 'fc-driver', 'allow'); + + // fc-late is drained in the same wake instead of being orphaned: it runs to + // completion and the batch finalizes (work cleared, state advances) rather + // than staying parked on fc-late forever. + const rec = h.loadTurnRecord('sess-drain'); + expect(executionEvents(h.emitted, 'function_execution_end', 'fc-driver')).toHaveLength(1); + expect(executionEvents(h.emitted, 'function_execution_end', 'fc-late')).toHaveLength(1); + expect(rec?.awaiting_approval).toEqual([]); + expect(rec?.state).toBe('steering_check'); + }); + + it('re-enqueues a follow-up wake when a resolved call leaves siblings pending', async () => { + const h = createParallelApprovalHarness(); + vi.spyOn(agentTriggerModule, 'dispatchWithHook') + .mockResolvedValueOnce({ kind: 'pending' }) + .mockResolvedValueOnce({ kind: 'pending' }); + + h.seedExecute( + 'sess-reenqueue', + makeAssistantWithCalls([ + { id: 'fc-1', functionId: 'shell::run' }, + { id: 'fc-2', functionId: 'shell::run' }, + ]), + ); + await h.runExecute('sess-reenqueue'); + + const before = wakeEnqueues(h, 'sess-reenqueue'); + await h.resolveApproval('sess-reenqueue', 'fc-1', 'allow'); + + const rec = h.loadTurnRecord('sess-reenqueue'); + expect(rec?.awaiting_approval?.map((e) => e.function_call_id)).toEqual(['fc-2']); + // The wake that resolved fc-1 must kick a fresh, uncontended wake so a + // dropped fc-2 contender can't orphan it: the approval::resolve write + // accounts for one wake, the follow-up kick for a second. + expect(wakeEnqueues(h, 'sess-reenqueue')).toBeGreaterThanOrEqual(before + 2); + }); + it('persists the decision and wakes function_awaiting_approval via approval::resolve', async () => { const h = createParallelApprovalHarness(); vi.spyOn(agentTriggerModule, 'dispatchWithHook').mockResolvedValueOnce({ kind: 'pending' }); diff --git a/harness/tests/session/inbox.test.ts b/harness/tests/session/inbox.test.ts deleted file mode 100644 index a11280af7..000000000 --- a/harness/tests/session/inbox.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { inboxKey } from '../../src/session/inbox/key.js'; - -describe('inboxKey', () => { - it('namespaces by session and name', () => { - expect(inboxKey('steering', 's1')).toBe('s1/steering'); - }); -}); diff --git a/harness/tests/session/operations.test.ts b/harness/tests/session/operations.test.ts index 7ce3a995f..c18e346ad 100644 --- a/harness/tests/session/operations.test.ts +++ b/harness/tests/session/operations.test.ts @@ -8,7 +8,6 @@ import { fork, loadMessages, loadMessagesWithEntryIds, - reconcile, tree, } from '../../src/session/tree/operations.js'; import { InMemoryStore } from '../../src/session/tree/store.js'; @@ -39,6 +38,16 @@ describe('session-tree operations', () => { expect(path).toEqual([e1, e2]); }); + it('append with null parent_id chains to the active tip', async () => { + const store = new InMemoryStore(); + const sid = await createSession(store); + const e1 = await appendMessage(store, sid, null, userMsg('a')); + const e2 = await appendMessage(store, sid, e1, asstMsg('b')); + const e3 = await appendMessage(store, sid, null, userMsg('c')); + const path = await activePath(store, sid); + expect(path).toEqual([e1, e2, e3]); + }); + it('loadMessages filters non-message entries from active path', async () => { const store = new InMemoryStore(); const sid = await createSession(store); @@ -49,25 +58,6 @@ describe('session-tree operations', () => { expect((messages[0] as { content: Array<{ text: string }> }).content[0].text).toBe('a'); }); - it('reconcile appends missing tail messages', async () => { - const store = new InMemoryStore(); - const sid = await createSession(store); - await appendMessage(store, sid, null, userMsg('one')); - const result = await reconcile(store, sid, [userMsg('one'), asstMsg('two'), userMsg('three')]); - expect(result.repaired).toBe(2); - const after = await loadMessages(store, sid); - expect(after).toHaveLength(3); - }); - - it('reconcile is idempotent', async () => { - const store = new InMemoryStore(); - const sid = await createSession(store); - const snap = [userMsg('a'), asstMsg('b')]; - await reconcile(store, sid, snap); - const second = await reconcile(store, sid, snap); - expect(second.repaired).toBe(0); - }); - it('fork copies path entries with re-mapped ids', async () => { const store = new InMemoryStore(); const sid = await createSession(store, 'orig'); diff --git a/harness/tests/turn-orchestrator/_helpers/mockTurnStore.ts b/harness/tests/turn-orchestrator/_helpers/mockTurnStore.ts index e1ff6b0fe..1da608fc1 100644 --- a/harness/tests/turn-orchestrator/_helpers/mockTurnStore.ts +++ b/harness/tests/turn-orchestrator/_helpers/mockTurnStore.ts @@ -22,8 +22,8 @@ export function mockTurnStore(overrides: Partial = {}): MockTurnStore loadRecord: vi.fn(async () => null), saveRecord: vi.fn(async () => {}), writeRecord: vi.fn(async () => {}), + ensureSession: vi.fn(async () => {}), loadMessages: vi.fn(async () => []), - saveMessages: vi.fn(async () => {}), appendMessages: vi.fn(async () => {}), loadRunRequest: vi.fn(async () => defaultRunRequest), saveRunRequest: vi.fn(async () => {}), diff --git a/harness/tests/turn-orchestrator/assistant-streaming.test.ts b/harness/tests/turn-orchestrator/assistant-streaming.test.ts index ff55b9c18..36edfab24 100644 --- a/harness/tests/turn-orchestrator/assistant-streaming.test.ts +++ b/harness/tests/turn-orchestrator/assistant-streaming.test.ts @@ -137,7 +137,7 @@ describe('finalizeAssistantTurn', () => { rec.state = 'assistant_streaming'; const asst = assistant({ stop_reason: 'error', error_message: 'auth failed' }); - await finalizeAssistantTurn(ports, rec, asst); + await finalizeAssistantTurn(ports, rec, asst, []); expect(rec.state).toBe('stopped'); expect(rec.turn_end_emitted).toBe(true); @@ -152,9 +152,10 @@ describe('finalizeAssistantTurn', () => { content: [{ type: 'function_call', id: 'fc-1', function_id: 'shell::run', arguments: {} }], }); - await finalizeAssistantTurn(ports, rec, asst); + await finalizeAssistantTurn(ports, rec, asst, []); expect(ports.persistAssistantIfNew).toHaveBeenCalledOnce(); + expect(ports.persistAssistantIfNew).toHaveBeenCalledWith('s1', asst, []); expect(rec.state).toBe('function_execute'); expect(rec.work?.prepared).toHaveLength(1); expect(rec.function_results).toEqual([]); diff --git a/harness/tests/turn-orchestrator/context-view.test.ts b/harness/tests/turn-orchestrator/context-view.test.ts new file mode 100644 index 000000000..d22af1b7c --- /dev/null +++ b/harness/tests/turn-orchestrator/context-view.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { buildSummaryMessage } from '../../src/context-compaction/flat-state.js'; +import { buildContextView } from '../../src/turn-orchestrator/state-runtime/context-view.js'; +import type { AgentMessage } from '../../src/types/agent-message.js'; +import type { MessageWithEntryId } from '../../src/session/tree/types.js'; + +function user(text: string): AgentMessage { + return { role: 'user', content: [{ type: 'text', text }], timestamp: 0 }; +} + +function asst(text: string): AgentMessage { + return { + role: 'assistant', + content: [{ type: 'text', text }], + stop_reason: 'end', + error_message: null, + error_kind: null, + usage: null, + model: 'm', + provider: 'p', + timestamp: 0, + }; +} + +function entry(id: string, message: AgentMessage): MessageWithEntryId { + return { entry_id: id, message }; +} + +describe('buildContextView', () => { + it('returns raw path when there is no compaction', () => { + const messages = [entry('a', user('one')), entry('b', asst('two'))]; + expect(buildContextView(messages, [])).toEqual([user('one'), asst('two')]); + }); + + it('reconstructs summary + tail from tail_start_id', () => { + const messages = [ + entry('head', user('old')), + entry('tail1', asst('keep')), + entry('last', user('in flight')), + ]; + const compactions = [{ summary: 'condensed', tail_start_id: 'tail1', timestamp: 100 }]; + + expect(buildContextView(messages, compactions)).toEqual([ + buildSummaryMessage('condensed'), + asst('keep'), + user('in flight'), + ]); + }); + + it('uses the latest compaction when several exist', () => { + const messages = [ + entry('h', user('old')), + entry('t1', asst('early tail')), + entry('t2', user('recent')), + ]; + const compactions = [ + { summary: 'first', tail_start_id: 'h', timestamp: 10 }, + { summary: 'latest', tail_start_id: 't2', timestamp: 20 }, + ]; + + expect(buildContextView(messages, compactions)).toEqual([ + buildSummaryMessage('latest'), + user('recent'), + ]); + }); + + it('keeps the whole tail when tail_start_id is absent from the path', () => { + const messages = [entry('a', user('one')), entry('b', asst('two'))]; + const compactions = [{ summary: 's', tail_start_id: 'gone', timestamp: 1 }]; + + expect(buildContextView(messages, compactions)).toEqual([ + buildSummaryMessage('s'), + user('one'), + asst('two'), + ]); + }); +}); diff --git a/harness/tests/turn-orchestrator/finish.test.ts b/harness/tests/turn-orchestrator/finish.test.ts index 3775f750d..5ccde8f06 100644 --- a/harness/tests/turn-orchestrator/finish.test.ts +++ b/harness/tests/turn-orchestrator/finish.test.ts @@ -5,11 +5,8 @@ import { newRecord } from '../../src/turn-orchestrator/state.js'; import { installMockTurnStore } from './_helpers/mockTurnStore.js'; describe('TurnStatePorts.finishSession', () => { - it('emits agent_end with the transcript and sets state to stopped', async () => { - const messages = [ - { role: 'user' as const, content: [{ type: 'text' as const, text: 'hi' }], timestamp: 1 }, - ]; - installMockTurnStore({ loadMessages: vi.fn(async () => messages) }); + it('emits agent_end as a signal (no transcript reload) and sets state to stopped', async () => { + const store = installMockTurnStore(); const emitted: Array<{ type: string; messages?: unknown }> = []; const iii = { trigger: vi.fn(async (req: { function_id: string; payload: unknown }) => { @@ -27,6 +24,9 @@ describe('TurnStatePorts.finishSession', () => { expect(rec.state).toBe('stopped'); const agentEnd = emitted.find((e) => e.type === 'agent_end'); expect(agentEnd).toBeDefined(); - expect(agentEnd?.messages).toEqual(messages); + // agent_end is a turn-end signal; no consumer reads .messages, so the + // session is no longer reloaded just to populate it. + expect(agentEnd?.messages).toEqual([]); + expect(store.loadMessages).not.toHaveBeenCalled(); }); }); diff --git a/harness/tests/turn-orchestrator/run-start.test.ts b/harness/tests/turn-orchestrator/run-start.test.ts index efba08ddf..5bddea0e3 100644 --- a/harness/tests/turn-orchestrator/run-start.test.ts +++ b/harness/tests/turn-orchestrator/run-start.test.ts @@ -165,4 +165,21 @@ describe('execute', () => { expect(wake?.payload).toEqual({ session_id: 'sess-1' }); expect(wake?.action).toEqual(TriggerAction.Enqueue({ queue: TURN_STEP_QUEUE })); }); + + it('ensures the session tree exactly once, before the first append', async () => { + const { iii, calls } = fakeIii(); + + await execute(iii, RunStartPayloadSchema.parse(harnessRunStartPayload)); + + // Single ensure per run — later loadMessages/appendMessages no longer re-ensure. + const ensureCalls = calls.filter((c) => c.function_id === 'session-tree::ensure'); + expect(ensureCalls).toHaveLength(1); + expect(ensureCalls[0]?.payload).toEqual({ session_id: 'sess-1' }); + + // The single ensure must precede the run's first tree write (append). + const ensureIdx = calls.findIndex((c) => c.function_id === 'session-tree::ensure'); + const firstAppendIdx = calls.findIndex((c) => c.function_id === 'session-tree::append'); + expect(ensureIdx).toBeGreaterThanOrEqual(0); + expect(firstAppendIdx).toBeGreaterThan(ensureIdx); + }); }); diff --git a/harness/tests/turn-orchestrator/run-transition.test.ts b/harness/tests/turn-orchestrator/run-transition.test.ts index ac1a1f289..0ec81b9f9 100644 --- a/harness/tests/turn-orchestrator/run-transition.test.ts +++ b/harness/tests/turn-orchestrator/run-transition.test.ts @@ -85,7 +85,6 @@ describe('runTransition', () => { const rec: TurnStateRecord = { ...newRecord('s1'), state: 'steering_check' }; const store = installMockTurnStore({ loadRecord: vi.fn(async () => rec), - loadMessages: vi.fn(async () => []), }); const handle = vi.fn(async () => { throw new Error('boom'); diff --git a/harness/tests/turn-orchestrator/steering-check-layer.test.ts b/harness/tests/turn-orchestrator/steering-check-layer.test.ts index 78d99b67b..ad6f567a7 100644 --- a/harness/tests/turn-orchestrator/steering-check-layer.test.ts +++ b/harness/tests/turn-orchestrator/steering-check-layer.test.ts @@ -3,18 +3,13 @@ import type { AgentMessage } from '../../src/types/agent-message.js'; import { applySteeringCheckOutcome, processSteeringCheck, + route, } from '../../src/turn-orchestrator/steering-check/run.js'; -import { parseDrainItems } from '../../src/turn-orchestrator/steering-check/ports.js'; import type { SteeringCheckPorts } from '../../src/turn-orchestrator/steering-check/ports.js'; import { newRecord } from '../../src/turn-orchestrator/state.js'; -function userMessage(text: string): AgentMessage { - return { role: 'user', content: [{ type: 'text', text }] }; -} - function stubPorts(overrides: Partial = {}): SteeringCheckPorts { return { - drainInbox: vi.fn(async () => []), loadMessages: vi.fn(async () => []), appendMessages: vi.fn(async () => {}), checkpoint: vi.fn(async () => {}), @@ -35,48 +30,16 @@ function stubPorts(overrides: Partial = {}): SteeringCheckPo }; } -describe('parseDrainItems', () => { - it('returns items array when present', () => { - const items = [userMessage('hello')]; - expect(parseDrainItems({ items })).toEqual(items); - }); - - it('returns empty array for invalid shapes', () => { - expect(parseDrainItems(null)).toEqual([]); - expect(parseDrainItems({})).toEqual([]); - expect(parseDrainItems({ items: 'bad' })).toEqual([]); +describe('route', () => { + it.each([ + [true, 'continue_after_function'], + [false, 'end_turn'], + ] as const)('route(%s) -> %s', (has_function_results, expected) => { + expect(route(has_function_results)).toBe(expected); }); }); describe('processSteeringCheck', () => { - it('returns resume_with_inbox for steering messages', async () => { - const steeringItems = [userMessage('steer')]; - const ports = stubPorts({ - drainInbox: vi.fn(async (name) => (name === 'steering' ? steeringItems : [])), - }); - const rec = { ...newRecord('s1'), state: 'steering_check' as const }; - - const outcome = await processSteeringCheck(ports, rec); - - expect(outcome).toEqual({ kind: 'resume_with_inbox', inbox: steeringItems }); - expect(ports.drainInbox).toHaveBeenCalledTimes(1); - }); - - it('drains followup only when steering is empty', async () => { - const followupItems = [userMessage('follow')]; - const drainInbox = vi.fn(async (name: 'steering' | 'followup') => - name === 'followup' ? followupItems : [], - ); - const ports = stubPorts({ drainInbox }); - const rec = { ...newRecord('s1'), state: 'steering_check' as const }; - - const outcome = await processSteeringCheck(ports, rec); - - expect(outcome).toEqual({ kind: 'resume_with_inbox', inbox: followupItems }); - expect(drainInbox).toHaveBeenCalledWith('steering', 's1'); - expect(drainInbox).toHaveBeenCalledWith('followup', 's1'); - }); - it('returns continue_after_function when function_results present', async () => { const ports = stubPorts(); const rec = { @@ -105,7 +68,7 @@ describe('processSteeringCheck', () => { expect(outcome).toEqual({ kind: 'max_turns_reached' }); }); - it('returns end_turn when no steering, followup, or function results', async () => { + it('returns end_turn when no function results', async () => { const ports = stubPorts(); const rec = { ...newRecord('s1'), state: 'steering_check' as const }; @@ -116,30 +79,7 @@ describe('processSteeringCheck', () => { }); describe('applySteeringCheckOutcome', () => { - it('resume_with_inbox: emits turn_end, saves messages, clears function_results', async () => { - const inbox = [userMessage('new')]; - const emitTurnEnd = vi.fn(async () => {}); - const appendMessages = vi.fn(async () => {}); - const ports = stubPorts({ - emitTurnEnd, - appendMessages, - }); - const rec = { - ...newRecord('s1'), - state: 'steering_check' as const, - function_results: [{ role: 'function_result', content: [] }] as never, - }; - - await applySteeringCheckOutcome(ports, rec, { kind: 'resume_with_inbox', inbox }); - - expect(rec.state).toBe('assistant_streaming'); - expect(rec.function_results).toEqual([]); - expect(rec.turn_end_emitted).toBe(true); - expect(emitTurnEnd).toHaveBeenCalledWith('s1', expect.anything(), []); - expect(appendMessages).toHaveBeenCalledWith('s1', inbox); - }); - - it('continue_after_function: transitions without loading messages', async () => { + it('continue_after_function: transitions without reloading messages', async () => { const loadMessages = vi.fn(async () => []); const emitTurnEnd = vi.fn(async () => {}); const ports = stubPorts({ loadMessages, emitTurnEnd }); diff --git a/harness/tests/turn-orchestrator/steering.test.ts b/harness/tests/turn-orchestrator/steering.test.ts index a1fb9a14b..a5b7603ee 100644 --- a/harness/tests/turn-orchestrator/steering.test.ts +++ b/harness/tests/turn-orchestrator/steering.test.ts @@ -1,54 +1,25 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; -import type { AgentMessage } from '../../src/types/agent-message.js'; import * as events from '../../src/turn-orchestrator/events.js'; import { installMockTurnStore } from './_helpers/mockTurnStore.js'; import { newRecord, type TurnStateRecord } from '../../src/turn-orchestrator/state.js'; import { handleSteering } from '../../src/turn-orchestrator/steering-check/process.js'; -import { route } from '../../src/turn-orchestrator/steering-check/run.js'; afterEach(() => { vi.restoreAllMocks(); }); -describe('steering route()', () => { - it.each([ - [true, true, true, 'steering'], - [true, false, false, 'steering'], - [false, true, true, 'followup'], - [false, true, false, 'followup'], - [false, false, true, 'continue_after_function'], - [false, false, false, 'end_turn'], - ] as const)('route(%s, %s, %s) -> %s', (has_steering, has_followup, has_function_results, expected) => { - expect(route(has_steering, has_followup, has_function_results)).toBe(expected); - }); -}); - -function userMessage(text: string): AgentMessage { - return { role: 'user', content: [{ type: 'text', text }] }; -} - -function makeIii(opts: { steeringItems?: AgentMessage[]; followupItems?: AgentMessage[] } = {}) { - const { steeringItems = [], followupItems = [] } = opts; - const drainCalls: Array<{ name: string; session_id: string }> = []; - +function makeIii() { const iii = { trigger: vi.fn(async (req: { function_id: string; payload: unknown }) => { if (req.function_id === 'state::get') return null; - if (req.function_id === 'session-inbox::drain') { - const p = req.payload as { name: string; session_id: string }; - drainCalls.push(p); - if (p.name === 'steering') return { items: steeringItems }; - if (p.name === 'followup') return { items: followupItems }; - return { items: [] }; - } if (req.function_id === 'state::update') return { old_value: 0 }; if (req.function_id === 'stream::set') return null; return null; }), } as unknown as ISdk; - return { iii, drainCalls }; + return { iii }; } function steeringRec( @@ -61,55 +32,6 @@ function steeringRec( } describe('handleSteering', () => { - it('steering: appends drained messages and transitions to assistant_streaming', async () => { - const steeringItems = [userMessage('steer-me')]; - const { iii } = makeIii({ steeringItems }); - const rec = steeringRec('s1', { - function_results: [{ role: 'function_result', content: [] }] as never, - }); - const store = installMockTurnStore(); - const appendSpy = store.appendMessages; - vi.spyOn(events, 'emit').mockResolvedValue(undefined); - - await handleSteering(iii, rec); - - expect(rec.state).toBe('assistant_streaming'); - expect(rec.function_results).toEqual([]); - expect(rec.turn_end_emitted).toBe(true); - expect(appendSpy).toHaveBeenCalledWith('s1', steeringItems); - expect(store.loadMessages).not.toHaveBeenCalled(); - }); - - it('followup: drains followup when steering queue is empty', async () => { - const followupItems = [userMessage('follow-up')]; - const { iii, drainCalls } = makeIii({ followupItems }); - const rec = steeringRec('s1'); - const store = installMockTurnStore(); - const appendSpy = store.appendMessages; - vi.spyOn(events, 'emit').mockResolvedValue(undefined); - - await handleSteering(iii, rec); - - expect(rec.state).toBe('assistant_streaming'); - expect(drainCalls.map((c) => c.name)).toEqual(['steering', 'followup']); - expect(appendSpy).toHaveBeenCalledWith('s1', followupItems); - }); - - it('followup: skipped when steering queue has items', async () => { - const { iii, drainCalls } = makeIii({ - steeringItems: [userMessage('steer')], - followupItems: [userMessage('follow')], - }); - const rec = steeringRec('s1'); - installMockTurnStore(); - vi.spyOn(events, 'emit').mockResolvedValue(undefined); - - await handleSteering(iii, rec); - - expect(drainCalls.map((c) => c.name)).toEqual(['steering']); - expect(rec.state).toBe('assistant_streaming'); - }); - it('continue_after_function: clears function_results without reloading messages', async () => { const { iii } = makeIii(); const rec = steeringRec('s1', { @@ -130,7 +52,7 @@ describe('handleSteering', () => { it('end_turn: emits turn_end then finishes the session (agent_end + stopped)', async () => { const { iii } = makeIii(); const rec = steeringRec('s1'); - installMockTurnStore({ loadMessages: vi.fn(async () => []) }); + const store = installMockTurnStore(); const emitSpy = vi.spyOn(events, 'emit').mockResolvedValue(undefined); await handleSteering(iii, rec); @@ -139,6 +61,8 @@ describe('handleSteering', () => { expect(rec.turn_end_emitted).toBe(true); expect(emitSpy).toHaveBeenCalledWith(iii, 's1', expect.objectContaining({ type: 'turn_end' })); expect(emitSpy).toHaveBeenCalledWith(iii, 's1', expect.objectContaining({ type: 'agent_end' })); + // agent_end is a signal: finishSession no longer reloads the transcript. + expect(store.loadMessages).not.toHaveBeenCalled(); }); it('caps at max_turns: emits a max_turns assistant + message_complete + turn_end and tears down instead of continuing', async () => { @@ -148,7 +72,7 @@ describe('handleSteering', () => { turn_count: 2, function_results: [{ role: 'function_result', content: [] }] as never, }); - const store = installMockTurnStore({ loadMessages: vi.fn(async () => []) }); + const store = installMockTurnStore(); const appendSpy = store.appendMessages; const emitSpy = vi.spyOn(events, 'emit').mockResolvedValue(undefined); @@ -165,7 +89,9 @@ describe('handleSteering', () => { expect.objectContaining({ type: 'message_complete' }), ); expect(emitSpy).toHaveBeenCalledWith(iii, 's1', expect.objectContaining({ type: 'turn_end' })); - expect(store.loadMessages).toHaveBeenCalledWith('s1'); + // max_turns teardown appends the synthetic notice and finishes without a + // transcript reload (agent_end is a signal). + expect(store.loadMessages).not.toHaveBeenCalled(); expect(appendSpy).toHaveBeenCalledWith('s1', [ expect.objectContaining({ content: expect.arrayContaining([ @@ -175,24 +101,6 @@ describe('handleSteering', () => { ]); }); - it('caps at max_turns via steering route: tears down instead of continuing to assistant_streaming', async () => { - const { iii } = makeIii({ steeringItems: [userMessage('steer-me')] }); - const rec = steeringRec('s1', { - max_turns: 3, - turn_count: 3, - }); - installMockTurnStore({ loadMessages: vi.fn(async () => []) }); - vi.spyOn(events, 'emit').mockResolvedValue(undefined); - - await handleSteering(iii, rec); - - expect(rec.state).toBe('stopped'); - expect(rec.turn_end_emitted).toBe(true); - expect(rec.last_assistant?.content[0]).toEqual( - expect.objectContaining({ text: expect.stringContaining('max_turns') }), - ); - }); - it('continues to assistant_streaming when under max_turns (continue_after_function route)', async () => { const { iii } = makeIii(); const rec = steeringRec('s1', { diff --git a/harness/tests/turn-orchestrator/store.test.ts b/harness/tests/turn-orchestrator/store.test.ts index 8c811876d..5c5f9bd11 100644 --- a/harness/tests/turn-orchestrator/store.test.ts +++ b/harness/tests/turn-orchestrator/store.test.ts @@ -2,25 +2,10 @@ import { describe, expect, it, vi } from 'vitest'; import type { ISdk } from '../../src/runtime/iii.js'; import { createTurnStore, - parseFlatMessages, shouldWakeStep, } from '../../src/turn-orchestrator/state-runtime/store.js'; import { newRecord } from '../../src/turn-orchestrator/state.js'; -describe('parseFlatMessages', () => { - it('returns the array when messages are objects', () => { - const messages = [{ role: 'user', content: [], timestamp: 1 }]; - expect(parseFlatMessages(messages)).toEqual(messages); - }); - - it('returns [] for null, undefined, and non-arrays', () => { - expect(parseFlatMessages(null)).toEqual([]); - expect(parseFlatMessages(undefined)).toEqual([]); - expect(parseFlatMessages('bad')).toEqual([]); - expect(parseFlatMessages({})).toEqual([]); - }); -}); - function fakeIii(): { iii: ISdk; emits: Array<{ session_id: string; event: unknown }> } { const emits: Array<{ session_id: string; event: unknown }> = []; const iii = { @@ -124,6 +109,47 @@ describe('saveRecord no-op suppression', () => { }); }); +describe('session-tree call reduction', () => { + function recordingIii(): { iii: ISdk; calls: string[] } { + const calls: string[] = []; + const iii = { + trigger: vi.fn(async ({ function_id }: { function_id: string }) => { + calls.push(function_id); + if (function_id === 'session-tree::messages') return { messages: [] }; + if (function_id === 'session-tree::compactions') return { entries: [] }; + return null; + }), + } as unknown as ISdk; + return { iii, calls }; + } + + it('loadMessages reads the window without re-ensuring the session', async () => { + const { iii, calls } = recordingIii(); + await createTurnStore(iii).loadMessages('sess-a'); + expect(calls).toContain('session-tree::messages'); + expect(calls).toContain('session-tree::compactions'); + expect(calls).not.toContain('session-tree::ensure'); + }); + + it('appendMessages writes without re-ensuring the session', async () => { + const { iii, calls } = recordingIii(); + const msg = { + role: 'user' as const, + content: [{ type: 'text' as const, text: 'hi' }], + timestamp: 1, + }; + await createTurnStore(iii).appendMessages('sess-a', [msg]); + expect(calls.filter((c) => c === 'session-tree::append')).toHaveLength(1); + expect(calls).not.toContain('session-tree::ensure'); + }); + + it('ensureSession is the sole trigger of session-tree::ensure', async () => { + const { iii, calls } = recordingIii(); + await createTurnStore(iii).ensureSession('sess-a'); + expect(calls.filter((c) => c === 'session-tree::ensure')).toHaveLength(1); + }); +}); + describe('shouldWakeStep', () => { it('accepts first write to a stepable state', () => { expect(shouldWakeStep(null, 'provisioning')).toBe(true);