From 5d411f9c4e4c0b74089266d44357595a39ad3d3f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:49:37 +0800 Subject: [PATCH] fix(session-title): preserve agent turn outcomes --- ...-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 +- docs/cordis-catalog/events.md | 8 +-- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 8 +-- packages/core/session/README.md | 1 + packages/core/session/src/index.ts | 21 +++++++ packages/core/session/tests/session.spec.ts | 44 +++++++++++++- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 4 +- .../tests/subagent-inprocess.spec.ts | 32 +++++++++- packages/ui/jsonrpc/README.md | 2 +- packages/ui/jsonrpc/src/server.ts | 6 +- packages/ui/jsonrpc/tests/server.spec.ts | 60 ++++++++++++++++++- 15 files changed, 176 insertions(+), 22 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 eab17eea2b..3906b08f01 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: 0690b09965df516b180652182c15e34c8faf06cc -2026-07-21-log-backed-session-titles.zh.md: f973fdca4897bd57590fb0bc53a20a345144089f +2026-07-21-log-backed-session-titles.md: 0ab8f5b42004892c3ca6d6598cfacd57fbc9300c +2026-07-21-log-backed-session-titles.zh.md: 7dfbc5cc378b8cf16743fceb594d8bcb47477637 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 0690b09965..0ab8f5b420 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 @@ -40,7 +40,7 @@ Automatic provider failures are nonfatal warnings and retain the latest title. E A fork inherits seed title events unchanged, like the rest of its source log. The first-message provider does not automatically retitle a fork. The all-messages provider may append a child-owned revision after a later child prompt, using inherited and new eligible messages. -`ctx.sessionQuery.readTitle()` folds one live-preferred or persisted log without loading titles during `listSessions()`. ACP maps the event to `session_info_update` during both live streaming and load replay, using the event timestamp for `updatedAt`. The TUI uses the latest title as its header subtitle and sets the terminal window title to `` after terminal-safe rendering. +`ctx.sessionQuery.readTitle()` folds one live-preferred or persisted log without loading titles during `listSessions()`. ACP maps the event to `session_info_update` during both live streaming and load replay, using the event timestamp for `updatedAt`. The TUI uses the latest title as its header subtitle and sets the terminal window title to `` after terminal-safe rendering. A synthetic title turn remains a completed durability boundary for the metadata write; consumers reporting agent completion use the core `findLastMessageTurnEnd()` fold so a later title, injection, or other plugin-owned turn cannot replace the preceding message-triggered outcome. ## Alternatives considered 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 f973fdca48..7dfbc5cc37 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 @@ -40,7 +40,7 @@ Status: implemented 与源日志的其他部分相同,fork 会原样继承作为种子的标题事件。首消息提供方不会自动为 fork 重新生成标题。全部消息提供方可以在子会话出现后续提示词后追加一项归子会话所有的修订,并使用继承的合格消息和新增的合格消息。 -`ctx.sessionQuery.readTitle()` 会折叠一份实时优先或已持久化的日志,而不会在 `listSessions()` 期间加载标题。ACP(Agent Client Protocol)会在实时流式输出和加载回放期间把该事件映射到 `session_info_update`,并使用事件时间戳作为 `updatedAt`。TUI 使用最新标题作为其标题栏副标题,并在完成终端安全渲染后,将终端窗口标题设置为 ``。 +`ctx.sessionQuery.readTitle()` 会折叠一份实时优先或已持久化的日志,而不会在 `listSessions()` 期间加载标题。ACP(Agent Client Protocol)会在实时流式输出和加载回放期间把该事件映射到 `session_info_update`,并使用事件时间戳作为 `updatedAt`。TUI 使用最新标题作为其标题栏副标题,并在完成终端安全渲染后,将终端窗口标题设置为 ``。合成标题轮次本身仍会完成,并作为元数据写入的持久性边界;报告 agent 完成情况的消费方使用核心的 `findLastMessageTurnEnd()` 折叠逻辑,因此后续的标题轮次、注入轮次或其他归插件所有的轮次无法取代此前由消息触发的结果。 ## 考虑过的替代方案 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 6fefa8d53c..57feb1bf5a 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -561,7 +561,7 @@ Creation announcement during session publication. A synchronous throw vetoes and Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:68`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit @@ -582,7 +582,7 @@ Emitted once when an announced session leaves the store, including publication r Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:57`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:78`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -605,7 +605,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:69`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:90`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -626,7 +626,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:100`](../../packages/core/session/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b1cb864e7c..cbef1910ec 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -989,7 +989,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:554`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:575`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ad914bbfc3..380003767c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -31,10 +31,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:44`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:90`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:100`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 55b9846607..ebdcd5e474 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -11,6 +11,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall - `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`. - `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject. - `ctx.sessions.appendOutOfBand(session, type, data, trigger)` accepts only plugin event types opted into `OutOfBandSessionEventMap`. It appends directly inside an open turn; otherwise it atomically opens a zero-step plugin turn, appends, closes, and flushes. A target failure still closes and flushes the synthetic turn, and detach is deferred until the sequence settles. +- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest turn boundary because a later injection or plugin-owned zero-step turn has its own outcome. - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 6350694ebe..3a53dd1ca0 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -27,6 +27,27 @@ export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from ' export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts' +/** + * Find the latest closed message-triggered turn, excluding injection and + * plugin-owned zero-step turns. + * @param events - session events, or an owned suffix, to inspect. + * @returns the latest matching turn end, or `undefined`. + */ +export function findLastMessageTurnEnd( + events: readonly SessionEvent[], +): SessionEvent<'turn/end'> | undefined { + const messageTurns = new Set() + let latest: SessionEvent<'turn/end'> | undefined + for (const event of events) { + if (event.type === 'turn/start') { + if (event.data.trigger.kind === 'message') messageTurns.add(event.data.turn) + continue + } + if (event.type === 'turn/end' && messageTurns.delete(event.data.turn)) latest = event + } + return latest +} + declare module 'cordis' { interface Context { sessions: SessionStore diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index a17981146d..10efce608b 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -1,7 +1,13 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' -import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { + findLastMessageTurnEnd, + SESSION_FORMAT_VERSION, + Session, + SessionEvent, + SessionId, +} from '@deepseek-ai/dsh-session' import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session' describe('Session', () => { @@ -48,6 +54,42 @@ describe('Session', () => { expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' }) }) + it('finds the latest message-turn outcome past later non-message turns', () => { + const session = new Session(SessionId('message-turn-outcome')) + expect(findLastMessageTurnEnd(session.events)).toBeUndefined() + session.append('turn/start', { + turn: 1, + trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } }, + }) + session.append('context/message', { + content: [{ type: 'text', text: 'before' }], + source: { kind: 'plugin', plugin: 'before' }, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + expect(findLastMessageTurnEnd(session.events)).toBeUndefined() + + session.append('turn/start', { + turn: 2, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + session.append('user/message', { + content: [{ type: 'text', text: 'bounded prompt' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + const messageEnd = session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } }) + session.append('turn/start', { + turn: 3, + trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'after' } }, + }) + session.append('context/message', { + content: [{ type: 'text', text: 'after' }], + source: { kind: 'plugin', plugin: 'after' }, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn: 3, reason: { kind: 'completed' } }) + + expect(findLastMessageTurnEnd(session.events)).toBe(messageEnd) + }) + it('renders context and steering messages as plain user content', () => { const session = new Session(SessionId('s2')) session.append('context/message', { diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index d51fc53e5d..e24df08af5 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -12,7 +12,7 @@ The driver follows this sequence: 2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. 3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime. 4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.send(prompt)` followed by `child.whenIdle()`. -5. Read the child's own last assistant message and terminal turn reason, excluding any fork seed. +5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned zero-step turns. The child gets the parent's working-directory/session lineage and inherits the parent model unless `request.agentOptions` overrides it. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index f0e9731c9d..48a4c46c0b 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -10,7 +10,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' -import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' @@ -175,7 +175,7 @@ function readResult( ): SubagentResult { const own = child.session.events.slice(seedLength) const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message') - const lastEnd = own.findLast((event): event is SessionEvent<'turn/end'> => event.type === 'turn/end') + const lastEnd = findLastMessageTurnEnd(own) const output: ContentBlock[] = lastMessage?.data.content ?? [] const recorded = toStopReason(lastEnd?.data.reason) // Disposal can tear the owner down before the loop records its ordinary diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index e82c1ad028..a7caa2daa6 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -6,7 +6,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService from '@deepseek-ai/dsh-subagent' -import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -44,6 +44,36 @@ describe('startInProcessRun', () => { expect(ctx.agents.get(run.id)).toBeUndefined() }) + it('reports the message-turn outcome when a later non-message turn completes during flush', async () => { + const { ctx, parent } = await setup([maxTokensResponse('partial answer')]) + let injected = false + ctx.on('session/flush', (session) => { + if (injected || session.header.parentSession === undefined) return + const lastEnd = session.events.findLast(event => event.type === 'turn/end') + if (lastEnd?.type !== 'turn/end' || lastEnd.data.reason.kind !== 'max-tokens') return + injected = true + const turn = lastEnd.data.turn + 1 + session.append('turn/start', { + turn, + trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } }, + }) + session.append('context/message', { + content: [{ type: 'text', text: 'late metadata' }], + source: { kind: 'plugin', plugin: 'late-metadata' }, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + }) + + const run = await startInProcessRun(request(parent), {}) + const result = await run.result + const child = ctx.agents.get(run.id)! + + expect(child.session.events.findLast(event => event.type === 'turn/end')) + .toMatchObject({ data: { reason: { kind: 'completed' } } }) + expect(result.stopReason).toBe('max-tokens') + await run.dispose() + }) + it('seeds a forked child but reads only the child-owned output', async () => { const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) parent.send([{ type: 'text', text: 'parent question' }]) diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 856c933cbc..e4c49dfe97 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -20,7 +20,7 @@ The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to qu ## Wire notes -`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. Persistence roots and persona come from `cordis.yml`. +`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later injection or plugin-owned zero-step turns still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`. ## Model Experience diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index f3a164340c..65ca3ed52c 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -10,7 +10,7 @@ import { resolve } from 'node:path' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope' -import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' +import { findLastMessageTurnEnd, SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' import type SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -93,7 +93,9 @@ export class HarnessSdkServer { this.disposers.push(ctx.on('session/event', (session, event) => { if (event.type === 'turn/end') { const rec = this.sessions.get(String(session.id)) - if (rec) rec.lastTurnEnd = event.data.reason + if (rec && findLastMessageTurnEnd(session.events)?.seq === event.seq) { + rec.lastTurnEnd = event.data.reason + } } this.transport.notify('session.event', { sessionId: String(session.id), event }) })) diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 034577f105..40dbc81acd 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' -import { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -215,6 +215,64 @@ describe('HarnessSdkServer', () => { expect(otherHandle.dispose).toHaveBeenCalledOnce() }) + it('reports the message-turn outcome when a later non-message turn settles before idle', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) as unknown as { + prompt(params: { sessionId: string; contentBlocks: { type: 'text'; text: string }[] }): Promise + sessions: Map + shutdown(): Promise> + } + const session = ctx.sessions.create(SessionId('message-outcome')) + const agent = { + session, + send(content: { type: 'text'; text: string }[]) { + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + session.append('user/message', { + content, + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } }) + session.append('turn/start', { + turn: 2, + trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } }, + }) + session.append('context/message', { + content: [{ type: 'text', text: 'late metadata' }], + source: { kind: 'plugin', plugin: 'late-metadata' }, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + }, + whenIdle: () => Promise.resolve(), + } as unknown as Agent + server.sessions.set('message-outcome', { + handle: { agent, dispose: () => Promise.resolve() }, + lastTurnEnd: undefined, + activePrompt: false, + }) + + await server.prompt({ + sessionId: 'message-outcome', + contentBlocks: [{ type: 'text', text: 'bounded prompt' }], + }) + + expect(transport.notifications.findLast(notification => notification.method === 'session.finished')) + .toEqual({ + method: 'session.finished', + params: { + sessionId: 'message-outcome', + status: 'error', + reason: { kind: 'max-tokens' }, + }, + }) + await server.shutdown() + await ctx.fiber.dispose() + }) + it('notifies the host when a child session is created with parent lineage', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-')) const ctx = await makeHarness(storageDir)