fix: bind stdio to its configured agent

This commit is contained in:
Tianyi Cui
2026-07-14 09:23:02 +08:00
parent a21802ce14
commit 2dea4afac0
4 changed files with 53 additions and 19 deletions

View File

@@ -32,7 +32,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
| `welcome` | `ready.` | the stdin-chat banner |
| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-<uuid>` agent/session id, so durable restarts cannot collide. The UI's `main` text is a display label, not a second routing id. Resumed sessions register under the exact `resumeSessionId` and keep the cwd stored in the persisted session header.
Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-<uuid>` agent/session id, so durable restarts cannot collide. The UI's `main` text is a display label, not a second routing id; the UI binds to that fresh-id namespace, or to the exact `resumeSessionId` for a resumed run, and never selects unrelated registry roots. Resumed sessions keep the cwd stored in the persisted session header.
## The bin

View File

@@ -124,5 +124,8 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
ctx.plugin(UserInteractionService)
ctx.plugin(toolAskUser)
ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.' })
ctx.plugin(uiStdio, {
welcome: config.welcome ?? 'ready.',
...config.resumeSessionId !== undefined ? { resumeSessionId: config.resumeSessionId } : {},
})
}

View File

@@ -36,10 +36,13 @@ export const inject = ['agents', 'userInteraction']
export interface Config {
/** Banner printed once on start, before the first `> ` prompt. */
welcome?: string
/** Exact persisted session id the app configured for resume; absent selects the app's fresh `main-session-*` identity. */
resumeSessionId?: string
}
export const Config: z<Config> = z.object({
welcome: z.string().default('ready.'),
resumeSessionId: z.string(),
})
/**
@@ -95,16 +98,25 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
const welcome = config.welcome ?? 'ready.'
const { input, output, exit } = runtime
// 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] })
// Bind only to this app's configured top-level agent. Fresh runs own the
// `main-session-*` namespace; resumed runs own the exact persisted id. The
// registry's runtime-root relation excludes subagents without confusing it
// with durable parentSession lineage. Keeping the matching candidates also
// covers HMR's publish-new-before-dispose-old ordering without ever falling
// through to an unrelated root owned by another app or test fixture.
const matchesConfiguredIdentity = (agent: Agent): boolean => config.resumeSessionId === undefined
? agent.id.startsWith('main-session-')
: agent.id === config.resumeSessionId
const configuredRoots = new Set(ctx.agents.roots().filter(matchesConfiguredIdentity))
let target: Agent | undefined = [...configuredRoots].at(-1)
ctx.on('agent/created', (agent) => {
if (!matchesConfiguredIdentity(agent) || !ctx.agents.roots().includes(agent)) return
configuredRoots.add(agent)
target ??= agent
})
ctx.on('agent/disposed', (agent) => {
if (target === agent) target = ctx.agents.roots().at(-1)
configuredRoots.delete(agent)
if (target === agent) target = [...configuredRoots].at(-1)
})
// Transcript rendering off the durable `session/event` feed — the assistant

View File

@@ -74,7 +74,7 @@ function chunkEvent(chunk: StreamChunk): SessionEvent {
return { type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 0, chunk } }
}
const CONFIG: Config = { welcome: 'hi there' }
const CONFIG: Config = { welcome: 'hi there', resumeSessionId: 'main' }
async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
const ctx = new Context()
@@ -199,7 +199,9 @@ describe('createStdioChat rendering', () => {
})
it('accepts a lineage-bearing configured agent created after the UI installs', async () => {
const { ctx, input } = await setup()
const { ctx, input } = await setup({ welcome: 'hi there', resumeSessionId: 'resumed' })
const unrelated = makeAgent('unrelated')
ctx.agents.register(unrelated)
const resumed = makeAgent('resumed')
;(resumed.session.header as { parentSession?: string }).parentSession = 'persisted-parent'
ctx.agents.register(resumed)
@@ -207,6 +209,7 @@ describe('createStdioChat rendering', () => {
input.feed('continue')
await new Promise(resolve => setImmediate(resolve))
expect(unrelated.sent).toEqual([])
expect(resumed.sent).toEqual([[{ type: 'text', text: 'continue' }]])
})
@@ -235,7 +238,7 @@ describe('createStdioChat rendering', () => {
it('keeps the target when a different agent is disposed', async () => {
const { ctx, out } = await setup()
const target = makeAgent('target')
const target = makeAgent('main')
ctx.agents.register(target)
ctx.emit('agent/disposed', makeAgent('other'))
ctx.emit('session/event', target.session, {
@@ -245,11 +248,11 @@ describe('createStdioChat rendering', () => {
})
it('retargets a surviving root when HMR publishes it before disposing the old root', async () => {
const { ctx, input } = await setup()
const oldRoot = makeAgent('old-root')
const { ctx, input } = await setup({ welcome: 'hi there' })
const oldRoot = makeAgent('main-session-old')
const child = makeAgent('child')
;(child.session.header as { parentSession?: string }).parentSession = oldRoot.id
const replacement = makeAgent('replacement')
const replacement = makeAgent('main-session-replacement')
const lateChild = makeAgent('late-child')
const disposeOld = ctx.agents.register(oldRoot)
const disposeChild = ctx.agents.enter(child, oldRoot)
@@ -273,6 +276,22 @@ describe('createStdioChat rendering', () => {
disposeChild()
})
it('does not retarget stdin to an unrelated root after the configured agent is disposed', async () => {
const { ctx, input } = await setup()
const unrelated = makeAgent('unrelated')
ctx.agents.register(unrelated)
const configured = makeAgent('main')
const disposeConfigured = ctx.agents.register(configured)
const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
disposeConfigured()
input.feed('must not leak')
await new Promise(resolve => setImmediate(resolve))
expect(unrelated.sent).toEqual([])
expect(error).toHaveBeenCalledWith('ui-stdio: main agent is not running')
})
it('renders tool/call and tool/result session events', async () => {
const { ctx, out } = await setup()
const session = {} as Session
@@ -720,8 +739,8 @@ describe('createStdioChat input', () => {
expect(spy).toHaveBeenCalledWith('ui-stdio: main agent is not running')
})
it('drives the app-owned agent without a duplicate id config', async () => {
const { ctx, input } = await setup({ welcome: 'w' })
it('drives the exact app-configured resumed session', async () => {
const { ctx, input } = await setup({ welcome: 'w', resumeSessionId: 'worker' })
const agent = makeAgent('worker')
ctx.agents.register(agent)
input.feed('hi')