diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index bfb58e3a57..2a4d620494 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -95,20 +95,18 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt const welcome = config.welcome ?? 'ready.' const { input, output, exit } = runtime - // This app owns one root agent. Hold the live object directly: its per-run id - // is intentionally fresh, while `main` remains only the terminal's fixed - // display label. HMR may publish the replacement before old teardown emits - // disposed, so a target disposal reselects the surviving root from the live - // registry instead of leaving the terminal detached. Fork children carry - // parentSession lineage and must never become the terminal target. - const rootAgent = (): Agent | undefined => - ctx.agents.list().find(agent => agent.session.header.parentSession === undefined) - let target: Agent | undefined = rootAgent() - ctx.on('agent/created', (agent) => { - if (target === undefined && agent.session.header.parentSession === undefined) target = agent - }) + // 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 }) ctx.on('agent/disposed', (agent) => { - if (target === agent) target = rootAgent() + if (target === agent) target = ctx.agents.list().at(-1) }) // Transcript rendering off the durable `session/event` feed — the assistant diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 2b1df53921..2654a05680 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -185,6 +185,9 @@ describe('createStdioChat rendering', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) const agent = makeAgent('main') + // Durable lineage does not imply runtime child ownership: the stdio app + // may explicitly resume a persisted fork as its one configured agent. + ;(agent.session.header as { parentSession?: string }).parentSession = 'persisted-parent' ctx.agents.register(agent) // registered BEFORE the UI plugin below const { runtime, out } = makeRuntime() await ctx.plugin(Object.assign((inner: Context) => { @@ -196,6 +199,18 @@ describe('createStdioChat rendering', () => { expect(out.text()).toContain('[main turn 5] ') }) + it('accepts a lineage-bearing configured agent created after the UI installs', async () => { + const { ctx, input } = await setup() + const resumed = makeAgent('resumed') + ;(resumed.session.header as { parentSession?: string }).parentSession = 'persisted-parent' + ctx.emit('agent/created', resumed) + + input.feed('continue') + await new Promise(resolve => setImmediate(resolve)) + + expect(resumed.sent).toEqual([[{ type: 'text', text: 'continue' }]]) + }) + it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => { const { ctx, out } = await setup() const session = makeSession('main') @@ -242,7 +257,7 @@ describe('createStdioChat rendering', () => { // The replacement's created edge arrived while oldRoot was still targeted. // Once oldRoot is removed, registry order is child then replacement; the - // UI must skip the surviving child and route input to the replacement root. + // most recently published survivor is the HMR replacement. disposeOld() input.feed('after hmr') await new Promise(resolve => setImmediate(resolve))