diff --git a/.changeset/fix-sigint-context-order.md b/.changeset/fix-sigint-context-order.md new file mode 100644 index 0000000000..3a91427f1e --- /dev/null +++ b/.changeset/fix-sigint-context-order.md @@ -0,0 +1,5 @@ +--- +'@electric-ax/agents-runtime': patch +--- + +Preserve volatile context source order in `assembleContext()` instead of globally sorting by `at` timestamp. Fixes a bug where the SIGINT reordering performed by `reorderInterruptedRuns()` was undone by a downstream sort, causing interrupted run output to appear after the interrupt marker in the model transcript. diff --git a/packages/agents-runtime/src/context-assembly.ts b/packages/agents-runtime/src/context-assembly.ts index 31f3f38022..b135c930a9 100644 --- a/packages/agents-runtime/src/context-assembly.ts +++ b/packages/agents-runtime/src/context-assembly.ts @@ -313,8 +313,6 @@ export async function assembleContext( } } - volatileMessages.sort((left, right) => left.at - right.at) - const remainingBudget = Math.max(0, config.sourceBudget - budgetUsed) const accepted: Array = [] const droppedOffsets: Array = [] diff --git a/packages/agents-runtime/test/use-context-volatile-interleave.test.ts b/packages/agents-runtime/test/use-context-volatile-interleave.test.ts index 834e17274c..2dee07861e 100644 --- a/packages/agents-runtime/test/use-context-volatile-interleave.test.ts +++ b/packages/agents-runtime/test/use-context-volatile-interleave.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it } from 'vitest' import { assembleContext } from '../src/context-assembly' +import { defaultProjection, materializeTimeline } from '../src/timeline-context' +import type { + IncludesInboxMessage, + IncludesRun, + IncludesSignal, +} from '../src/entity-timeline' describe(`volatile interleave`, () => { - it(`merges volatile sources by at`, async () => { + it(`preserves volatile source order and per-source message order`, async () => { const messages = await assembleContext({ sourceBudget: 10_000, sources: { @@ -27,9 +33,159 @@ describe(`volatile interleave`, () => { expect(messages.map((message) => message.content)).toEqual([ `A1`, - `B3`, `A5`, + `B3`, `B7`, ]) + expect(messages.map((message) => message.at)).toEqual([1, 5, 3, 7]) + }) + + it(`concatenates three volatile sources in registration order`, async () => { + const messages = await assembleContext({ + sourceBudget: 10_000, + sources: { + alpha: { + content: () => [{ role: `user` as const, content: `A1`, at: 9 }], + max: 1_000, + cache: `volatile`, + }, + beta: { + content: () => [ + { role: `user` as const, content: `B1`, at: 2 }, + { role: `user` as const, content: `B2`, at: 4 }, + ], + max: 1_000, + cache: `volatile`, + }, + gamma: { + content: () => [{ role: `user` as const, content: `C1`, at: 1 }], + max: 1_000, + cache: `volatile`, + }, + }, + }) + + expect(messages.map((message) => message.content)).toEqual([ + `A1`, + `B1`, + `B2`, + `C1`, + ]) + expect(messages.map((message) => message.at)).toEqual([9, 2, 4, 1]) + }) + + it(`preserves semantic order returned by a volatile source when at values race`, async () => { + const messages = await assembleContext({ + sourceBudget: 10_000, + sources: { + conversation: { + content: () => [ + { role: `user` as const, content: `start`, at: 1 }, + { role: `assistant` as const, content: `partial`, at: 3 }, + { + role: `user` as const, + content: ``, + at: 2, + }, + { role: `user` as const, content: `continue`, at: 4 }, + ], + cache: `volatile`, + }, + }, + }) + + expect(messages.map((message) => message.content)).toEqual([ + `start`, + `partial`, + ``, + `continue`, + ]) + expect(messages.map((message) => message.at)).toEqual([1, 3, 2, 4]) + }) + + it(`end-to-end: timeline SIGINT reorder survives assembleContext`, async () => { + function order(index: number): string { + return index.toString().padStart(20, `0`) + } + + const inbox: Array = [ + { + key: `msg-1`, + order: order(1), + from: `user`, + payload: `start`, + timestamp: `2026-06-01T00:00:00.000Z`, + }, + { + key: `msg-2`, + order: order(4), + from: `user`, + payload: `continue`, + timestamp: `2026-06-01T00:00:03.000Z`, + }, + ] + const signals: Array = [ + { + key: `sig-1`, + order: order(2), + signal: `SIGINT`, + status: `handled`, + timestamp: `2026-06-01T00:00:02.000Z`, + outcome: `aborted`, + }, + ] + const runs: Array = [ + { + key: `run-1`, + order: order(3), + status: `completed`, + finish_reason: `aborted`, + texts: [ + { + key: `text-1`, + run_id: `run-1`, + order: order(5), + status: `completed`, + text: `partial response`, + }, + ], + toolCalls: [], + steps: [], + errors: [], + }, + ] + + const timelineItems = materializeTimeline({ + runs, + inbox, + wakes: [], + signals, + contextInserted: [], + contextRemoved: [], + entities: [], + }) + + const volatileContent = timelineItems.flatMap((item) => { + const msgs = defaultProjection(item) ?? [] + return msgs.map((m) => ({ ...m, at: item.at })) + }) + + const messages = await assembleContext({ + sourceBudget: 10_000, + sources: { + conversation: { + content: () => volatileContent, + cache: `volatile`, + }, + }, + }) + + expect(messages.map((m) => m.content)).toEqual([ + `start`, + `partial response`, + expect.stringContaining(`SIGINT`), + `continue`, + ]) + expect(messages.map((m) => m.at)).toEqual([1, 3, 2, 4]) }) })