Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id

This commit is contained in:
Tianyi Cui
2026-07-16 11:21:57 +08:00
9 changed files with 56 additions and 103 deletions

View File

@@ -337,13 +337,7 @@ export class BasicCompactService extends CompactService {
signal?: AbortSignal,
): Promise<CompactionResult> {
const session = agent.session
// Resolve the range by surface POSITION, not numeric seq interval. A prior
// replace lands a fresh high-seq summary node AT the shadowed range's
// position, so the surface order (head→tail) no longer tracks seq order —
// `[newSummarySeq, olderRetainedSeq, …]` is normal. Indexing into the
// ordered node list and slicing it is the only correct way to read a range;
// a `seq >= start && seq <= end` interval test would mis-collect
// nodes (and `start > end` would falsely reject) once that happens.
// Resolve by surface position: a newer replacement seq may occupy an older slot.
const nodes = session.surface.nodes
const startIdx = nodes.indexOf(start)
const endIdx = nodes.indexOf(end)
@@ -510,14 +504,8 @@ export class BasicCompactService extends CompactService {
// The whole surface fits the retain budget — nothing to compact.
if (keepFromIdx === 0) return null
// Round the cutoff to a tool-pairing boundary: if the cut before
// `nodes[keepFromIdx]` is unbalanced (an unanswered tool-call sits before
// it — i.e. it is mid-step), extend the retained side head-ward until the
// cut is balanced, so the compacted range ends without splitting an
// assistant↔result pair. A node that belongs to no step is already a
// balanced (free) boundary. Decline if no balanced cut exists at or below
// `keepFromIdx` (the compactable range is only an un-splittable open tail
// step — retry once it closes).
// Round the cutoff head-ward to a tool-pairing boundary; decline when no
// safe compactable prefix exists.
while (keepFromIdx > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!)) break

View File

@@ -1516,42 +1516,25 @@ describe('BasicCompactService edge cases', () => {
describe('BasicCompactService positional range (surface seqs are not monotonic after a replace)', () => {
it('compacts a second region after the first replace lands a high-seq summary at the head position', async () => {
// A replace inserts the new summary node (a high seq) AT the shadowed
// range's surface position, so the surface becomes
// [highSeqSummary, …olderRetainedLowerSeqs]. A second compaction over a
// range whose start node has a HIGHER seq than its end node must still
// succeed — the range is positional, not a numeric seq interval.
// Replacement can make surface seqs non-monotonic; ranges remain positional.
const svc = createTestService({ auto: false })
const session = multiTurnSession(4, 1)
// First compaction: shadow the two oldest surface nodes.
const nodes0 = session.surface.nodes
await compactRegion(svc, session, nodes0[0]!, nodes0[1]!, 'm')
const firstSummarySeq = session.events.findLast(e => e.type === 'compact/summary')!.seq
// The summary node now sits at the head with a seq HIGHER than the
// retained older nodes that follow it — the non-monotonic surface. (The
// head is the user/message replace node, appended after the compact/summary
// provenance event.
const nodes1 = session.surface.nodes
expect(nodes1[0]!).toBeGreaterThan(firstSummarySeq)
expect(nodes1[0]!).toBeGreaterThan(nodes1[1]!)
// Second compaction: shadow [summary(head) … turn-2's step end]. The start
// seq (the head summary node) is GREATER than the end seq (an older retained
// node), so the range is a SURFACE-POSITION span, not a numeric seq interval.
// The end must land on a step boundary (turn-2's assistant message closes
// its step).
const startSeq = nodes1[0]!
const endSeq = nodes1[2]!
expect(startSeq).toBeGreaterThan(endSeq)
const second = await compactRegion(svc, session, startSeq, endSeq, 'm')
const secondSummarySeq = session.events.findLast(e => e.type === 'compact/summary')!.seq
// Exactly the three nodes at surface positions [0..2] are shadowed, in
// surface order — the positional slice, regardless of their seq values.
expect(second.shadowedSeqs).toEqual([nodes1[0]!, nodes1[1]!, nodes1[2]!])
// The surface still derives cleanly: a new head replace node + the rest.
const finalNodes = session.surface.nodes
expect(finalNodes[0]!).toBeGreaterThan(secondSummarySeq)
expect(session.deriveMessages().length).toBe(finalNodes.length)
@@ -1561,20 +1544,13 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a
const svc = createTestService({ auto: false })
const session = multiTurnSession(3, 1)
// First compaction shadows the oldest two surface nodes, landing a high-seq
// summary node at the head.
const n0 = session.surface.nodes
await compactRegion(svc, session, n0[0]!, n0[1]!, 'm')
// Second compaction spans [head summary … turn-2's step end]. The head's seq
// is higher than the older retained nodes' seqs, so a log-seq-order walk
// would emit the older messages BEFORE the checkpoint.
const n1 = session.surface.nodes
svc.summarizeCalls = []
await compactRegion(svc, session, n1[0]!, n1[2]!, 'm')
// The extracted transcript follows surface order: the checkpoint (head)
// first, then the older retained messages — matching deriveMessages().
const { text } = svc.summarizeCalls[0]!
const checkpointIdx = text.indexOf('compacted-summary')
const olderIdx = text.indexOf('turn 2 user')