From 76acba476b2b9417599a2bafc97e7c16e89fcacb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 15 Oct 2026 20:22:09 +0800 Subject: [PATCH] fix(headless): retain explicit effort on same-route resume --- packages/bundle/headless/README.i18n.yaml | 4 +-- packages/bundle/headless/README.md | 2 +- packages/bundle/headless/README.zh.md | 2 +- packages/bundle/headless/src/index.ts | 4 +-- .../bundle/headless/tests/headless.spec.ts | 30 +++++++++++++++++-- 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 95b4b286aa..224fb49c67 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/README.i18n.yaml @@ -24,8 +24,8 @@ en: ca8217837ee9c698 zh: fa20daf27d4e09ef /deepseek-ai-dsh-headless/use-this-package/choosing-the-session-identity: - en: 8d009bb9ed21b946 - zh: d276dd3f2c4ab43d + en: 0ef0c533ef9aebea + zh: fcb267b61bd23437 /deepseek-ai-dsh-headless/use-this-package/machine-readable-output: en: 2804a96e88857b16 zh: fe20dc0fb9962785 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 26d7cebdd9..1688aa5720 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -73,7 +73,7 @@ The restored store uses [saved credentials before environment fallbacks](../../c Every invocation defaults to a fresh `session-` identity, which `--json` reports in its opening `session` event. Pass `--session-id ` to continue that conversation: the runner adopts the persisted Session with that id, and an id with no stored Session fails before the task runs rather than quietly opening an empty history. Adoption requires the composed `sessionPersistence` and `sessionQuery` services, so a profile that omits either fails loudly instead of returning an id whose history dies with the process. An Agent already live under the requested id in this process is refused: another owner may still drive it, so the runner cannot claim an exclusive run interval over it. The identity is opaque, so the exact string is used, whitespace included. The working directory is resolved through the mounted filesystem provider (`fs.resolve('.')` and `fs.processPath()`), or the process cwd when no filesystem service is mounted; new Sessions record that directory. Adoption compares the recorded cwd with the same provider-resolved directory and refuses a Session that is a subagent or forked session, that recorded no working directory, that runs under an agent preset this profile does not compose, or whose preset record is malformed — the check reads the preset the Session log currently records, so a Session that switched preset while blank is rejected too. A supervisor therefore cannot silently drive someone else's conversation under a different composition; any mismatch fails before the task runs. -On adoption, the runner records the current deployment selection before submitting the task. An existing request keeps its own captured route; subsequent steps read the shared Session selection. +On adoption, the runner records the current deployment route before submitting the task. An omitted effort preserves an explicit saved effort only for the same route. A step already prepared in this process keeps its captured route; subsequent steps read the shared Session selection. ### Machine-readable output diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index e3b90c2cef..955b77020a 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -73,7 +73,7 @@ agent 会完成该任务,把提供方的每个非空推理(reasoning)增 每次调用默认使用全新的 `session-` 标识,`--json` 会在开头的 `session` 事件里报告它。传入 `--session-id ` 延续这段对话:runner 沿用该 id 对应的持久化 Session,而该 id 没有持久化 Session 时会在任务运行前失败,而不是悄悄开出一段空历史。沿用要求已组合 `sessionPersistence` 与 `sessionQuery` 服务,因此缺少任一服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id。本进程中已存在持有该 id 的存活 Agent 时会被拒绝:它的原 owner 可能仍在驱动它,runner 无法取得独占的运行区间。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。工作目录通过已挂载的文件系统提供方解析(`fs.resolve('.')` 与 `fs.processPath()`);未挂载文件系统服务时使用进程 cwd,新 Session 会记录该目录。沿用会将已记录 cwd 与同一提供方解析出的目录比较,并拒绝子 agent 或 fork 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。 -接管会话时,运行器会在提交任务前记录当前部署选择。已有请求保留自身捕获的路由,后续步骤读取共享的 Session 选择。 +接管会话时,运行器会在提交任务前记录当前部署路由。未声明推理强度时,仅在路由相同的情况下保留已保存的显式强度。本进程中已准备的步骤保留捕获的路由,后续步骤读取共享的 Session 选择。 ### 机器可读输出 diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index b44664a91b..ebac05d977 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -14,7 +14,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { brandString } from '@deepseek-ai/dsh-brand' -import { installModelSwitchNotice, recordModelSelection } from '@deepseek-ai/dsh-agent' +import { installModelSwitchNotice, recordDeclaredRoute } from '@deepseek-ai/dsh-agent' import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' import type {} from '@deepseek-ai/dsh-fs' @@ -339,7 +339,7 @@ async function run(ctx: Context, config: Config, io: HeadlessIo): Promise // (@deepseek-ai/dsh-agent-preset-registry README, "Composing a child agent"). const setup = (agentCtx: Context, agent: Agent): void => { // A resumed Session continues with this run's configured model. - recordModelSelection(ctx.sessionProjections, agent, selection) + if (config.sessionId !== undefined) recordDeclaredRoute(ctx.sessionProjections, agent, selection) installModelSwitchNotice(agentCtx) } const sessionId = brandString(config.sessionId ?? `session-${randomUUID()}`) diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 468c1e67a9..54aa1f2050 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -4,7 +4,7 @@ import { Readable } from 'node:stream' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { currentModelSelection } from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle, @@ -13,7 +13,7 @@ import type { ResumeAgentOptions, } from '@deepseek-ai/dsh-agent' import AgentDefaultModelConfig from '@deepseek-ai/dsh-agent-default-model' -import { LlmAttemptId, ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage, type MessageId, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { LlmAttemptId, ReasoningEffortId, ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage, type MessageId, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session' @@ -738,6 +738,32 @@ describe('headless runner', () => { await test.ctx.fiber.dispose() }) + it.each(['test-model', 'other-model'])('restores explicit effort only for the declared route (%s)', async (savedModel) => { + const test = await bench({ + afterPrompt(session, message, agent) { + expect(currentModelSelection(test.ctx.sessionProjections, agent)).toEqual({ + provider: 'test-provider', model: 'test-model', + ...savedModel === 'test-model' ? { reasoningEffort: 'high' } : {}, + }) + appendTurn(session, 1, message, 'resumed answer', true) + }, + }, { + sessionId: 'session-effort', + observe: () => Promise.resolve({ + header: { cwd: process.cwd(), origin: 'user' }, + events: [], + [Symbol.dispose]() {}, + }), + }) + try { + const session = test.ctx.sessions.create(brandString('session-effort'), { meta: { cwd: process.cwd() } }) + session.append('model/selection', { provider: 'test-provider', model: savedModel, reasoningEffort: ReasoningEffortId('high') }) + expect(await test.run()).toMatchObject({ code: 0, out: 'resumed answer\n', err: '' }) + } finally { + await test.ctx.fiber.dispose() + } + }) + it('rejects a persisted Session recorded in another working directory', async () => { const test = await bench({ afterPrompt: () => {} }, { sessionId: 'session-exact',