Merge origin/master into codex/skill-system
This commit is contained in:
@@ -10,4 +10,4 @@ Integrations that expose the agent to an external editor or client. These are **
|
||||
|
||||
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline `ui-stdio` plugin is the unstructured analogue but lives in `support/` because it exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product.
|
||||
|
||||
`stdio-agent` and `acp-agent` are the two **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is just the swappable backends plus one app entry. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention.
|
||||
`stdio-agent` and `acp-agent` are the two **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is the swappable backends plus one app entry plus any optional product tools. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention.
|
||||
|
||||
@@ -14,11 +14,12 @@
|
||||
* which this app does not prevent — so the rule "never add a stdout logger to an
|
||||
* ACP leaf" still stands; the app just gives the leaf nothing to misconfigure.)
|
||||
*
|
||||
* The leaf supplies only the swappable backends: the LLM adapter (`llm-deepseek`
|
||||
* for the real model, `llm-replay` for keyless snapshot replay) and the bash
|
||||
* executor (`bash-local`). This app's {@link Config} (model, system prompt,
|
||||
* persistence root) routes each value to where it is wired — model/prompt onto
|
||||
* the bridge's per-session agent template, the root onto the JSONL backend.
|
||||
* The leaf supplies the swappable backends: the LLM adapter (`llm-deepseek` for
|
||||
* the real model, `llm-replay` for keyless snapshot replay), the bash executor
|
||||
* (`bash-local`), and any optional product tools it wants to expose. This app's
|
||||
* {@link Config} (model, system prompt, persistence root) routes each value to
|
||||
* where it is wired — model/prompt onto the bridge's per-session agent
|
||||
* template, the root onto the JSONL backend.
|
||||
*
|
||||
* Plugin export shape: named `name`/`Config`/`apply`, NO default export — the
|
||||
* cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ import {
|
||||
type LoadSessionResponse,
|
||||
type NewSessionRequest,
|
||||
type NewSessionResponse,
|
||||
type Plan,
|
||||
type PlanEntry,
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
type SessionNotification,
|
||||
@@ -64,7 +66,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolCallKind, ToolCallPresentation, ToolRegistry, ToolResultPresentation, ToolTerminal } from '@deepseek-ai/dsh-tools'
|
||||
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
|
||||
// Context (the bridge injects it and reads `list()` for load cwd validation).
|
||||
@@ -873,6 +875,10 @@ export function streamSessionEventUpdate(
|
||||
})
|
||||
return
|
||||
}
|
||||
case 'todo/write': {
|
||||
notify({ sessionId, update: { sessionUpdate: 'plan', ...todosToPlan(event.data.todos) } })
|
||||
return
|
||||
}
|
||||
// turn/step boundaries, context/message, steering,
|
||||
// assistant/message — no direct ACP client update.
|
||||
default:
|
||||
@@ -880,6 +886,18 @@ export function streamSessionEventUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a harness todo list to an ACP `plan` body. ACP's `PlanEntry` requires
|
||||
* `content` + `priority` + `status`, but a {@link TodoItem} carries no priority,
|
||||
* so synthesize a constant `'medium'` on every entry; `status` maps 1:1 (the
|
||||
* harness status triple IS `PlanEntryStatus`). The ACP client REPLACES its whole
|
||||
* plan on each `plan` update, matching the harness's whole-list-replace
|
||||
* semantics, so no per-entry diffing is needed.
|
||||
*/
|
||||
export function todosToPlan(todos: TodoItem[]): Plan {
|
||||
return { entries: todos.map((todo): PlanEntry => ({ content: todo.content, priority: 'medium', status: todo.status })) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-connection terminal-rendering context threaded into
|
||||
* {@link streamSessionEventUpdate}: whether the client advertised the
|
||||
|
||||
@@ -20,6 +20,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
@@ -158,6 +159,12 @@ export async function makeBridgeHarness(options: {
|
||||
* implementation over a mock in tests").
|
||||
*/
|
||||
withBash?: boolean
|
||||
/**
|
||||
* Plug the REAL `dsh-tool-todo` tool so a test can drive `todo_write` through
|
||||
* the bridge and assert the resulting `plan` sessionUpdate — the shipping
|
||||
* tool + the bridge's own todo/write→plan mapping, not a stand-in.
|
||||
*/
|
||||
withTodo?: boolean
|
||||
} = { storageDir: '' }): Promise<BridgeHarness> {
|
||||
const adapter = new MockAdapter(options.script ?? [])
|
||||
|
||||
@@ -173,6 +180,9 @@ export async function makeBridgeHarness(options: {
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(ToolBash)
|
||||
}
|
||||
if (options.withTodo) {
|
||||
await ctx.plugin(ToolTodo)
|
||||
}
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
// Two identity byte pipes cross-wired into the two ndJsonStreams: bytes the
|
||||
|
||||
@@ -94,6 +94,44 @@ describe('acp bridge — session/load replay', () => {
|
||||
expect(content[0]?.content.text).toBe('```console\nhello\n```')
|
||||
})
|
||||
|
||||
it('replays a persisted todo/write as a plan sessionUpdate on load', async () => {
|
||||
// A turn whose model called todo_write persists a todo/write event. A fresh
|
||||
// bridge loading the session must re-emit the ACP `plan` update from the log
|
||||
// (the load replay runs every event through streamSessionEventUpdate), so an
|
||||
// editor reopening the session sees the current plan.
|
||||
live = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withTodo: true,
|
||||
script: [
|
||||
toolCallResponse('c1', 'todo_write', {
|
||||
todos: [
|
||||
{ content: 'first step', status: 'in_progress' },
|
||||
{ content: 'second step', status: 'pending' },
|
||||
],
|
||||
}),
|
||||
textResponse('planned'),
|
||||
],
|
||||
})
|
||||
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'plan it' }] })
|
||||
await live.dispose()
|
||||
live = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, withTodo: true, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const plan = loader.updates.find(u => u.sessionUpdate === 'plan')
|
||||
expect(plan).toEqual({
|
||||
sessionUpdate: 'plan',
|
||||
entries: [
|
||||
{ content: 'first step', priority: 'medium', status: 'in_progress' },
|
||||
{ content: 'second step', priority: 'medium', status: 'pending' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('replays a persisted bash call as a TERMINAL card when the loader advertises the capability', async () => {
|
||||
// The presentation is resolved at replay time, so a loader that advertised
|
||||
// _meta.terminal_output must reconstruct the terminal card (content + _meta)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionNotification } from '@agentclientprotocol/sdk'
|
||||
import type { ToolDefinition, ToolRegistry } from '@deepseek-ai/dsh-tools'
|
||||
import { streamSessionEventUpdate, agentOptions, ToolPresenter } from '../src/index.ts'
|
||||
import { streamSessionEventUpdate, agentOptions, todosToPlan, ToolPresenter } from '../src/index.ts'
|
||||
|
||||
/** Collect the updates a single event produces (no presenter → generic fallback). */
|
||||
function updatesFor(event: SessionEvent): SessionNotification['update'][] {
|
||||
@@ -118,6 +118,43 @@ describe('streamSessionEventUpdate', () => {
|
||||
expect(updatesFor(evt('turn/end', { turn: 1, reason: { kind: 'completed' } }))).toEqual([])
|
||||
expect(updatesFor(evt('step/start', { turn: 1, step: 1 }))).toEqual([])
|
||||
})
|
||||
|
||||
it('maps todo/write to a plan sessionUpdate with priority synthesized as medium', () => {
|
||||
expect(updatesFor(evt('todo/write', {
|
||||
todos: [
|
||||
{ content: 'plan the work', status: 'in_progress' },
|
||||
{ content: 'write the code', status: 'pending' },
|
||||
{ content: 'run the tests', status: 'completed' },
|
||||
],
|
||||
}))).toEqual([{
|
||||
sessionUpdate: 'plan',
|
||||
entries: [
|
||||
{ content: 'plan the work', priority: 'medium', status: 'in_progress' },
|
||||
{ content: 'write the code', priority: 'medium', status: 'pending' },
|
||||
{ content: 'run the tests', priority: 'medium', status: 'completed' },
|
||||
],
|
||||
}])
|
||||
})
|
||||
|
||||
it('maps an empty todo list to a plan with no entries', () => {
|
||||
expect(updatesFor(evt('todo/write', { todos: [] }))).toEqual([{ sessionUpdate: 'plan', entries: [] }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('todosToPlan', () => {
|
||||
it('maps status 1:1 and stamps every entry priority medium', () => {
|
||||
expect(todosToPlan([
|
||||
{ content: 'a', status: 'pending' },
|
||||
{ content: 'b', status: 'in_progress' },
|
||||
{ content: 'c', status: 'completed' },
|
||||
])).toEqual({
|
||||
entries: [
|
||||
{ content: 'a', priority: 'medium', status: 'pending' },
|
||||
{ content: 'b', priority: 'medium', status: 'in_progress' },
|
||||
{ content: 'c', priority: 'medium', status: 'completed' },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToolPresenter (tool-owned presentation via the tool registry)', () => {
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
*
|
||||
* 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
|
||||
* `ui-stdio` sends to. The leaf supplies only the swappable backends (the LLM
|
||||
* adapter, the bash executor), the optional `hmr` dev-reload plugin, and this
|
||||
* app's {@link Config} (model, prompt, persistence root, welcome banner).
|
||||
* `ui-stdio` sends to. 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).
|
||||
*
|
||||
* `hmr` is deliberately a LEAF entry, not baked in here: it is a Loader-only,
|
||||
* subprocess-only dev plugin (its constructor throws without `--expose-internals`
|
||||
|
||||
Reference in New Issue
Block a user