refactor: unify agent and session identity

This commit is contained in:
Tianyi Cui
2026-07-14 01:59:21 +08:00
parent 7a33ee94be
commit 709cc7200e
105 changed files with 899 additions and 948 deletions

View File

@@ -11,11 +11,11 @@ A terminal chat always wants the same cluster, so the package owns it rather tha
| Plugin | Why it is here |
|---|---|
| `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) |
| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model` with `process.cwd()` as the fresh session cwd and carrying its `persona` |
| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating one agent under the `main` config label from this app's `model`, with `process.cwd()` as the fresh session cwd and carrying its `persona` |
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools |
| `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool |
| `stdio-chat` (in-package module) | the readline UI, bound to the `main` agent |
| `stdio-chat` (in-package module) | the readline UI, holding the app-owned agent object directly and rendering it as `main` |
`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`.
@@ -25,14 +25,14 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
| Key | Default | Routed to |
|---|---|---|
| `model` | (required) | the pre-created `main` agent's model |
| `model` | (required) | the pre-created agent's model |
| `persona` | — | the deployment persona template (may reference `{{model}}`), routed to `dsh-system-prompt` |
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
| `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`, so project-scoped features such as skill discovery and default bash workdir follow the directory where `dsh-stdio-agent` was started. 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 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.
## The bin

View File

@@ -3,11 +3,11 @@
* @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster a terminal
* chat needs — a console logger, the readline UI (the in-package `stdio-chat`
* module), JSONL session
* persistence, and a pre-created `main` agent the UI drives.
* persistence, and one pre-created agent the UI drives under its `main` label.
*
* The cluster is BAKED IN, not left to the leaf: a stdio app always logs to the
* console (stdout is just the terminal) and always pre-creates the `main` agent
* the readline UI sends to. The leaf supplies the swappable backends (the LLM
* console (stdout is just the terminal) and always pre-creates one agent the
* readline UI labels `main`. The leaf supplies the swappable backends (the LLM
* adapter, the bash executor), optional product tools, the optional `hmr`
* dev-reload plugin, and this app's {@link Config} (model, prompt, persistence
* root, welcome banner).
@@ -41,7 +41,6 @@
import type { Context } from 'cordis'
import ConsoleExporter from '@cordisjs/plugin-logger-console'
import z from 'schemastery'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import * as agentCore from '@deepseek-ai/dsh-agent-core'
@@ -54,7 +53,7 @@ export const name = 'stdio-agent'
/**
* App config: the swappable per-demo values, each routed to where the app wires
* it. `model`/`resumeSessionId` configure the pre-created `main` agent (through
* it. `model`/`resumeSessionId` configure the pre-created agent (through
* {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is
* the deployment persona (forwarded to the system-prompt plugin); `toolOrder`
* is the explicit model-facing tool order (forwarded to the system-prompt plugin);
@@ -63,7 +62,7 @@ export const name = 'stdio-agent'
* `welcome` is the UI banner.
*/
export interface Config {
/** Model name for the `main` agent (must have a registered adapter). */
/** Model name for the pre-created agent (must have a registered adapter). */
model: string
/** Deployment persona (the system-prompt plugin's `persona` config). */
persona?: string
@@ -78,7 +77,7 @@ export interface Config {
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
skills?: agentCore.SkillConfig
/**
* If set, the `main` agent RESUMES this persisted session id instead of
* If set, the pre-created agent RESUMES this persisted session id instead of
* starting fresh. Sourced from an env var in the leaf `cordis.yml`
* (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`).
*/
@@ -103,9 +102,9 @@ export const Config: z<Config> = z.object({
/**
* Compose the spine with the stdio front door. The console logger comes first
* (infra), then the agent-core bundle pre-creating the `main` agent from this
* app's `model`/`resumeSessionId` with the deployment `persona`, then the JSONL
* backend, then the readline UI bound to `main`. The `hmr` dev-reload plugin is
* (infra), then the agent-core bundle pre-creating one agent from this app's
* `model`/`resumeSessionId` with the deployment `persona`, then the JSONL
* backend, then the readline UI rendering that object as `main`. The `hmr` dev-reload plugin is
* a leaf concern (see the module doc), so it is not mounted here.
*/
export function apply(ctx: Context, config: Config): void {
@@ -115,7 +114,7 @@ export function apply(ctx: Context, config: Config): void {
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
...config.tools !== undefined ? { tools: config.tools } : {},
agents: [{
id: AgentId('main'),
id: 'main',
model: config.model,
cwd: process.cwd(),
...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
@@ -125,5 +124,5 @@ 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.', agent: 'main' })
ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.' })
}

View File

@@ -19,7 +19,7 @@ import { createInterface } from 'node:readline'
import type { Readable, Writable } from 'node:stream'
import type { Context } from 'cordis'
import z from 'schemastery'
import { AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import {
UserInteractionError,
type AskUserQuestionAnswer,
@@ -36,15 +36,10 @@ export const inject = ['agents', 'userInteraction']
export interface Config {
/** Banner printed once on start, before the first `> ` prompt. */
welcome?: string
// TODO(fixed-stdio-agent): this app-internal plugin is mounted only for the
// precreated `main` agent; remove configurability and its config-only test.
/** Id of the agent stdin drives (`send`/`steer`) and whose status gates the EOF exit; rendering is global. Defaults to `'main'`. */
agent?: string
}
export const Config: z<Config> = z.object({
welcome: z.string().default('ready.'),
agent: z.string().default('main'),
})
/**
@@ -98,23 +93,16 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
// Loader validation, so it must be self-contained rather than trusting the
// cast — `config.welcome as string` would otherwise be `undefined` on `{}`.
const welcome = config.welcome ?? 'ready.'
const agentId = AgentId(config.agent ?? 'main')
const { input, output, exit } = runtime
// Render label lookup: the `turn/start` session event carries only the turn
// number, so to print the short agent id (`[main turn 1]`) we map the
// session's id to its agent's id. The session id is not reliably the agent id
// (a session can be created with an explicit/client-supplied id), so build the
// map from `agent/created` rather than parsing the id string. Seed from the
// registry's current agents first: an agent registered before this plugin
// installed (e.g. the pre-created `main` agent, or any agent surviving an HMR
// reload of just this fiber) already fired its `agent/created`, so the live
// listener alone would miss it and its turns would fall back to the raw
// session id.
const labelBySession = new Map<string, string>()
for (const agent of ctx.agents.list()) labelBySession.set(agent.session.header.id, agent.id)
ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) })
ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) })
// This app owns exactly one pre-created agent. Hold the live object directly:
// its per-run id is intentionally fresh, while `main` remains only the
// terminal's fixed display label.
let target: Agent | undefined = ctx.agents.list()[0]
ctx.on('agent/created', (agent) => { target ??= agent })
ctx.on('agent/disposed', (agent) => {
if (target === agent) target = undefined
})
// Transcript rendering off the durable `session/event` feed — the assistant
// token stream, turn/step boundaries, tool activity, and todos all come from
@@ -136,7 +124,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
output.write(chunk.text)
}
} else if (event.type === 'turn/start') {
const label = labelBySession.get(session.header.id) ?? session.header.id
const label = target?.session === session ? 'main' : session.id
output.write(`\n[${label} turn ${event.data.turn}] `)
} else if (event.type === 'turn/end') {
if (inReasoning) output.write('\x1B[0m')
@@ -187,7 +175,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
// Work submitted: wait until a turn has run and the agent is idle.
if (submittedWork) {
if (!sawRunning) return
const agent = ctx.agents.get(agentId)
const agent = target
if (agent && agent.status !== 'idle') return // a turn is still running
}
// Let any final output flush, then exit. The handle is tracked so the
@@ -201,7 +189,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
}
const disposeStatusListener = ctx.on('agent/status', (subject, status) => {
if (subject.id !== agentId) return
if (subject !== target) return
if (status === 'running') sawRunning = true
if (status === 'idle') maybeExit()
})
@@ -354,9 +342,9 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
}
const text = line.trim()
if (!text) return
const agent = ctx.agents.get(agentId)
const agent = target
if (!agent) {
ctx.logger.error('ui-stdio: agent "%s" is not running', agentId)
ctx.logger.error('ui-stdio: main agent is not running')
return
}
submittedWork = true

View File

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

View File

@@ -4,7 +4,8 @@ import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import * as stdioAgent from '../src/index.ts'
@@ -82,9 +83,12 @@ describe('dsh-stdio-agent app', () => {
expect(ctx.get('sessionPersistence')).toBeDefined()
expect(ctx.get('userInteraction')).toBeDefined()
expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined()
// The pre-created `main` agent the UI drives.
const agent = ctx.get('agents')?.get(AgentId('main'))
// The sole pre-created agent the UI drives. `main` is its stable config
// label; each fresh process mints a durable combined agent/session id.
const agent = ctx.get('agents')?.list()[0]
expect(agent).toBeDefined()
expect(agent?.id).toBe(agent?.session.id)
expect(agent?.id).toMatch(/^main-session-/)
expect(agent?.session.header.cwd).toBe(process.cwd())
await ctx.fiber.dispose()
})
@@ -99,7 +103,7 @@ describe('dsh-stdio-agent app', () => {
stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() })
await new Promise(resolve => setTimeout(resolve, 80))
expect(ctx.get('sessionPersistence')).toBeDefined()
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
expect(ctx.get('agents')?.list()).toHaveLength(1)
await ctx.fiber.dispose()
})
@@ -116,7 +120,7 @@ describe('dsh-stdio-agent app', () => {
it('forwards resumeSessionId onto the pre-created agent when set', async () => {
// A resume id defers agent creation until persistence loads; with no backing
// session the resume is contained + logged, so no `main` agent registers —
// session the resume is contained + logged, so no agent registers —
// the branch that maps resumeSessionId through is what this covers.
const ctx = await mount({
model: 'mock',
@@ -125,7 +129,7 @@ describe('dsh-stdio-agent app', () => {
resumeSessionId: 'no-such-session',
skills: await isolatedSkillsConfig(),
})
expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
expect(ctx.get('agents')?.list()).toEqual([])
await ctx.fiber.dispose()
})

View File

@@ -57,17 +57,16 @@ function makeAgent(id: string, status: AgentStatus = 'idle'): Agent & {
status,
sent,
steered,
// A minimal session stub: the UI reads only `session.header.id` (to map the
// session back to its agent id for the turn-boundary label).
session: { header: { id: `${id}-session` } },
// A minimal session stub with the agent's shared durable identity.
session: { id, header: { id } },
send: (content: ContentBlock[]) => void sent.push(content),
steer: (content: ContentBlock[]) => void steered.push(content),
} as never
}
/** A session stub whose `header.id` matches an agent's, for `session/event` emits. */
function makeSession(agentId: string): Session {
return { header: { id: `${agentId}-session` } } as Session
function makeSession(id: string): Session {
return { id, header: { id } } as Session
}
/** An `assistant/chunk` session event carrying one raw stream chunk. */
@@ -75,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', agent: 'main' }
const CONFIG: Config = { welcome: 'hi there' }
async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
const ctx = new Context()
@@ -99,12 +98,11 @@ describe('createStdioChat rendering', () => {
expect(out.text()).toBe('hi there\n> ')
})
it('falls back to default welcome/agent when called with empty config', async () => {
it('falls back to the default welcome when called with empty config', async () => {
// createStdioChat is exported and may be driven directly (bypassing the
// Loader's schemastery validation), so it must default welcome/agent itself.
// Loader's schemastery validation), so it must default the welcome itself.
const { out } = await setup({})
expect(out.text()).toBe('ready.\n> ')
// And it drives the default agent id 'main'.
})
it('detects readline terminal mode from both stream TTY flags', async () => {
@@ -156,9 +154,9 @@ 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 populates the session-id → agent-id label map.
// agent/created supplies the app-owned target object.
ctx.emit('agent/created', agent)
const session = makeSession('main')
const session = agent.session
ctx.emit('session/event', session, {
type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } },
} as SessionEvent)
@@ -169,21 +167,20 @@ describe('createStdioChat rendering', () => {
expect(out.text()).toContain('\n> ')
})
it('falls back to the session id as the label when no agent is mapped', async () => {
it('uses the session id as the label for a non-target session', async () => {
const { ctx, out } = await setup()
// No agent/created emitted, so the label map is empty — the header id shows.
// No target exists, so the event's durable identity is the label.
ctx.emit('session/event', makeSession('orphan'), {
type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
} as SessionEvent)
expect(out.text()).toContain('[orphan-session turn 1] ')
expect(out.text()).toContain('[orphan turn 1] ')
})
it('seeds labels for agents already registered before the UI installs', async () => {
it('uses an agent already registered before the UI installs as its target', async () => {
// The pre-created `main` agent (and any agent surviving an HMR reload of just
// this fiber) fired its `agent/created` before the UI's listener existed, so
// the live listener alone would miss it. Seeding from `ctx.agents.list()` at
// install time is what keeps its turn header showing `[main turn N]` instead
// of the raw session id.
// install time preserves the terminal's fixed `[main turn N]` label.
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
@@ -193,7 +190,7 @@ describe('createStdioChat rendering', () => {
await ctx.plugin(Object.assign((inner: Context) => {
createStdioChat(inner, CONFIG, runtime)
}, { inject: ['agents', 'userInteraction'] }))
ctx.emit('session/event', makeSession('main'), {
ctx.emit('session/event', agent.session, {
type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } },
} as SessionEvent)
expect(out.text()).toContain('[main turn 5] ')
@@ -209,17 +206,28 @@ describe('createStdioChat rendering', () => {
expect(out.text()).toContain('\x1B[2mmid\x1B[0m')
})
it('drops the label mapping on agent/disposed', async () => {
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)
// After disposal the map no longer resolves the agent id — fall back to the
// session header id.
ctx.emit('session/event', makeSession('main'), {
// After disposal the event belongs to a non-target session, so its durable
// identity is rendered directly.
ctx.emit('session/event', agent.session, {
type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
} as SessionEvent)
expect(out.text()).toContain('[main-session turn 1] ')
expect(out.text()).toContain('[main turn 1] ')
})
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.emit('agent/disposed', makeAgent('other'))
ctx.emit('session/event', target.session, {
type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
} as SessionEvent)
expect(out.text()).toContain('[main turn 1] ')
})
it('renders tool/call and tool/result session events', async () => {
@@ -666,11 +674,11 @@ describe('createStdioChat input', () => {
const spy = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
input.feed('nobody home')
await new Promise(r => setImmediate(r))
expect(spy).toHaveBeenCalledWith('ui-stdio: agent "%s" is not running', 'main')
expect(spy).toHaveBeenCalledWith('ui-stdio: main agent is not running')
})
it('drives the agent named in config, not a hardcoded id', async () => {
const { ctx, input } = await setup({ welcome: 'w', agent: 'worker' })
it('drives the app-owned agent without a duplicate id config', async () => {
const { ctx, input } = await setup({ welcome: 'w' })
const agent = makeAgent('worker')
ctx.agents.register(agent)
input.feed('hi')