fix(client): floor the fork anchor to a real event seq

The fork button on a stopped assistant message was inert. Frozen
interrupted nodes carry a flow-ordering seq of turnEnd.seq - 0.9, and
session.fork takes a non-negative integer on the wire, so every such
request was rejected as invalid-params before reaching the host — where
an aborted turn's logged turn/end has always made it forkable.

SessionsService.fork floors atSeq at the wire boundary. Flooring stays
inside the anchor's own turn (every turn opens with turn/start), so the
host's first-turn/end-at-or-after cut still closes on that turn.
This commit is contained in:
ZiyaZhang
2026-07-31 05:41:45 -07:00
parent 5c4b701afe
commit 344ad0d6fb
6 changed files with 119 additions and 5 deletions

View File

@@ -388,6 +388,9 @@ export class SessionsService implements ISessions {
* cut (the boundary is the first turn/end at or after it; an in-log
* anchor in an open turn is unavailable rather than clipped backward),
* and whether to increment an inherited durable title before resolving.
* A fractional anchor floors to a real event seq: the frozen nodes of an
* interrupted turn carry flow-ordering seqs between two events, and the
* wire takes integers only.
* @returns the child session id.
* @throws {SessionForkError} with the source id.
* @throws {Error} when a requested child-title rename fails after creation.
@@ -402,7 +405,10 @@ export class SessionsService implements ISessions {
: undefined
const result = await this.manager.fork({
sessionId: opts.sessionId,
...(opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }),
// Flooring lands inside the anchor's own turn (every turn opens with a
// turn/start), so the host's first-turn/end-at-or-after cut still ends
// on that turn — never clipped back to the previous one.
...(opts.atSeq === undefined ? {} : { atSeq: Math.floor(opts.atSeq) }),
})
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
this.projectList()

View File

@@ -455,6 +455,17 @@ describe('fork', () => {
})
})
it('floors a fractional anchor to the real event seq the wire accepts', async () => {
const b = bench()
await feedList(b, [{ id: 'source', cwd: '/work' }])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
// The frozen node of an interrupted turn carries turnEnd.seq - 0.9.
await expect(b.svc.fork({ sessionId: sid('source'), atSeq: 41.1 })).resolves.toBe('child')
expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 41 }])
})
it('does not rename without the title policy or a durable source title', async () => {
const b = bench()
await feedList(b, [{ id: 'source', cwd: '/work' }])

View File

@@ -46,7 +46,10 @@ async function composed(): Promise<Context> {
return ctx
}
function liveAgent(ctx: Context, id: string, turns: number, openTail = false): Session {
/** Tail turn appended after the completed ones: left open, or closed as aborted (a stopped turn). */
type Tail = 'none' | 'open' | 'aborted'
function liveAgent(ctx: Context, id: string, turns: number, tail: Tail = 'none'): Session {
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } })
for (let turn = 1; turn <= turns; turn++) {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -56,12 +59,13 @@ function liveAgent(ctx: Context, id: string, turns: number, openTail = false): S
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
if (openTail) {
if (tail !== 'none') {
session.append('turn/start', { turn: turns + 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'open prompt' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
if (tail === 'aborted') session.append('turn/end', { turn: turns + 1, reason: { kind: 'aborted' } })
}
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
return session
@@ -92,7 +96,7 @@ describe('sessions.fork', () => {
it('uses the last completed turn only for omitted and past-end anchors', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-tail', 2, true)
const source = liveAgent(ctx, 'session-tail', 2, 'open')
const proxy = api(ctx)
const expectedTypes = [
'turn/start', 'user/message', 'turn/end',
@@ -114,9 +118,26 @@ describe('sessions.fork', () => {
await ctx.fiber.dispose()
})
it('cuts through an aborted turn: stopped is closed, not open', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-aborted', 1, 'aborted')
// What a stopped message's fork button anchors on: the frozen node sits
// one event before its turn/end, floored client-side to that event's seq.
const anchor = (source.events.at(-1)?.seq ?? 0) - 1
const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: anchor }))
expect(response.result.ok).toBe(true)
if (!response.result.ok) return
expect(ctx.sessions.get(response.result.value.sessionId)?.events.map(event => event.type)).toEqual([
'turn/start', 'user/message', 'turn/end',
'turn/start', 'user/message', 'turn/end',
'session/end-seed',
])
await ctx.fiber.dispose()
})
it('rejects an in-log anchor whose turn is still open', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-open', 1, true)
const source = liveAgent(ctx, 'session-open', 1, 'open')
const anchor = source.events.at(-1)?.seq ?? 0
const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: anchor }))
expect(response.result).toMatchObject({