Merge branch 'codex/simp-unify-agent-session-id' into codex/simp-ui-identity-residue

This commit is contained in:
Tianyi Cui
2026-07-14 07:15:05 +08:00
9 changed files with 92 additions and 31 deletions

View File

@@ -95,18 +95,16 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
const welcome = config.welcome ?? 'ready.'
const { input, output, exit } = runtime
// This app owns one configured agent. Hold the live object directly: its
// per-run id is intentionally fresh, while `main` remains only the
// terminal's fixed display label. At install the configured agent is the
// earliest registry entry (it creates any subagents later). During HMR the
// replacement is published after the old tree, so when old teardown finally
// emits disposed, the newest survivor is the replacement. Persisted
// parentSession lineage is deliberately irrelevant: a resumed child session
// can itself be this process's configured top-level agent.
let target: Agent | undefined = ctx.agents.list()[0]
ctx.on('agent/created', (agent) => { target ??= agent })
// This app owns one configured top-level agent. Hold the live object
// directly: its per-run id is intentionally fresh, while `main` remains only
// the terminal's fixed display label. Runtime creator ownership distinguishes
// that root from its subagents even if a child is registered after an HMR
// replacement. Persisted parentSession lineage is deliberately irrelevant:
// a resumed child session can itself be this process's configured root.
let target: Agent | undefined = ctx.agents.roots()[0]
ctx.on('agent/created', () => { target ??= ctx.agents.roots()[0] })
ctx.on('agent/disposed', (agent) => {
if (target === agent) target = ctx.agents.list().at(-1)
if (target === agent) target = ctx.agents.roots().at(-1)
})
// Transcript rendering off the durable `session/event` feed — the assistant

View File

@@ -16,9 +16,9 @@ function fakeContext(): Context {
return {
on: vi.fn(() => vi.fn()),
effect: vi.fn((callback: () => () => void) => callback()),
// The UI seeds its target object from the registry at install; this suite only
// The UI seeds its root target from the registry at install; this suite only
// exercises readline terminal-mode selection, so an empty roster suffices.
agents: { list: vi.fn(() => []) },
agents: { roots: vi.fn(() => []) },
userInteraction: { registerProvider: vi.fn(() => vi.fn()) },
} as unknown as Context
}

View File

@@ -154,8 +154,7 @@ describe('createStdioChat rendering', () => {
it('renders turn/start and turn/end markers from the session feed', async () => {
const { ctx, out } = await setup()
const agent = makeAgent('main')
// agent/created supplies the app-owned target object.
ctx.emit('agent/created', agent)
ctx.agents.register(agent)
const session = agent.session
ctx.emit('session/event', session, {
type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } },
@@ -203,7 +202,7 @@ describe('createStdioChat rendering', () => {
const { ctx, input } = await setup()
const resumed = makeAgent('resumed')
;(resumed.session.header as { parentSession?: string }).parentSession = 'persisted-parent'
ctx.emit('agent/created', resumed)
ctx.agents.register(resumed)
input.feed('continue')
await new Promise(resolve => setImmediate(resolve))
@@ -224,8 +223,8 @@ describe('createStdioChat rendering', () => {
it('drops the target object on agent/disposed', async () => {
const { ctx, out } = await setup()
const agent = makeAgent('main')
ctx.emit('agent/created', agent)
ctx.emit('agent/disposed', agent)
const dispose = ctx.agents.register(agent)
dispose()
// After disposal the event belongs to a non-target session, so its durable
// identity is rendered directly.
ctx.emit('session/event', agent.session, {
@@ -237,7 +236,7 @@ describe('createStdioChat rendering', () => {
it('keeps the target when a different agent is disposed', async () => {
const { ctx, out } = await setup()
const target = makeAgent('target')
ctx.emit('agent/created', target)
ctx.agents.register(target)
ctx.emit('agent/disposed', makeAgent('other'))
ctx.emit('session/event', target.session, {
type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
@@ -251,19 +250,27 @@ describe('createStdioChat rendering', () => {
const child = makeAgent('child')
;(child.session.header as { parentSession?: string }).parentSession = oldRoot.id
const replacement = makeAgent('replacement')
const lateChild = makeAgent('late-child')
const disposeOld = ctx.agents.register(oldRoot)
ctx.agents.register(child)
const disposeChild = ctx.agents.enter(child, oldRoot)
ctx.agents.announce(child)
ctx.agents.register(replacement)
const disposeLateChild = ctx.agents.enter(lateChild, replacement)
ctx.agents.announce(lateChild)
// The replacement's created edge arrived while oldRoot was still targeted.
// Once oldRoot is removed, registry order is child then replacement; the
// most recently published survivor is the HMR replacement.
// A replacement-owned child then arrived even later. Once oldRoot is
// removed, runtime ownership still identifies replacement as the only
// surviving root instead of selecting either newer child by insertion order.
disposeOld()
input.feed('after hmr')
await new Promise(resolve => setImmediate(resolve))
expect(child.sent).toEqual([])
expect(lateChild.sent).toEqual([])
expect(replacement.sent).toEqual([[{ type: 'text', text: 'after hmr' }]])
disposeLateChild()
disposeChild()
})
it('renders tool/call and tool/result session events', async () => {