Merge remote-tracking branch 'origin/master' into HEAD
# Conflicts: # .agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml # .agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md # .agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md # apps/cli/reference/README.i18n.yaml
This commit is contained in:
@@ -218,6 +218,10 @@
|
||||
- id: skill-local
|
||||
name: '@deepseek-ai/dsh-skill-local'
|
||||
|
||||
- id: skill-badge
|
||||
name: '@deepseek-ai/dsh-skill-badge'
|
||||
disabled: true
|
||||
|
||||
- id: tool-skill
|
||||
name: '@deepseek-ai/dsh-tool-skill'
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-badge": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill-policy": "workspace:^",
|
||||
|
||||
1
packages/bundle/base/tests/fixtures/root.cordis.yml
vendored
Normal file
1
packages/bundle/base/tests/fixtures/root.cordis.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bundle/headless/README.md
|
||||
README.md: d08fb08e2aca3c4e5ccd733b37fc415d492974ca
|
||||
README.zh.md: 99a64ef04c4fd8fb0c6a979d3f09f1bd98b434a0
|
||||
README.md: 661b377817482d22f58f22b573075722646729a2
|
||||
README.zh.md: a6b91a8e60fdcc06ba23e07dcb2f4208ea1020f7
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md): it moves the webserver to an OS-assigned port (parallel runs never collide), silences the URL line, and inserts this package's `headless-runner` plugin (config `{task}`). The runner drives one task turn through the in-process API carrier (`InProcessApiClient` over `toFetchHandler(ctx.apiProxy)`, so the full wire chain — serialization, zod, SSE framing — really runs), aggregates the turn's final assistant text, writes it to stdout, and requests exit (completed → 0, else 1) through the launcher-provided `ctx.headlessIo` seam. The Web composition stays mounted, so the running session is observable in a browser at the stderr-announced URL. The launcher patches the task text in (`dsh --profile headless "task"`), and fails loud when a task is given to a profile without this row.
|
||||
The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md): it moves the webserver to an OS-assigned port (parallel runs never collide), silences the URL line, and inserts this package's `headless-runner` plugin (config `{task}`). The runner drives one task turn through the in-process API carrier (`InProcessApiClient` over `toFetchHandler(ctx.apiProxy)`, so the full wire chain — serialization, zod, SSE framing — really runs), waits at idle until that mux has consumed the session's final event sequence, aggregates the turn's final assistant text, writes it to stdout, and requests exit (completed → 0, else 1) through the launcher-provided `ctx.headlessIo` seam. The Web composition stays mounted, so the running session is observable in a browser at the stderr-announced URL. The launcher patches the task text in (`dsh run "task"`), and fails loud when the selected profile lacks this row.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md) 之上:把 webserver 移到 OS 分配的端口(并行运行绝不冲突),关闭 URL 行输出,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。runner 通过进程内 API 载体(架在 `toFetchHandler(ctx.apiProxy)` 之上的 `InProcessApiClient`,因此序列化、zod、SSE(Server-Sent Events)帧封装这整条 wire 链路都会真实运行)驱动一个任务轮次,聚合该轮次最终的 assistant 文本,写到 stdout,再经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0,否则 1)。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh --profile headless "task"`);如果向没有这一行的 profile 传入任务,则大声失败。
|
||||
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md) 之上:把 webserver 移到 OS 分配的端口(并行运行绝不冲突),关闭 URL 行输出,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。runner 通过进程内 API 载体(架在 `toFetchHandler(ctx.apiProxy)` 之上的 `InProcessApiClient`,因此序列化、zod、SSE(Server-Sent Events)帧封装这整条 wire 链路都会真实运行)驱动一个任务轮次,在 idle 时等待该 mux 消费完会话的最终事件序号,再聚合该轮次最终的 assistant 文本,写到 stdout,并经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0,否则 1)。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
* (InProcessApiClient over toFetchHandler(ctx.apiProxy), so the full wire
|
||||
* chain — serialization, zod, SSE framing — really runs), prints the final
|
||||
* assistant text at agent quiescence, and exits (completed → 0, else 1). The
|
||||
* task text arrives as launcher-patched config
|
||||
* (`dsh --profile headless "task"`).
|
||||
* task text arrives as launcher-patched config (`dsh run "task"`).
|
||||
* @module @deepseek-ai/dsh-headless
|
||||
*/
|
||||
|
||||
@@ -86,26 +85,31 @@ async function unwrap<T>(response: RpcResponse<T>, io: HeadlessIo): Promise<T> {
|
||||
* `agent/status` subscription; the stream itself carries no status frame.
|
||||
* @param frames - the mux stream opened before the prompt.
|
||||
* @param sessionId - the headless session.
|
||||
* @param idle - resolves when the agent reaches quiescence.
|
||||
* @param idle - resolves to the final session-event sequence when the agent reaches quiescence.
|
||||
* @param io - process-facing effects for stream diagnostics.
|
||||
* @returns the aggregated outcome.
|
||||
*/
|
||||
async function consumeUntilIdle(
|
||||
frames: AsyncIterable<RpcRequest<MuxFrame>>,
|
||||
sessionId: SessionId,
|
||||
idle: Promise<void>,
|
||||
idle: Promise<number>,
|
||||
io: HeadlessIo,
|
||||
): Promise<TurnOutcome> {
|
||||
let started = false
|
||||
let text = ''
|
||||
let reason: string = 'error'
|
||||
void (async () => {
|
||||
let observedSeq = -1
|
||||
let resolveProgress: (() => void) | undefined
|
||||
const streamDone = (async () => {
|
||||
try {
|
||||
for await (const frame of frames) {
|
||||
const payload = frame.payload
|
||||
if (payload.type === 'stream/error') return
|
||||
if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue
|
||||
const event = payload.event
|
||||
observedSeq = event.seq
|
||||
resolveProgress?.()
|
||||
resolveProgress = undefined
|
||||
if (event.type === 'turn/start') {
|
||||
started = true
|
||||
continue
|
||||
@@ -121,7 +125,12 @@ async function consumeUntilIdle(
|
||||
io.stderr.write(`dsh: event stream failed: ${String(error)}\n`)
|
||||
}
|
||||
})()
|
||||
await idle
|
||||
const streamEnded = streamDone.then(() => 'ended' as const)
|
||||
const idleSeq = await idle
|
||||
while (observedSeq < idleSeq) {
|
||||
const progress = new Promise<'progress'>((resolve) => { resolveProgress = () => { resolve('progress') } })
|
||||
if (await Promise.race([progress, streamEnded]) === 'ended') break
|
||||
}
|
||||
return { text, reason }
|
||||
}
|
||||
|
||||
@@ -154,9 +163,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// port of this runner must replace it with a wire-visible idle signal.
|
||||
const abort = new AbortController()
|
||||
const frames = api.events.mux({}, abort.signal)
|
||||
const idle = new Promise<void>((resolve) => {
|
||||
const idle = new Promise<number>((resolve) => {
|
||||
ctx.on('agent/status', ({ agent, status }) => {
|
||||
if (agent.id === created.sessionId && status === 'idle') resolve()
|
||||
if (agent.id === created.sessionId && status === 'idle') resolve(agent.session.seq - 1)
|
||||
})
|
||||
})
|
||||
const done = consumeUntilIdle(frames, created.sessionId, idle, io)
|
||||
|
||||
@@ -21,26 +21,45 @@ function stamped(event: ScriptedEvent): ScriptedEvent {
|
||||
|
||||
interface RpcShapedRequest { rpcId: string }
|
||||
|
||||
interface ScriptedApiOptions {
|
||||
promptFails?: boolean
|
||||
framesAfterPrompt?: boolean
|
||||
onPrompt?: () => void
|
||||
}
|
||||
|
||||
/** Build a fake apiProxy (echoing rpcIds like the real gateway) whose mux stream replays `events` for the created session. */
|
||||
function scriptedApi(events: ScriptedEvent[], options: { promptFails?: boolean } = {}): unknown {
|
||||
function scriptedApi(events: ScriptedEvent[], options: ScriptedApiOptions = {}): unknown {
|
||||
let releaseFrames = (): void => {}
|
||||
const framesReady = options.framesAfterPrompt === true
|
||||
? new Promise<void>((resolve) => { releaseFrames = resolve })
|
||||
: Promise.resolve()
|
||||
const prepared = events.map((event) => {
|
||||
if (event.type === 'stream/error') return { streamError: true } as const
|
||||
const { sessionId = 'S1', ...rest } = event
|
||||
return { streamError: false, sessionId, event: stamped(rest) } as const
|
||||
})
|
||||
return {
|
||||
sessions: {
|
||||
create: (request: RpcShapedRequest) =>
|
||||
Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { sessionId: 'S1' } } }),
|
||||
prompt: (request: RpcShapedRequest) => Promise.resolve(options.promptFails === true
|
||||
// A code from the closed wire union: the carrier schema rejects invented codes.
|
||||
? { rpcId: request.rpcId, result: { ok: false, error: { code: 'agent-busy', message: 'agent is busy', details: { reason: 'test' } } } }
|
||||
: { rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } }),
|
||||
prompt: (request: RpcShapedRequest) => {
|
||||
releaseFrames()
|
||||
options.onPrompt?.()
|
||||
return Promise.resolve(options.promptFails === true
|
||||
// A code from the closed wire union: the carrier schema rejects invented codes.
|
||||
? { rpcId: request.rpcId, result: { ok: false, error: { code: 'agent-busy', message: 'agent is busy', details: { reason: 'test' } } } }
|
||||
: { rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } })
|
||||
},
|
||||
},
|
||||
events: {
|
||||
mux: async function* () {
|
||||
for (const event of events) {
|
||||
if (event.type === 'stream/error') {
|
||||
await framesReady
|
||||
for (const item of prepared) {
|
||||
if (item.streamError) {
|
||||
yield { rpcId: 'e', payload: { type: 'stream/error', error: { code: 'cancelled', message: 'stream broke', details: {} } } }
|
||||
continue
|
||||
}
|
||||
const { sessionId = 'S1', ...rest } = event
|
||||
yield { rpcId: 'e', payload: { type: 'session/event', sessionId, event: stamped(rest) } }
|
||||
yield { rpcId: 'e', payload: { type: 'session/event', sessionId: item.sessionId, event: item.event } }
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -51,7 +70,10 @@ function scriptedApi(events: ScriptedEvent[], options: { promptFails?: boolean }
|
||||
* Mount the runner against a scripted API, emit the idle transition after the
|
||||
* scripted frames drain, and wait for its exit request.
|
||||
*/
|
||||
async function run(events: ScriptedEvent[], options: { promptFails?: boolean } = {}): Promise<{ code: number; out: string; err: string }> {
|
||||
async function run(
|
||||
events: ScriptedEvent[],
|
||||
options: { promptFails?: boolean; framesAfterPrompt?: boolean; idleInPrompt?: boolean } = {},
|
||||
): Promise<{ code: number; out: string; err: string }> {
|
||||
const ctx = new Context()
|
||||
let out = ''
|
||||
let err = ''
|
||||
@@ -63,16 +85,25 @@ async function run(events: ScriptedEvent[], options: { promptFails?: boolean } =
|
||||
}
|
||||
ctx.provide('headlessIo', io)
|
||||
})
|
||||
ctx.provide('apiProxy', scriptedApi(events, options) as never)
|
||||
const emitIdle = (): void => {
|
||||
ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' })
|
||||
}
|
||||
ctx.provide('apiProxy', scriptedApi(events, {
|
||||
...options.promptFails === undefined ? {} : { promptFails: options.promptFails },
|
||||
...options.framesAfterPrompt === undefined ? {} : { framesAfterPrompt: options.framesAfterPrompt },
|
||||
...options.idleInPrompt === true ? { onPrompt: emitIdle } : {},
|
||||
}) as never)
|
||||
ctx.provide('httpServer', { port: 12345 } as never)
|
||||
apply(ctx, { task: 'do the thing' })
|
||||
// Quiescence is out of band: give the scripted stream a beat to drain, then
|
||||
// flip the agent idle exactly as the loop would. Foreign agents and
|
||||
// non-idle transitions must not settle the run.
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
ctx.emit('agent/status', { agent: { id: 'OTHER' } as Agent, status: 'idle' })
|
||||
ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'running' })
|
||||
ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'idle' })
|
||||
if (options.idleInPrompt !== true) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
ctx.emit('agent/status', { agent: { id: 'OTHER' } as Agent, status: 'idle' })
|
||||
ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'running' })
|
||||
emitIdle()
|
||||
}
|
||||
const code = await exited
|
||||
await ctx.fiber.dispose()
|
||||
return { code, out, err }
|
||||
@@ -106,6 +137,15 @@ describe('headless runner', () => {
|
||||
expect(err).toContain('observing at http://127.0.0.1:12345')
|
||||
})
|
||||
|
||||
it('consumes through the idle sequence when queued frames arrive after the status transition', async () => {
|
||||
const { code, out } = await run(
|
||||
[messageTurn, text(1, 'race-free answer'), end(1, 'completed')],
|
||||
{ framesAfterPrompt: true, idleInPrompt: true },
|
||||
)
|
||||
expect(code).toBe(0)
|
||||
expect(out).toBe('race-free answer\n')
|
||||
})
|
||||
|
||||
it('exits 1 when the final turn ends for any other reason', async () => {
|
||||
const { code } = await run([messageTurn, end(1, 'aborted')])
|
||||
expect(code).toBe(1)
|
||||
@@ -168,7 +208,7 @@ describe('headless runner', () => {
|
||||
ctx.provide('httpServer', { port: 1 } as never)
|
||||
apply(ctx, { task: 't' })
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'idle' })
|
||||
ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' })
|
||||
expect(await exited).toBe(1)
|
||||
expect(err).toContain('event stream failed')
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
@@ -149,6 +149,10 @@
|
||||
- id: ui-conversation
|
||||
name: '@deepseek-ai/dsh-client-ui-conversation'
|
||||
|
||||
# Tool call tree, generic fallback, and keyed business Tool views.
|
||||
- id: ui-tool
|
||||
name: '@deepseek-ai/dsh-client-ui-tool'
|
||||
|
||||
# Turn tail: the produced-files row under each closing assistant message.
|
||||
# Remove this entry to turn the surface off; the tail hole renders empty.
|
||||
- id: ui-deliverables
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-tool": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",
|
||||
|
||||
Reference in New Issue
Block a user