Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-sigint-context-order.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 0 additions & 2 deletions packages/agents-runtime/src/context-assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<VolatileMessage> = []
const droppedOffsets: Array<number> = []
Expand Down
160 changes: 158 additions & 2 deletions packages/agents-runtime/test/use-context-volatile-interleave.test.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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: `<agent_signal signal="SIGINT" />`,
at: 2,
},
{ role: `user` as const, content: `continue`, at: 4 },
],
cache: `volatile`,
},
},
})

expect(messages.map((message) => message.content)).toEqual([
`start`,
`partial`,
`<agent_signal signal="SIGINT" />`,
`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<IncludesInboxMessage> = [
{
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<IncludesSignal> = [
{
key: `sig-1`,
order: order(2),
signal: `SIGINT`,
status: `handled`,
timestamp: `2026-06-01T00:00:02.000Z`,
outcome: `aborted`,
},
]
const runs: Array<IncludesRun> = [
{
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])
})
})
Loading