From 6475e518255b6c70b5994a7ace0ae563beb220db Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:08:53 +0800 Subject: [PATCH] fix(goal-session): discriminate plugin turn triggers --- ...-07-21-log-backed-session-titles.i18n.yaml | 4 +-- .../2026-07-21-log-backed-session-titles.md | 2 +- ...2026-07-21-log-backed-session-titles.zh.md | 2 +- packages/goal/goal-session/README.md | 2 +- packages/goal/goal-session/src/index.ts | 14 +++++--- .../goal-session/tests/goal-session.spec.ts | 32 +++++++++++++++++++ 6 files changed, 47 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml index 3906b08f01..bb6fc53670 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.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 -2026-07-21-log-backed-session-titles.md: 0ab8f5b42004892c3ca6d6598cfacd57fbc9300c -2026-07-21-log-backed-session-titles.zh.md: 7dfbc5cc378b8cf16743fceb594d8bcb47477637 +2026-07-21-log-backed-session-titles.md: d322b58c32d44c07d8d18618546225e7001ab025 +2026-07-21-log-backed-session-titles.zh.md: cb70533d52310574c56c7b0177b9b1ba34d7176a diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md index 0ab8f5b420..d322b58c32 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md @@ -18,7 +18,7 @@ The [`session-title` capability family](../../../../packages/session-title/READM Every accepted revision is a log-only `session/title` event. Its payload contains normalized non-empty text, the exact eligible human `user/message` seqs used to derive it, and either fallback provenance or the registered provider id plus optional provider/model route. Before an auxiliary title-model dispatch, the shared helper appends a log-only `session/title-llm-request` event containing the title-provider id, exact source seqs, route, system prompt, messages, and output-token cap; a later generation failure leaves the request auditable. The dispatched envelope is deep-frozen to preserve exact agreement with that record but carries no process-local agent-loop request identity, so loop-only reconstruction checks do not compare it with the main conversation header. Validation failures that never reach dispatch create no request event. `foldSessionTitle()` selects the latest title event and adds that event's seq and timestamp as `SessionTitleSnapshot`. Neither event enters `session.surface` or `deriveMessages()`. -The core session package exposes `ctx.sessions.appendOutOfBand()` only for plugin event types whose owners also declaration-merge an `OutOfBandSessionEventMap` marker. An open turn receives the log-only event directly and owns its normal checkpoint. A closed log receives `turn/start → event → turn/end` under the plugin's trigger, followed by an awaited flush. Once the synthetic turn opens, target-append failure still attempts to close and flush it; detach is deferred until the sequence settles. Session titles contribute the `session-title` zero-step trigger and opt both title event types into this seam. +The core session package exposes `ctx.sessions.appendOutOfBand()` only for plugin event types whose owners also declaration-merge an `OutOfBandSessionEventMap` marker. An open turn receives the log-only event directly and owns its normal checkpoint. A closed log receives `turn/start → event → turn/end` under the plugin's trigger, followed by an awaited flush. Once the synthetic turn opens, target-append failure still attempts to close and flush it; detach is deferred until the sequence settles. Session titles contribute the source-free `session-title` zero-step trigger and opt both title event types into this seam. No message caused that trigger, so consumers of the merge-extensible `TurnTriggerMap` discriminate `kind` before reading variant fields; goal-round admission, for example, ignores every non-`message` trigger. ### Input and asynchronous timing diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md index 7dfbc5cc37..cb70533d52 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md @@ -18,7 +18,7 @@ Status: implemented 每个已接受的修订都是纯日志 `session/title` 事件。其载荷包含规范化后的非空文本、用于派生标题的所有合格且来源为人类的 `user/message` 的准确 seq,以及回退来源信息,或已注册的提供方 id 加可选的提供方和模型路由。辅助标题模型发起调用前,共享辅助组件会追加一个纯日志 `session/title-llm-request` 事件,其载荷包含标题提供方 id、准确的源 seq、路由、系统提示词、消息和输出 token 上限;即使后续生成失败,这次请求仍可审计。发送的请求信封经过深度冻结,以确保其与该记录精确一致,但它有意不携带进程本地的 agent loop(智能体循环)请求身份,因此仅针对 agent loop 的重建检查不会将它与主对话请求头进行比较。未进入调用阶段的验证失败不会创建请求事件。`foldSessionTitle()` 选择最新的标题事件,并将该事件的 seq 和时间戳加入 `SessionTitleSnapshot`。这两类事件都不会进入 `session.surface` 或 `deriveMessages()`。 -核心会话包通过 `ctx.sessions.appendOutOfBand()` 暴露这一接口,但只允许所属插件同时通过声明合并向 `OutOfBandSessionEventMap` 添加标记的插件事件类型使用。开放轮次会直接接收纯日志事件,并负责其常规检查点。已关闭的日志会在该插件的触发器下接收 `turn/start → event → turn/end`,随后等待刷写完成。合成轮次一旦开启,即使目标追加失败,系统仍会尝试将其关闭并刷写;整个序列完成前会延迟 detach。会话标题提供 `session-title` 零步骤触发器,并让这两类标题事件都使用这一服务边界。 +核心会话包通过 `ctx.sessions.appendOutOfBand()` 暴露这一接口,但只允许所属插件同时通过声明合并向 `OutOfBandSessionEventMap` 添加标记的插件事件类型使用。开放轮次会直接接收纯日志事件,并负责其常规检查点。已关闭的日志会在该插件的触发器下接收 `turn/start → event → turn/end`,随后等待刷写完成。合成轮次一旦开启,即使目标追加失败,系统仍会尝试将其关闭并刷写;整个序列完成前会延迟 detach。会话标题提供不带消息来源的 `session-title` 零步骤触发器,并让这两类标题事件都使用这一服务边界。该触发器并非由消息引起,因此可合并扩展的 `TurnTriggerMap` 的消费方在读取变体字段前,会先根据 `kind` 判别类型;例如,目标轮次准入会忽略所有非 `message` 触发器。 ### 输入与异步时序 diff --git a/packages/goal/goal-session/README.md b/packages/goal/goal-session/README.md index 2ad4b161a3..2ded72ca78 100644 --- a/packages/goal/goal-session/README.md +++ b/packages/goal/goal-session/README.md @@ -21,7 +21,7 @@ The plugin has no tunable configuration. `maxGoalRounds` belongs to the goal def When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves `roundsStarted + 1` for the current `{ goalId, revision }`. It queues one `` prompt with `GoalMessageSource`. Admission through `agent/prompt-submit` verifies the complete queued record and current goal both before and after downstream prompt hooks; only the accepted `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number. -One goal round owns one ordinary session turn, and that turn may contain several model/tool steps. Human messages remain ordinary turns and do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until that work settles; a pending automatic prompt in a mixed batch is rejected and re-reserved only after the agent becomes idle. +One goal round owns one ordinary session turn, and that turn may contain several model/tool steps. The driver pairs a reservation only with a `message` turn carrying its exact `GoalMessageSource`; merge-extensible plugin turn triggers do not admit or replace that reservation. Human messages remain ordinary turns and do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until that work settles; a pending automatic prompt in a mixed batch is rejected and re-reserved only after the agent becomes idle. The retained prompt names the JSON-quoted objective and `round/maxGoalRounds`, treats the current workspace, tool results, and durable session state as authoritative, requires evidence before completion, and tells the model to leave the goal active when work remains. Quoting preserves multiline or tag-like objective text as data. Goal lifecycle mutations still require the independent authority checks in `dsh-tool-goal`. diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index 24364a4983..29ee650eb9 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -345,11 +345,17 @@ export function apply(ctx: Context): void { switch (event.type) { case 'turn/start': state.openTurn = event.data.turn - if (state.attempt !== undefined && isGoalRoundSource(event.data.trigger.source) - && sameRound(event.data.trigger.source, state.attempt)) { - state.attempt.turn = event.data.turn + switch (event.data.trigger.kind) { + case 'message': + if (state.attempt !== undefined && isGoalRoundSource(event.data.trigger.source) + && sameRound(event.data.trigger.source, state.attempt)) { + state.attempt.turn = event.data.turn + } + return + default: + // Injection and merge-extensible plugin triggers cannot admit a queued goal message. + return } - return case 'user/message': if (state.attempt !== undefined && isGoalRoundSource(event.data.source) && sameRound(event.data.source, state.attempt)) { diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index f43cb1afae..c8540297e4 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -12,6 +12,13 @@ import { SessionId } from '@deepseek-ai/dsh-session' import type { TurnEndReason } from '@deepseek-ai/dsh-session' import * as goalSession from '../src/index.ts' +declare module '@deepseek-ai/dsh-session' { + interface TurnTriggerMap { + /** Test-only plugin turn with no message source. */ + 'test-metadata': { kind: 'test-metadata' } + } +} + type ScriptEntry = StreamChunk[] | Error | 'hang' | ((options: GenerateOptions) => StreamChunk[]) /** Small request-recording adapter with controllable failure and cancellation. */ @@ -323,6 +330,31 @@ describe('same-session goal driving', () => { expect(requestText(test.adapter.requests[1]!)).toContain('') }) + it('ignores plugin-owned turn triggers while a goal round is queued', async () => { + const test = await harness([textResponse('goal answer')]) + const warnings: string[] = [] + test.ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof test.ctx.logger.warn + let inserted = false + test.ctx.on('agent/queued', (agent, _content, info) => { + if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return + inserted = true + const lastStart = agent.session.events.findLast(event => event.type === 'turn/start') + const turn = (lastStart?.data.turn ?? 0) + 1 + agent.session.append('turn/start', { + turn, + trigger: { kind: 'test-metadata' }, + }) + agent.session.append('turn/end', { turn, reason: { kind: 'completed' } }) + }) + test.ctx.goals.create(test.agent, { objective: 'ignore metadata', maxGoalRounds: 1 }) + + await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked') + + expect(inserted).toBe(true) + expect(test.adapter.requests).toHaveLength(1) + expect(warnings.some(warning => warning.includes('session/event listener threw'))).toBe(false) + }) + it('makes a reserved round stale when a listener queues human work behind it', async () => { const test = await harness([textResponse('human batch'), textResponse('later goal')]) let inserted = false