Merge branch 'codex/simp-ui-identity-residue' into codex/simp-hide-concrete-agent-loop
# Conflicts: # packages/core/agent-loop/README.md
This commit is contained in:
@@ -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; 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.
|
||||
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 app passes that exact opaque id to both its config-created agent and UI; the UI's `main` text is only a display label and never selects another registry root by prefix or insertion order. A resumed run binds both components to the exact `resumeSessionId` and keeps the cwd stored in the persisted session header.
|
||||
|
||||
## The bin
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import ConsoleExporter from '@cordisjs/plugin-logger-console'
|
||||
import z from 'schemastery'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -108,6 +109,8 @@ export const Config: z<Config> = z.object({
|
||||
* a leaf concern (see the module doc), so it is not mounted here.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId
|
||||
const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`)
|
||||
ctx.plugin(ConsoleExporter)
|
||||
ctx.plugin(agentCore, {
|
||||
...config.persona !== undefined ? { persona: config.persona } : {},
|
||||
@@ -117,7 +120,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
id: 'main',
|
||||
model: config.model,
|
||||
cwd: process.cwd(),
|
||||
...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
|
||||
...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId },
|
||||
}],
|
||||
...config.skills !== undefined ? { skills: config.skills } : {},
|
||||
})
|
||||
@@ -126,6 +129,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(toolAskUser)
|
||||
ctx.plugin(uiStdio, {
|
||||
welcome: config.welcome ?? 'ready.',
|
||||
...config.resumeSessionId !== undefined ? { resumeSessionId: config.resumeSessionId } : {},
|
||||
sessionId,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -36,13 +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
|
||||
/** Exact shared agent/session identity this app instance created or resumed. */
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
welcome: z.string().default('ready.'),
|
||||
resumeSessionId: z.string(),
|
||||
sessionId: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -98,26 +98,18 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
|
||||
const welcome = config.welcome ?? 'ready.'
|
||||
const { input, output, exit } = runtime
|
||||
|
||||
// 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 resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId
|
||||
const matchesConfiguredIdentity = (agent: Agent): boolean => resumeSessionId === undefined
|
||||
? agent.id.startsWith('main-session-')
|
||||
: agent.id === resumeSessionId
|
||||
const configuredRoots = new Set(ctx.agents.roots().filter(matchesConfiguredIdentity))
|
||||
let target: Agent | undefined = [...configuredRoots].at(-1)
|
||||
// Bind only to the exact identity this app passed to its config-created
|
||||
// agent. Session ids are opaque: neither a prefix nor registry order can
|
||||
// identify ownership. The root check rejects a child that somehow preempts
|
||||
// the configured id; later recreation under the same id supports loop HMR.
|
||||
const matchesConfiguredIdentity = (agent: Agent): boolean =>
|
||||
agent.id === config.sessionId && ctx.agents.roots().includes(agent)
|
||||
let target: Agent | undefined = ctx.agents.roots().find(agent => agent.id === config.sessionId)
|
||||
ctx.on('agent/created', (agent) => {
|
||||
if (!matchesConfiguredIdentity(agent) || !ctx.agents.roots().includes(agent)) return
|
||||
configuredRoots.add(agent)
|
||||
target ??= agent
|
||||
if (matchesConfiguredIdentity(agent)) target = agent
|
||||
})
|
||||
ctx.on('agent/disposed', (agent) => {
|
||||
configuredRoots.delete(agent)
|
||||
if (target === agent) target = [...configuredRoots].at(-1)
|
||||
if (target === agent) target = undefined
|
||||
})
|
||||
|
||||
// Transcript rendering off the durable `session/event` feed — the assistant
|
||||
|
||||
@@ -93,6 +93,19 @@ describe('dsh-stdio-agent app', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('normalizes an empty resume id to a fresh exact app identity', async () => {
|
||||
const ctx = await mount({
|
||||
model: 'mock',
|
||||
resumeSessionId: '',
|
||||
persistenceRoot: '/tmp/dsh-stdio-agent-spec-empty-resume',
|
||||
skills: await isolatedSkillsConfig(),
|
||||
})
|
||||
const agent = ctx.get('agents')?.list()[0]
|
||||
expect(agent?.id).toMatch(/^main-session-[0-9a-f-]{36}$/)
|
||||
expect(agent?.id).toBe(agent?.session.id)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('defaults persistenceRoot and welcome when omitted', async () => {
|
||||
// Direct apply (NOT via ctx.plugin, which validates+defaults the config
|
||||
// first) so the runtime `?? './.sessions'` / `?? 'ready.'` fallbacks on
|
||||
|
||||
@@ -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', resumeSessionId: 'main' }
|
||||
const CONFIG: Config = { welcome: 'hi there', sessionId: 'main' }
|
||||
|
||||
async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
|
||||
const ctx = new Context()
|
||||
@@ -199,7 +199,7 @@ describe('createStdioChat rendering', () => {
|
||||
})
|
||||
|
||||
it('accepts a lineage-bearing configured agent created after the UI installs', async () => {
|
||||
const { ctx, input } = await setup({ welcome: 'hi there', resumeSessionId: 'resumed' })
|
||||
const { ctx, input } = await setup({ welcome: 'hi there', sessionId: 'resumed' })
|
||||
const unrelated = makeAgent('unrelated')
|
||||
ctx.agents.register(unrelated)
|
||||
const resumed = makeAgent('resumed')
|
||||
@@ -247,33 +247,21 @@ describe('createStdioChat rendering', () => {
|
||||
expect(out.text()).toContain('[main turn 1] ')
|
||||
})
|
||||
|
||||
it('retargets a surviving root when HMR publishes it before disposing the old root', async () => {
|
||||
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('main-session-replacement')
|
||||
const lateChild = makeAgent('late-child')
|
||||
it('retargets only the exact identity after loop HMR recreation', async () => {
|
||||
const { ctx, input } = await setup({ welcome: 'hi there', sessionId: 'main-session-fixed' })
|
||||
const oldRoot = makeAgent('main-session-fixed')
|
||||
const prefixCollision = makeAgent('main-session-unrelated')
|
||||
const disposeOld = ctx.agents.register(oldRoot)
|
||||
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.
|
||||
// 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.
|
||||
ctx.agents.register(prefixCollision)
|
||||
disposeOld()
|
||||
const replacement = makeAgent('main-session-fixed')
|
||||
ctx.agents.register(replacement)
|
||||
|
||||
input.feed('after hmr')
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
expect(child.sent).toEqual([])
|
||||
expect(lateChild.sent).toEqual([])
|
||||
expect(prefixCollision.sent).toEqual([])
|
||||
expect(replacement.sent).toEqual([[{ type: 'text', text: 'after hmr' }]])
|
||||
disposeLateChild()
|
||||
disposeChild()
|
||||
})
|
||||
|
||||
it('does not retarget stdin to an unrelated root after the configured agent is disposed', async () => {
|
||||
@@ -740,7 +728,7 @@ describe('createStdioChat input', () => {
|
||||
})
|
||||
|
||||
it('drives the exact app-configured resumed session', async () => {
|
||||
const { ctx, input } = await setup({ welcome: 'w', resumeSessionId: 'worker' })
|
||||
const { ctx, input } = await setup({ welcome: 'w', sessionId: 'worker' })
|
||||
const agent = makeAgent('worker')
|
||||
ctx.agents.register(agent)
|
||||
input.feed('hi')
|
||||
@@ -748,14 +736,6 @@ describe('createStdioChat input', () => {
|
||||
expect(agent.sent).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('treats an empty resume session id as a fresh configured identity', async () => {
|
||||
const { ctx, input } = await setup({ welcome: 'w', resumeSessionId: '' })
|
||||
const agent = makeAgent('main-session-fresh')
|
||||
ctx.agents.register(agent)
|
||||
input.feed('hi')
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(agent.sent).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createStdioChat EOF exit', () => {
|
||||
|
||||
Reference in New Issue
Block a user