diff --git a/.agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.i18n.yaml index b21b0943c6..00e79dae97 100644 --- a/.agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.md -2026-08-22-fire-and-forget-webhook-sessions.md: 8d61e623343cc765ebed34e22a76c955b48c34e6 -2026-08-22-fire-and-forget-webhook-sessions.zh.md: f4b6e3e5d7192acfe59bbb559e6cb5b99571b6cb +2026-08-22-fire-and-forget-webhook-sessions.md: f72956a282e988ff017e647e7ad97ed6138a6319 +2026-08-22-fire-and-forget-webhook-sessions.zh.md: fa64298d0ea245a56c9206b74b0f0f87a9f88f7f diff --git a/.agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.md b/.agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.md index 8d61e62334..f72956a282 100644 --- a/.agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.md +++ b/.agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.md @@ -26,7 +26,7 @@ Patch loading anchors relative plugin names in inserted rows to the patch file. ## Session creation -A rule result names a local Workspace path, title, text prompt, agent preset, permission preset, and optional complete model selection. The runtime validates presets before mutation, resolves or creates the canonical Workspace, creates the Agent with that path as Session cwd, mounts the preset before publication, and attaches the Session before admitting the prompt. +A rule result names a local Workspace path, title, text prompt, agent preset, permission preset, and optional explicit provider/model route with an output cap. Without that route, the runtime snapshots the complete live default, including reasoning effort, until the first request records its durable header. It validates presets before mutation, resolves or creates the canonical Workspace, creates the Agent with that path as Session cwd, mounts the preset before publication, and attaches the Session before admitting the prompt. The initial follow-up is an ordinary durable user-role message with webhook provider, source, delivery, and rule provenance. Its inbox insertion is the webhook operation's last boundary. Ordinary Session persistence and Agent lifecycle own later work; the runtime neither flushes specially nor waits for a turn. diff --git a/.agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.zh.md b/.agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.zh.md index f4b6e3e5d7..fa64298d0e 100644 --- a/.agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.zh.md @@ -26,7 +26,7 @@ Patch 加载会把插入行中的相对插件名锚定到 patch 文件。因而 ## Session creation -规则结果会指定本地 Workspace 路径、标题、文本提示词、agent preset、permission preset 与可选完整模型选择。runtime 会在变更状态前验证 preset,解析或创建规范 Workspace,以该路径作为 Session cwd 创建 Agent,在发布前挂载 preset,并在接纳提示词前附加 Session。 +规则结果会指定本地 Workspace 路径、标题、文本提示词、agent preset、permission preset,以及可选的明确提供方/模型路由与输出上限。没有明确路由时,runtime 会快照包含推理强度的完整实时默认选择,直到首个请求记录其持久 header。runtime 会在变更状态前验证 preset,解析或创建规范 Workspace,以该路径作为 Session cwd 创建 Agent,在发布前挂载 preset,并在接纳提示词前附加 Session。 初始 follow-up 是普通持久 user-role 消息,并携带 webhook 提供方、来源、交付和规则来源信息。它的 inbox 插入是 webhook 操作的最后边界。之后的工作由普通 Session persistence 与 Agent 生命周期拥有;runtime 既不执行特殊 flush,也不等待轮次。 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 98155d2771..d0e127d480 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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 docs/event-producer-consumer.md -event-producer-consumer.md: cddf5668f75311827d1872684e89f6bb2f3647c5 -event-producer-consumer.zh.md: ee758ff76ce5453b97b95d577b95cd701790a594 +event-producer-consumer.md: a9cfc1201c7d851405c99fce2e8177297f0cfe5f +event-producer-consumer.zh.md: ecccc50fc9b3cd2fd730e30ec2636c773334b00d diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index cddf5668f7..a9cfc1201c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -16,7 +16,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index ee758ff76c..ecccc50fc9 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -18,7 +18,7 @@ | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server` | diff --git a/docs/subsystems/webhook.i18n.yaml b/docs/subsystems/webhook.i18n.yaml index 6aae52cb18..2499c1d1a6 100644 --- a/docs/subsystems/webhook.i18n.yaml +++ b/docs/subsystems/webhook.i18n.yaml @@ -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 docs/subsystems/webhook.md -webhook.md: 154f4adcc03bd5ce372322a06f8bd03bc27df2dd -webhook.zh.md: 9ce4618b78fcd17d8d18e449fec28d81b89af62b +webhook.md: a6257de3e0b81bf7d34d6ce40848303983bbf8b7 +webhook.zh.md: bdace07eb9ada471174548321e53d41943abb98e diff --git a/docs/subsystems/webhook.md b/docs/subsystems/webhook.md index 154f4adcc0..a6257de3e0 100644 --- a/docs/subsystems/webhook.md +++ b/docs/subsystems/webhook.md @@ -14,7 +14,7 @@ The Webhook subsystem turns authenticated external deliveries into optional ordi `WebhookRule` contains a unique id, provider kind, and `run(delivery, signal)`. The callback may execute arbitrary trusted code. It returns `null` or one `WebhookSessionRequest`, and it must observe the signal for asynchronous work that should stop when the registration unloads. -`WebhookSessionRequest` requires an absolute `workspacePath`, title, text prompt, agent preset, and permission preset. Optional `model` names a complete provider/model pair plus optional output-token cap; omission reads the current deployment default. +`WebhookSessionRequest` requires an absolute `workspacePath`, title, text prompt, agent preset, and permission preset. Optional `model` names an explicit provider/model route plus optional output-token cap and uses that adapter's reasoning default. Omission snapshots the complete current deployment selection, including reasoning effort, until the first request records its durable header. ## Fire-and-forget dispatch diff --git a/docs/subsystems/webhook.zh.md b/docs/subsystems/webhook.zh.md index 9ce4618b78..bdace07eb9 100644 --- a/docs/subsystems/webhook.zh.md +++ b/docs/subsystems/webhook.zh.md @@ -14,7 +14,7 @@ Webhook 子系统会把已通过身份验证的外部交付转换为可选的普 `WebhookRule` 包含唯一 id、提供方种类与 `run(delivery, signal)`。回调可以执行任意受信任代码。它返回 `null` 或一个 `WebhookSessionRequest`,并且异步工作若应在注册卸载时停止,就必须观察 signal。 -`WebhookSessionRequest` 要求绝对 `workspacePath`、标题、文本提示词、agent preset 与 permission preset。可选 `model` 会指定完整提供方/模型组合与可选输出 token 上限;省略时读取当前部署默认值。 +`WebhookSessionRequest` 要求绝对 `workspacePath`、标题、文本提示词、agent preset 与 permission preset。可选 `model` 会指定明确的提供方/模型路由与可选输出 token 上限,并使用该适配器的默认推理强度。省略时会快照包含推理强度的完整当前部署选择,直到首个请求记录持久 header。 ## Fire-and-forget 分发 diff --git a/packages/webhook/webhook/README.i18n.yaml b/packages/webhook/webhook/README.i18n.yaml index dd268ad7de..5f6dca50de 100644 --- a/packages/webhook/webhook/README.i18n.yaml +++ b/packages/webhook/webhook/README.i18n.yaml @@ -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/webhook/webhook/README.md -README.md: 940d03c4088b1dd9ec52e4f8b8c2d03df1fa9d41 -README.zh.md: b5ae9649a1ee0275ac7cf39cd2b194cf32695018 +README.md: 79337c7edb708f6862e0dc9b8ce98ebb491daf34 +README.zh.md: 90555a71eea8485766e2d96baccff07303daacf0 diff --git a/packages/webhook/webhook/README.md b/packages/webhook/webhook/README.md index 940d03c408..79337c7edb 100644 --- a/packages/webhook/webhook/README.md +++ b/packages/webhook/webhook/README.md @@ -14,7 +14,7 @@ Registration is an effect. Its awaitable disposer first hides the rule, then abo ## Session request -`WebhookSessionRequest` requires `workspacePath`, `title`, `prompt`, `agentPreset`, and `permissionPreset`; an optional complete model selection names provider, model, and output-token cap together. Omission reads the current deployment default. +`WebhookSessionRequest` requires `workspacePath`, `title`, `prompt`, `agentPreset`, and `permissionPreset`; optional `model` names an explicit provider/model route plus an output-token cap. An explicit route uses its adapter's reasoning default. Omission snapshots the complete current deployment selection, including reasoning effort, until the first request records its durable header; later Web model changes retain the ordinary session behavior. The runtime validates presets before mutation, resolves or creates the canonical Workspace, creates an Agent with that Workspace path as `SessionHeader.cwd`, mounts the agent preset before publication, and attaches the Session before applying permissions, title, and prompt. Failed attachment disposes the unpublished action. A later pre-prompt failure detaches the Workspace and disposes the Agent on a best-effort rollback. diff --git a/packages/webhook/webhook/README.zh.md b/packages/webhook/webhook/README.zh.md index b5ae9649a1..90555a71ee 100644 --- a/packages/webhook/webhook/README.zh.md +++ b/packages/webhook/webhook/README.zh.md @@ -14,7 +14,7 @@ ## Session 请求 -`WebhookSessionRequest` 要求 `workspacePath`、`title`、`prompt`、`agentPreset` 与 `permissionPreset`;可选的完整模型选择会同时指定提供方、模型与输出 token 上限。省略时读取当前部署默认值。 +`WebhookSessionRequest` 要求 `workspacePath`、`title`、`prompt`、`agentPreset` 与 `permissionPreset`;可选 `model` 会指定明确的提供方/模型路由与输出 token 上限。明确路由使用其适配器的默认推理强度。省略时会快照包含推理强度的完整当前部署选择,直到首个请求记录持久 header;之后的 Web 模型变更保留普通 Session 行为。 runtime 会在变更状态前验证 preset,解析或创建规范 Workspace,以该 Workspace 路径作为 `SessionHeader.cwd` 创建 Agent,在发布前挂载 agent preset,并在应用权限、标题与提示词前附加 Session。附加失败会释放尚未提交动作的 Agent。之后若在提示词前失败,则以尽力而为方式脱离 Workspace 并释放 Agent。 diff --git a/packages/webhook/webhook/src/session.ts b/packages/webhook/webhook/src/session.ts index bf784081e2..eef687ce4e 100644 --- a/packages/webhook/webhook/src/session.ts +++ b/packages/webhook/webhook/src/session.ts @@ -3,10 +3,10 @@ import type { Context } from '@deepseek-ai/cordis' import { randomUUID } from 'node:crypto' import { isAbsolute } from 'node:path' -import type {} from '@deepseek-ai/dsh-agent' +import type { ModelSelection } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' import type {} from '@deepseek-ai/dsh-agent-presets' -import { boundContextSummary, createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' +import { boundContextSummary, createUserMessage, errorChain, type LlmCallConfig } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-permission-presets' import { SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-title' @@ -21,6 +21,7 @@ interface ResolvedWebhookSessionRequest { readonly prompt: string readonly agentPreset: string readonly permissionPreset: string + readonly modelSelection: ModelSelection readonly agentOptions: { readonly provider: string readonly model: string @@ -57,9 +58,11 @@ function resolveRequest(ctx: Context, input: WebhookSessionRequest): ResolvedWeb throw new TypeError('webhook Session request model must be an object') } let agentOptions: ResolvedWebhookSessionRequest['agentOptions'] + let modelSelection: ModelSelection if (model === undefined) { const selected = ctx.agentDefaultModel.currentSelection() agentOptions = { provider: selected.provider, model: selected.model } + modelSelection = { ...selected } } else { const modelRecord = model as Record const provider = requiredString(modelRecord, 'provider') @@ -74,8 +77,9 @@ function resolveRequest(ctx: Context, input: WebhookSessionRequest): ResolvedWeb model: modelId, ...(maxTokens === undefined ? {} : { maxTokens }), } + modelSelection = { provider, model: modelId } } - return { workspacePath, title, prompt, agentPreset, permissionPreset, agentOptions } + return { workspacePath, title, prompt, agentPreset, permissionPreset, modelSelection, agentOptions } } /** Log a rollback failure without replacing the operation's original failure. */ @@ -83,6 +87,24 @@ function reportRollbackFailure(ctx: Context, subject: string, error: unknown): v ctx.logger.warn(`webhook: ${subject} rollback failed: ${errorChain(error)}`) } +/** Apply the creation-time selection until its first durable request header exists. */ +function installInitialModelSelection(agentCtx: Context, selection: ModelSelection): void { + agentCtx.on('agent/request', async (_payload, next): Promise => { + const resolved = await next() + const agent = agentCtx.agent + /* v8 ignore next -- AgentRegistry setup always provides the unpublished scoped Agent. */ + if (agent === undefined) throw new Error('webhook Session setup has no scoped Agent') + if (agent.session.requestHeader() !== undefined + || resolved.provider !== selection.provider + || resolved.model !== selection.model) return resolved + const { reasoningEffort: _inheritedEffort, ...withoutInheritedEffort } = resolved + return { + ...withoutInheritedEffort, + ...selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort }, + } + }) +} + /** * Create, attach, title, configure, and prompt one ordinary root Session. * Successful prompt admission ends webhook ownership of the operation; the @@ -117,6 +139,7 @@ export async function createWebhookSession( agentOptions: resolved.agentOptions, setup: async (agentCtx) => { await ctx.agentPresets.mount(agentCtx, preset.id) + installInitialModelSelection(agentCtx, resolved.modelSelection) }, }) diff --git a/packages/webhook/webhook/src/types.ts b/packages/webhook/webhook/src/types.ts index 2378f58a0d..158820b274 100644 --- a/packages/webhook/webhook/src/types.ts +++ b/packages/webhook/webhook/src/types.ts @@ -24,7 +24,7 @@ export interface VerifiedWebhookDelivery { readonly receivedAt: number } -/** Optional complete model selection for a webhook-created Agent. */ +/** Optional explicit model route and output cap for a webhook-created Agent. */ export interface WebhookModelSelection { /** Registered provider route. */ readonly provider: string @@ -46,7 +46,7 @@ export interface WebhookSessionRequest { readonly agentPreset: string /** Sandbox and approval preset applied before prompt admission. */ readonly permissionPreset: string - /** Optional explicit model; omission uses the current deployment default. */ + /** Optional explicit route; omission uses the complete current default, including reasoning effort. */ readonly model?: WebhookModelSelection } diff --git a/packages/webhook/webhook/tests/session.spec.ts b/packages/webhook/webhook/tests/session.spec.ts index fbfb5bbcd6..bdb9201fa9 100644 --- a/packages/webhook/webhook/tests/session.spec.ts +++ b/packages/webhook/webhook/tests/session.spec.ts @@ -1,4 +1,5 @@ import type { Context } from '@deepseek-ai/cordis' +import { ReasoningEffortId, type LlmCallConfig } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi } from 'vitest' import { WebhookDeliveryId, @@ -20,6 +21,8 @@ interface SessionHarness { readonly ctx: Context readonly calls: string[] readonly messages: unknown[] + readonly modelListeners: Map + markRequestHeader(): void readonly controller: AbortController readonly request: WebhookSessionRequest } @@ -34,8 +37,14 @@ afterEach(() => { function harness(options: HarnessOptions = {}): SessionHarness { const calls: string[] = [] const messages: unknown[] = [] + const modelListeners = new Map() const controller = new AbortController() - const session = { id: 'webhook-session', header: { cwd: '/workspace' } } + let requestHeader: object | undefined + const session = { + id: 'webhook-session', + header: { cwd: '/workspace' }, + requestHeader: () => requestHeader, + } const agent = { id: 'webhook-session', session, @@ -79,7 +88,7 @@ function harness(options: HarnessOptions = {}): SessionHarness { agentDefaultModel: { currentSelection() { calls.push('default-model') - return { provider: 'default-provider', model: 'default-model', reasoningEffort: 'ignored' } + return { provider: 'default-provider', model: 'default-model', reasoningEffort: 'high' } }, }, agentPresets: { @@ -110,7 +119,13 @@ function harness(options: HarnessOptions = {}): SessionHarness { async create(createOptions: { setup?: (ctx: unknown) => Promise }) { calls.push('agent-create') if (options.failAt === 'agent') throw new Error('agent failed') - await createOptions.setup?.({}) + await createOptions.setup?.({ + agent, + on(event: string, listener: unknown) { + modelListeners.set(event, listener) + return () => {} + }, + }) if (options.abortAt === 'agent') controller.abort(new Error('abort after agent')) return handle }, @@ -127,6 +142,8 @@ function harness(options: HarnessOptions = {}): SessionHarness { ctx: fake as unknown as Context, calls, messages, + modelListeners, + markRequestHeader() { requestHeader = {} }, controller, request: { workspacePath: '/workspace', @@ -158,6 +175,21 @@ async function create(test: SessionHarness, request = test.request): Promise Promise, +) => Promise { + const listener = test.modelListeners.get('agent/request') + if (typeof listener !== 'function') { + throw new Error('webhook Session did not install its initial model selection') + } + return listener as ( + payload: unknown, + next: () => Promise, + ) => Promise +} + describe('webhook Session creation', () => { it('preflights, mounts, attaches, configures, titles, and prompts in order', async () => { const test = harness() @@ -192,6 +224,48 @@ describe('webhook Session creation', () => { const withoutCap = harness() await create(withoutCap, { ...withoutCap.request, model: { provider: 'p', model: 'm' } }) expect(withoutCap.calls).not.toContain('default-model') + await expect(modelRequestListener(withoutCap)(undefined, async () => ({ + provider: 'p', model: 'm', reasoningEffort: ReasoningEffortId('inherited'), + }))).resolves.toEqual({ provider: 'p', model: 'm' }) + }) + + it('preserves default reasoning until the first request header is durable', async () => { + const test = harness() + await create(test) + const request = modelRequestListener(test) + + await expect(request(undefined, async () => ({ + provider: 'other-provider', + model: 'default-model', + reasoningEffort: ReasoningEffortId('other-provider-effort'), + }))).resolves.toMatchObject({ reasoningEffort: 'other-provider-effort' }) + await expect(request(undefined, async () => ({ + provider: 'default-provider', + model: 'other-model', + reasoningEffort: ReasoningEffortId('other-model-effort'), + }))).resolves.toMatchObject({ reasoningEffort: 'other-model-effort' }) + + const routed = await request(undefined, async () => ({ + provider: 'default-provider', + model: 'default-model', + reasoningEffort: ReasoningEffortId('inherited'), + })) as unknown + expect(routed).toEqual({ + provider: 'default-provider', + model: 'default-model', + reasoningEffort: 'high', + }) + + test.markRequestHeader() + await expect(request(undefined, async () => ({ + provider: 'later-provider', + model: 'later-model', + reasoningEffort: ReasoningEffortId('later'), + }))).resolves.toEqual({ + provider: 'later-provider', + model: 'later-model', + reasoningEffort: 'later', + }) }) it.each([