refactor(session): remove synthetic log-only turns

This commit is contained in:
_Kerman
2026-07-28 14:41:51 +08:00
parent 4c6fb8b957
commit cee0666a4d
83 changed files with 355 additions and 819 deletions

View File

@@ -634,10 +634,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'async flush(session: Session): Promise<void>',
jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the loop\'s turn-end checkpoint, idle\n * injection, teardown drains) must come through here rather than dispatch a\n * raw `ctx.parallel(\'session/flush\', …)` — one owner, one spelling, and the\n * scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns resolves when every flush listener has settled; after all settle,\n * rejects with the first registered listener failure if any listener failed.\n */',
},
{
signature: 'async appendOutOfBand<T extends OutOfBandSessionEventType>( session: Session, type: T, data: SessionEventMap[T], trigger: TurnTrigger, ): Promise<SessionEvent<T>>',
jsDoc: '/**\n * Append one plugin-declared log-only event without borrowing the agent\n * loop\'s lifecycle. An open turn receives the event directly and remains\n * responsible for its ordinary checkpoint. A closed log receives one\n * zero-step turn around the event, followed by an awaited flush.\n *\n * Once the synthetic `turn/start` commits, this method always attempts its\n * matching `turn/end` and flush, including when the target append fails.\n * Detachment requested by an event or flush listener is deferred until that\n * sequence settles, so publication cannot switch from a live scoped session\n * to an unobserved bare `Session` halfway through the update.\n *\n * @param session - exact live session that owns the target log.\n * @param type - event type opted into {@link OutOfBandSessionEventMap} by its owner.\n * @param data - typed JSON payload for the target event.\n * @param trigger - plugin-owned turn trigger used only when the log is closed.\n * @returns the accepted target event with its assigned sequence and timestamp.\n * @throws when the session is detached, another out-of-band append is active,\n * event acceptance fails, the synthetic turn cannot close, or flushing fails.\n */',
},
{
signature: 'get(id: SessionId): Session | undefined',
jsDoc: '/**\n * Look up a live session.\n * @param id - the session id to look up.\n * @returns the session, or undefined when no live session has that id.\n */',
@@ -648,7 +644,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session',
jsDoc: '/**\n * Create a live child session from a turn-enclosed prefix of a live source.\n * `boundary` is an inclusive source event seq; omitted means the source\'s\n * current last event. A non-empty selected slice must end at `turn/end`.\n *\n * @param source - Live source session object or id.\n * @param boundary - Inclusive source event seq to fork through; omitted means\n * the source\'s current last event, and omitted on an empty source forks an\n * empty child.\n * @param childSessionId - Optional child session id; omitted delegates to\n * `SessionStore`\'s id policy.\n * @returns The created live child session.\n */',
jsDoc: '/**\n * Create a live child session from a stable prefix of a live source.\n * `boundary` is an inclusive source event seq; omitted means the source\'s\n * current last event. The selected slice may end with a between-turn event\n * but must not end inside an open turn.\n *\n * @param source - Live source session object or id.\n * @param boundary - Inclusive source event seq to fork through; omitted means\n * the source\'s current last event, and omitted on an empty source forks an\n * empty child.\n * @param childSessionId - Optional child session id; omitted delegates to\n * `SessionStore`\'s id policy.\n * @returns The created live child session.\n */',
},
],
},
@@ -662,7 +658,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'async refresh(session: Session, signal?: AbortSignal): Promise<SessionTitleSnapshot | undefined>',
jsDoc: '/**\n * Explicitly retry the registered provider, or materialize the built-in\n * fallback when no provider is registered.\n * @param session - exact live session to refresh.\n * @param signal - optional caller cancellation; an in-progress fallback append may finish durably before rejection.\n * @returns latest accepted title, or `undefined` when no eligible text exists.\n */',
jsDoc: '/**\n * Explicitly retry the registered provider, or materialize the built-in\n * fallback when no provider is registered.\n * @param session - exact live session to refresh.\n * @param signal - optional caller cancellation.\n * @returns latest accepted title, or `undefined` when no eligible text exists.\n */',
},
{
signature: 'register(provider: SessionTitleProvider): () => Promise<void>',
@@ -1805,14 +1801,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ObjectJsonSchema',
declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};',
},
{
name: 'OutOfBandSessionEventMap',
declaration: 'export interface OutOfBandSessionEventMap {\n}',
},
{
name: 'OutOfBandSessionEventType',
declaration: 'export type OutOfBandSessionEventType = Exclude<Extract<SessionEventType, keyof OutOfBandSessionEventMap>, SurfaceEventType>;',
},
{
name: 'PreparedLlmCall',
declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 95b67fc5977a73d0b7fbf8d37d27eccfcd981338
README.zh.md: 47c97256fb14a21adef2a10589d0d7fa22ab2646
# pnpm run verify-translation-pairing --write packages/core/session/README.md
README.md: f54dc3048fb9ee765a420f387dce9a729c8a85ef
README.zh.md: a3737a12ebe5c86d77ceb6776359a84fb7f0d84f

View File

@@ -14,9 +14,8 @@ 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.
- `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 log event because between-turn records and non-message turns have no prompt 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 prefix to end outside an open turn, and create a live child session with lineage metadata.
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
@@ -74,7 +73,7 @@ A `user/message` renders its `content` verbatim as a user-role message whether i
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure.
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog. `OutOfBandSessionEventMap` is a separate empty-by-default marker map: an event owner must merge the same key there before `appendOutOfBand()` accepts that log-only type, while surface and lifecycle types remain excluded.
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog. A plugin owns the relational invariant for its merged events, including whether a log-only event may appear between turns. A producer that requires durability appends through `Session` and then awaits `ctx.sessions.flush(session)` without fabricating an execution turn.
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). A final model-request error retains one structured `LlmFailure`; other turn errors retain message/code, and both identify the failed step.
@@ -142,6 +141,6 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi
## Known Limitations and Deferred Work
- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`.
- **`fork()` cuts only at closed-turn boundaries of live sessions** — the boundary must be a `turn/end` event and the source must be in the store; forking a persisted-but-unloaded session is excluded from the [fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md).
- **`fork()` cuts only at stable boundaries of live sessions** — the selected prefix must end outside an open turn and the source must be in the store; forking a persisted-but-unloaded session is excluded from the [fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md).
- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no compatibility implied: a backend rejects any other version, and no migration path exists until the first release ([policy](../../../AGENTS.md)).
- **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them.

View File

@@ -14,9 +14,8 @@
- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt``seedLength``delegationDepth`
- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。
- `ctx.sessions.appendOutOfBand(session, type, data, trigger)` 只接受已在 `OutOfBandSessionEventMap` 中显式准入的插件事件类型。若轮次已打开,它会直接追加;否则会原子地开启一个零步骤插件轮次,依次追加、关闭并刷新。即使目标事件追加失败,仍会关闭并刷新合成轮次,且在整个序列结算前延后脱离操作
- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取最近的原始轮次边界,因为更晚的注入或插件所有的零步骤轮次具有自己的结果
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求边界为 `turn/end`,再创建带谱系元数据的实时子会话。
- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
@@ -74,7 +73,7 @@
生成的[持久化日志事件目录](../../../docs/persistence-catalog.md)逐成员列举仅追加日志的事件类型、载荷、surface 标记和溯源信息。Token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息。提供方/模型/回放溯源信息随 `assistant/message` 一同保存;运行错误的步骤记录在 `turn/end.reason` 上(此时为 `kind: 'error'`),最终模型请求失败时还包含结构化的提供方事实。
`SessionEventMap` 可通过合并扩展:插件使用声明合并添加自身类型(压缩 seam 的 `compact/*`、有界恢复的非 surface `llm/retry`、hook钩子桥接层的 `hook/*`);合并成员会出现在同一目录中。`OutOfBandSessionEventMap` 是独立、默认为空的标记映射:事件所有方必须在其中合并相同键,`appendOutOfBand()` 才接受该仅日志类型surface 和生命周期类型仍被排除
`SessionEventMap` 可通过合并扩展:插件使用声明合并添加自身类型(压缩 seam 的 `compact/*`、有界恢复的非 surface `llm/retry`、hook钩子桥接层的 `hook/*`);合并成员会出现在同一目录中。插件拥有其合并事件的关系不变量,包括是否允许纯日志事件出现在轮次之间。需要持久性的生产方通过 `Session` 追加,再等待 `ctx.sessions.flush(session)`,无需虚构一个执行轮次
此包还定义 `TurnTriggerMap``TurnEndReasonMap`(用于类型化轮次边界、可合并扩展的和类型;以 `kind` 为标签而不是字符串)。最终模型请求错误保留一个结构化 `LlmFailure`;其他轮次错误保留消息/代码,两者均标识失败步骤。
@@ -142,6 +141,6 @@
## 已知限制与暂缓工作
- **会话分支/树**pi 风格条目树):除非需要超越基于边界的 `fork()` 能力,否则暂缓。
- **`fork()` 仅在实时会话已关闭轮次的边界处切分**边界必须是 `turn/end` 事件,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。
- **`fork()` 仅在实时会话的稳定边界处切分**所选前缀结束时不得有开放轮次,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。
- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺兼容性;后端会拒绝其他任何版本,首次发布前不提供迁移路径([政策](../../../AGENTS.md))。
- **`TurnEndReasonMap` 不含 ACPAgent Client Protocol命名的 `refusal``max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。

View File

@@ -13,7 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { Message } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, EpochHeader, OutOfBandSessionEventType, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts'
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import { snapshotJsonValue } from './json.ts'
import { SurfaceManager } from './surface.ts'
import type { SessionSurface } from './surface.ts'
@@ -30,8 +30,8 @@ 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.
* Find the latest closed message-triggered turn, ignoring other triggers and
* between-turn events.
* @param events - session events, or an owned suffix, to inspect.
* @returns the latest matching turn end, or `undefined`.
*/
@@ -257,7 +257,6 @@ interface SessionEntry {
announced: boolean
announcing: boolean
appending: boolean
outOfBand: boolean
detachRequested: boolean
detach(): void
}
@@ -446,7 +445,7 @@ export class Session {
} finally {
if (entry !== undefined) {
entry.appending = false
if (entry.detachRequested && !entry.announcing && !entry.outOfBand) entry.detach()
if (entry.detachRequested && !entry.announcing) entry.detach()
}
}
}
@@ -587,8 +586,8 @@ export type SessionForkSource = Session | SessionId
* live store (`SESSION_NOT_FOUND`) or names a session object that is not the
* store's live instance (`SESSION_NOT_LIVE`); the requested child id is
* already taken (`SESSION_ALREADY_EXISTS`); the boundary is not a contiguous
* existing seq (`INVALID_BOUNDARY`); or the boundary event is not a
* `turn/end` — a fork must cut on a closed turn (`OPEN_TURN`).
* existing seq (`INVALID_BOUNDARY`); or the selected prefix ends inside an
* open turn (`OPEN_TURN`).
*/
export type SessionForkErrorCode =
| 'SESSION_NOT_FOUND'
@@ -729,7 +728,6 @@ export class SessionStore extends Service {
announced: false,
announcing: false,
appending: false,
outOfBand: false,
detachRequested: false,
detach: () => { this.detachEntered(entry) },
}
@@ -742,7 +740,7 @@ export class SessionStore extends Service {
// A lifecycle listener may own the advanced detach capability. Keep the
// entry and its publication hooks live until synchronous creation or append
// publication unwinds, then publish the paired disposal edge.
if (entry.announcing || entry.appending || entry.outOfBand) {
if (entry.announcing || entry.appending) {
entry.detachRequested = true
return
}
@@ -796,7 +794,7 @@ export class SessionStore extends Service {
}
} finally {
entry.announcing = false
if (entry.detachRequested && !entry.appending && !entry.outOfBand) entry.detach()
if (entry.detachRequested && !entry.appending) entry.detach()
}
}
@@ -840,87 +838,6 @@ export class SessionStore extends Service {
if (failure !== undefined) throw failure.reason
}
/**
* Append one plugin-declared log-only event without borrowing the agent
* loop's lifecycle. An open turn receives the event directly and remains
* responsible for its ordinary checkpoint. A closed log receives one
* zero-step turn around the event, followed by an awaited flush.
*
* Once the synthetic `turn/start` commits, this method always attempts its
* matching `turn/end` and flush, including when the target append fails.
* Detachment requested by an event or flush listener is deferred until that
* sequence settles, so publication cannot switch from a live scoped session
* to an unobserved bare `Session` halfway through the update.
*
* @param session - exact live session that owns the target log.
* @param type - event type opted into {@link OutOfBandSessionEventMap} by its owner.
* @param data - typed JSON payload for the target event.
* @param trigger - plugin-owned turn trigger used only when the log is closed.
* @returns the accepted target event with its assigned sequence and timestamp.
* @throws when the session is detached, another out-of-band append is active,
* event acceptance fails, the synthetic turn cannot close, or flushing fails.
*/
async appendOutOfBand<T extends OutOfBandSessionEventType>(
session: Session,
type: T,
data: SessionEventMap[T],
trigger: TurnTrigger,
): Promise<SessionEvent<T>> {
const entry = this.liveEntryFor(session)
if (entry.outOfBand) {
throw new Error(`session "${session.id}" already has an out-of-band append in progress`)
}
entry.outOfBand = true
// `T` is excluded from SurfaceEventType by OutOfBandSessionEventType, but
// TypeScript does not reduce Session.append's conditional rest parameter
// through a generic intersection. Preserve that proven two-argument call
// shape without widening the public Session.append overload.
const appendLogOnly = session.append.bind(session) as unknown as <K extends OutOfBandSessionEventType>(
eventType: K,
eventData: SessionEventMap[K],
) => SessionEvent<K>
try {
const lastBoundary = session.events.findLast(event => event.type === 'turn/start' || event.type === 'turn/end')
if (lastBoundary?.type === 'turn/start') {
return appendLogOnly(type, data)
}
const lastStart = session.events.findLast(event => event.type === 'turn/start')
const turn = (lastStart?.data.turn ?? 0) + 1
let accepted: SessionEvent<T> | undefined
let failure: unknown
let opened = false
try {
session.append('turn/start', { turn, trigger })
opened = true
accepted = appendLogOnly(type, data)
} catch (error: unknown) {
failure = error
} finally {
if (opened) {
// The only target types admitted by OutOfBandSessionEventMap are
// log-only plugin events, so the synthetic turn remains open here.
session.append('turn/end', { turn, reason: { kind: 'completed' } })
try {
await this.flush(session)
} catch (error: unknown) {
if (failure === undefined) failure = error
}
}
}
if (failure !== undefined) {
// eslint-disable-next-line @typescript-eslint/only-throw-error -- preserve an arbitrary flush-listener rejection exactly
throw failure
}
/* v8 ignore next -- accepted is assigned unless an append failure was captured above. */
if (accepted === undefined) throw new Error('out-of-band append completed without an accepted event')
return accepted
} finally {
entry.outOfBand = false
if (entry.detachRequested && !entry.announcing && !entry.appending) entry.detach()
}
}
/** Return the exact live entry; detached/prepared objects reject. */
private liveEntryFor(session: Session): SessionEntry {
const entry = attachments.get(session)
@@ -948,9 +865,10 @@ export class SessionStore extends Service {
}
/**
* Create a live child session from a turn-enclosed prefix of a live source.
* Create a live child session from a stable prefix of a live source.
* `boundary` is an inclusive source event seq; omitted means the source's
* current last event. A non-empty selected slice must end at `turn/end`.
* current last event. The selected slice may end with a between-turn event
* but must not end inside an open turn.
*
* @param source - Live source session object or id.
* @param boundary - Inclusive source event seq to fork through; omitted means
@@ -1007,9 +925,11 @@ export class SessionStore extends Service {
'INVALID_BOUNDARY',
)
}
if (boundaryEvent.type !== 'turn/end') {
const lastTurnBoundary = events.slice(0, boundary + 1)
.findLast(event => event.type === 'turn/start' || event.type === 'turn/end')
if (lastTurnBoundary?.type === 'turn/start') {
throw new SessionForkError(
`fork boundary ${boundary} in session "${session.id}" must be turn/end, got ${boundaryEvent.type}`,
`fork boundary ${boundary} in session "${session.id}" ends inside open turn ${lastTurnBoundary.data.turn}`,
'OPEN_TURN',
)
}

View File

@@ -66,8 +66,8 @@ function validateEvent(
let nextStep = trace.nextStep
let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' }
// Model input may be appended between turns without running the model.
// Merge-extensible package events remain turn-enclosed by default.
// Context and plugin-owned log-only events may be appended between model
// executions. Core execution events retain their explicit turn relations.
switch (event.type) {
case 'turn/start': {
if (trace.openTurn !== null) {
@@ -143,12 +143,17 @@ function validateEvent(
}
case 'user/message':
break
default: {
case 'steering/message':
case 'todo/write':
case 'request/header': {
if (trace.openTurn === null) {
fail(`${event.type} appended outside any open turn (every event must be turn-enclosed)`)
fail(`${event.type} appended outside any open turn (core execution events must be turn-enclosed)`)
}
break
}
default:
// Merge-extensible event relations belong to their owning plugin.
break
}
return {
scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep },

View File

@@ -257,23 +257,9 @@ export interface SessionEventMap {
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
}
/**
* Marker map for plugin-owned log-only events accepted by
* `SessionStore.appendOutOfBand()`. A plugin extends this map with the same key
* it adds to {@link SessionEventMap}; surface and lifecycle events stay
* ineligible unless their owner explicitly opts them into this narrow seam.
*/
export interface OutOfBandSessionEventMap {}
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
export type SessionEventType = keyof SessionEventMap
/** Plugin-declared non-surface event types accepted by `SessionStore.appendOutOfBand()`. */
export type OutOfBandSessionEventType = Exclude<
Extract<SessionEventType, keyof OutOfBandSessionEventMap>,
SurfaceEventType
>
/**
* The subset of {@link SessionEventType} values whose events produce LLM
* messages and are eligible to appear on the ordered surface. Only these

View File

@@ -4,6 +4,12 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionForkError, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
'test/log-only': { value: string }
}
}
async function setup(): Promise<{ ctx: Context; sessions: SessionStore }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -83,6 +89,21 @@ describe('SessionStore.fork', () => {
})
})
it('includes stable log-only events appended after a closed turn', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('log-only-parent'))
appendClosedTurn(source, 1, 'hello')
source.append('test/log-only', { value: 'after execution' })
const child = sessions.fork(source, undefined, SessionId('log-only-child'))
expect(child.events).toEqual(source.events)
expect(child.events.at(-1)).toMatchObject({
type: 'test/log-only',
data: { value: 'after execution' },
})
})
it('forks from an earlier turn boundary even when the source currently has an open tail', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
@@ -217,7 +238,7 @@ describe('SessionStore.fork', () => {
const boundary = build(source)
expect(() => sessions.fork(source, boundary))
.toThrow(new SessionForkError(`fork boundary ${boundary} in session "open-${lastType}" must be turn/end, got ${lastType}`, 'OPEN_TURN'))
.toThrow(new SessionForkError(`fork boundary ${boundary} in session "open-${lastType}" ends inside open turn 1`, 'OPEN_TURN'))
}
})

View File

@@ -102,7 +102,7 @@ describe('session-log invariants', () => {
} as never) }).toThrow(/seq must strictly increase/)
})
it('enforces turn numbering and encloses events other than idle context', async () => {
it('enforces turn numbering and core execution enclosure', async () => {
const first = await setup()
const open = first.ctx.sessions.create()
open.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -127,9 +127,13 @@ describe('session-log invariants', () => {
content: [{ type: 'text', text: 'go' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })).toThrow(/outside any open turn/)
// Merge-extensible session events use the same default enclosure branch.
// The owning plugin decides whether a merge-extensible event is log-only.
const appendUnknown = outside.append.bind(outside) as (type: string, data: unknown) => unknown
expect(() => { appendUnknown('plugin/marker', {}) }).toThrow(/outside any open turn/)
expect(() => { appendUnknown('plugin/marker', {}) }).not.toThrow()
expect(() => outside.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})).not.toThrow()
})
it('enforces open-step identity and numbering', async () => {

View File

@@ -1,226 +0,0 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
'test/log-only': { value: string }
}
interface OutOfBandSessionEventMap {
'test/log-only': true
}
}
const updateTrigger = { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } } as const
describe('SessionStore.appendOutOfBand', () => {
it('joins an open turn without adding a boundary or flushing it', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('open'))
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const event = await ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'inside' },
updateTrigger,
)
expect(event).toMatchObject({ type: 'test/log-only', seq: 1, data: { value: 'inside' } })
expect(session.events.map(item => item.type)).toEqual(['turn/start', 'test/log-only'])
expect(flushes).toBe(0)
})
it('wraps a closed log in one zero-step turn and flushes the balanced update', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('closed'))
const flushedTypes: string[][] = []
ctx.on('session/flush', (flushed) => {
flushedTypes.push(flushed.events.map(event => event.type))
})
const first = await ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'first' },
updateTrigger,
)
const second = await ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'second' },
updateTrigger,
)
expect(first.seq).toBe(1)
expect(second.seq).toBe(4)
expect(session.events).toMatchObject([
{ type: 'turn/start', seq: 0, data: { turn: 1, trigger: updateTrigger } },
{ type: 'test/log-only', seq: 1, data: { value: 'first' } },
{ type: 'turn/end', seq: 2, data: { turn: 1, reason: { kind: 'completed' } } },
{ type: 'turn/start', seq: 3, data: { turn: 2, trigger: updateTrigger } },
{ type: 'test/log-only', seq: 4, data: { value: 'second' } },
{ type: 'turn/end', seq: 5, data: { turn: 2, reason: { kind: 'completed' } } },
])
expect(flushedTypes).toEqual([
['turn/start', 'test/log-only', 'turn/end'],
['turn/start', 'test/log-only', 'turn/end', 'turn/start', 'test/log-only', 'turn/end'],
])
})
it('closes and flushes a zero-step turn when the target event is rejected', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('rejected'))
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 1n } as never,
updateTrigger,
)).rejects.toThrow(/non-JSON-serializable/)
expect(session.events).toMatchObject([
{ type: 'turn/start', data: { turn: 1 } },
{ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
])
expect(flushes).toBe(1)
})
it('does not flush when the synthetic turn cannot open', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('start-failure'))
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'unreachable' },
{ ...updateTrigger, invalid: 1n } as never,
)).rejects.toThrow(/non-JSON-serializable/)
expect(session.events).toEqual([])
expect(flushes).toBe(0)
})
it('preserves a target rejection when the balancing flush also rejects', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('target-and-flush-failure'))
ctx.on('session/flush', () => { throw new Error('disk failed') })
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 1n } as never,
updateTrigger,
)).rejects.toThrow(/non-JSON-serializable/)
expect(session.events.map(event => event.type)).toEqual([
'turn/start',
'turn/end',
])
})
it('keeps the session attached through publication and its flush', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.prepare(SessionId('dispose'))
const detach = ctx.sessions.enter(session)
ctx.sessions.announce(session)
let liveDuringFlush = false
ctx.on('session/event', (_observed, event) => {
if (event.type === 'turn/start') detach()
})
ctx.on('session/flush', () => {
liveDuringFlush = ctx.sessions.get(session.id) === session
})
await ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'last' },
updateTrigger,
)
expect(session.events.map(event => event.type)).toEqual([
'turn/start',
'test/log-only',
'turn/end',
])
expect(liveDuringFlush).toBe(true)
expect(ctx.sessions.get(session.id)).toBeUndefined()
})
it('rejects detached sessions before opening a turn', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.prepare(SessionId('detached'))
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'nope' },
updateTrigger,
)).rejects.toThrow('session "detached" is not live in this store')
expect(session.events).toEqual([])
})
it('leaves a balanced log when the durability checkpoint rejects', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('flush-failure'))
ctx.on('session/flush', () => { throw new Error('disk failed') })
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'accepted' },
updateTrigger,
)).rejects.toThrow('disk failed')
expect(session.events.map(event => event.type)).toEqual([
'turn/start',
'test/log-only',
'turn/end',
])
})
it('rejects overlapping updates while the first append is still settling', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('overlap'))
let release!: () => void
const checkpoint = new Promise<void>((resolve) => {
release = resolve
})
ctx.on('session/flush', () => checkpoint)
const first = ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'first' },
updateTrigger,
)
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'overlap' },
updateTrigger,
)).rejects.toThrow(/out-of-band append in progress/)
release()
await expect(first).resolves.toMatchObject({ data: { value: 'first' } })
})
})

View File

@@ -43,7 +43,7 @@ declare module '@deepseek-ai/dsh-session' {
* Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter
* model context; persistence and UIs get every call. Appended inside the
* parent `run_code`'s execution (the bridge drains in-flight dispatches
* before returning), so the turn-enclosure invariant holds by
* before returning), so its execution-enclosure relation holds by
* construction.
*/
'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] }

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 144616248d4e811b112f4c556502a960e681950b
README.zh.md: 036434ea6f10156a565734ac4da40f447e38ebb8
# pnpm run verify-translation-pairing --write packages/hooks/hook-protocol/README.md
README.md: 10cfcdcbf819f318f2ccaf412ae04bba60812397
README.zh.md: f6fd30c968f68faa46d7ea07188cb22ee5ef3afe

View File

@@ -29,7 +29,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`, with `appendHookResult` owning the decision rule). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog.md); `stderrSummary` is truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty).
Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`Stop`) fire inside the loop's open turn by construction. `SessionStart` and the pre-turn `UserPromptSubmit` admission seam get no `hook/*` record; allowed context is instead evidenced by its sourced `user/message` — see the hooks Agent Note.
Hook provenance records must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`Stop`) satisfy that owner-defined relation by construction. `SessionStart` and the pre-turn `UserPromptSubmit` admission seam get no `hook/*` record; allowed context is instead evidenced by its sourced `user/message` — see the hooks Agent Note.
## Model Experience

View File

@@ -29,7 +29,7 @@ Claude CodeCodex hook 协议格式的**共享核心**。它不是 cordis 插
通过 declaration merging 合并到 `SessionEventMap`(仅日志,与 `compact/*` 相同;不是 `SurfaceEventType`,没有 `surfaceOp``hook/invoked`hook 命令已运行)与 `hook/result`(其结果,按 `handlerId` 配对,由 `appendHookResult` 拥有决策规则。Payload 与每事件 JSDoc 位于生成的 [持久化日志事件目录](../../../docs/persistence-catalog.md)`stderrSummary` 会截断到记录的 `stderrSummaryMaxChars`(桥接配置,参考默认值 `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500为空时省略
与每个事件一样,它们必须位于开启轮次内。轮次中点(`PreToolUse``PostToolUse``Stop`)按构造位于 loop 的开启轮次中`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note。
Hook 溯源记录必须位于开启轮次内。轮次中点(`PreToolUse``PostToolUse``Stop`)按构造满足这条由所有方定义的关系`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note。
## 模型体验

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: e01db9f618fcc8ad139c7b7aaa3b942150df3194
README.zh.md: 2341850cefadff1bba8d0f738320028e00ea9ae5
# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-policy/README.md
README.md: dca54330bc888af9ecac21aa92019d8a2b0140bd
README.zh.md: abf2d9fb8830fdcaf7f1357b393b434de4a5ad8d

View File

@@ -21,7 +21,7 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de
- `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band.
- `SANDBOX_MODES` — every mode, for option advertisement and runtime validation.
The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside that closed vocabulary; Session and its companion own the surrounding storage and turn-enclosure rules.
The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside that closed vocabulary; Session and its companion own the surrounding storage and core execution-enclosure rules.
## The per-session store

View File

@@ -21,7 +21,7 @@
- `setSandboxMode(session, mode)`:逐会话覆盖的唯一写入路径:恰好追加一条 `sandbox/mode` 事件。切换本身就是事件;不会在带外修改模式。
- `SANDBOX_MODES`:所有模式,用于选项展示与运行时验证。
可选的 `./invariant` 配套组件会拒绝伪造的持久 `sandbox/mode` 事件只要其值不在该封闭词汇中Session 与其配套组件拥有周围的存储与轮次封闭规则。
可选的 `./invariant` 配套组件会拒绝伪造的持久 `sandbox/mode` 事件只要其值不在该封闭词汇中Session 与其配套组件拥有周围的存储与核心执行封闭规则。
## 逐会话 store

View File

@@ -253,10 +253,10 @@ export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionE
const headerLine = parsedHeader
// Parse and decode every complete line first so the last valid `turn/end`
// determines whether an earlier hole is committed corruption or an
// uncommitted tail. One line yields one event, or a whole run for a packed
// chunk row; a row-tagged line that fails row validation is a hole, exactly
// like unparsable JSON.
// determines whether an earlier hole interrupts an otherwise closed
// execution or belongs to a tolerable final suffix. One line yields one
// event, or a whole run for a packed chunk row; a row-tagged line that fails
// row validation is a hole, exactly like unparsable JSON.
interface Parsed { ok: boolean; events?: SessionEvent[]; endByte: number }
const parsed: Parsed[] = eventEntries.map((entry) => {
try {
@@ -266,9 +266,11 @@ export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionE
}
})
// The last index (into eventEntries) that ends in a valid `turn/end` — the
// last fully-committed boundary (the loop flushes only at turn/end). A packed
// row never stores a turn/end, so only single-event lines can match.
// The last index (into eventEntries) that ends in a valid `turn/end`. A hole
// before this boundary cannot be a torn final suffix because later execution
// already closed. Standalone events after it remain part of the preserved
// contiguous prefix. A packed row never stores a turn/end, so only
// single-event lines can match.
let lastTurnEnd = -1
for (let i = parsed.length - 1; i >= 0; i--) {
const p = parsed[i]

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 5d004b634e6242a8a558182e6c953e4c43b25e1f
README.zh.md: 36567d950e612561d8af804eca64ac6f9f3cd788
# pnpm run verify-translation-pairing --write packages/session-title/session-title-llm/README.md
README.md: 342687b5aa0cd35a70abf8cc66b3fe80342bce0b
README.zh.md: 2bb48b52ae6298c5c895ef99ff54cf5f1d7a6d8c

View File

@@ -10,7 +10,7 @@ This package is a library, not a Cordis plugin. The provider plugins call `regis
`provider` and `model` overrides are optional but must be supplied together as non-empty strings. Without that pair, the helper uses the exact provider/model route captured from the current session's logged `request/header`; an explicit refresh before any route exists therefore needs overrides. The helper measures the final JSON-framed user prompt, including seq fields, wrappers, and JSON escaping, against `maxInputBytes` before logging or dispatch instead of truncating it. Timeout and caller cancellation are rechecked while consuming the stream and after it completes, so a late successful result cannot be accepted even if an interceptor or adapter ignores abort. Malformed or empty output, tool calls, and non-stop finish reasons also reject; the session-title service decides whether that rejection is an automatic warning or an explicit caller failure.
After route and input validation, the helper appends a log-only `session/title-llm-request` event before model dispatch. It contains the title-provider id, exact source seqs, route, system prompt, message list, and output-token cap used by the call. The append shares the title capability's per-session settlement queue, so a superseding request cannot collide with an earlier fallback, request record, or accepted-title flush. The dispatched envelope is deep-frozen, carries `purpose: 'session-title'`, and deliberately lacks dsh-agent-loop's process-local request identity. Interceptors stay aligned with the record while loop-only reconstruction observers do not compare it with the conversation header. The DeepSeek adapter maps that purpose to thinking-disabled so the small output budget is reserved for visible title text; other adapters own their purpose-specific behavior. A later model failure leaves the request record intact; validation failures that never become dispatchable requests do not create one. The event stays outside derived model history.
After route and input validation, the helper appends a log-only `session/title-llm-request` event directly through `Session` before model dispatch. It contains the title-provider id, exact source seqs, route, system prompt, message list, and output-token cap used by the call. Persistence observes the record eagerly; the append does not need a title-specific marker, cast, settlement queue, or flush. The dispatched envelope is deep-frozen, carries `purpose: 'session-title'`, and deliberately lacks dsh-agent-loop's process-local request identity. Interceptors stay aligned with the record while loop-only reconstruction observers do not compare it with the conversation header. The DeepSeek adapter maps that purpose to thinking-disabled so the small output budget is reserved for visible title text; other adapters own their purpose-specific behavior. A later model failure leaves the request record intact; validation failures that never become dispatchable requests do not create one. The event stays outside derived model history.
## Configuration

View File

@@ -10,7 +10,7 @@
`provider``model` 覆盖项都是可选的,但必须同时作为非空字符串提供。如果没有这一对取值,辅助模块会使用当前会话已记录 `request/header` 中捕获的确切提供方/模型路由;因此,在任何路由出现前显式刷新时必须提供覆盖项。辅助模块在记录或分发前,以 `maxInputBytes` 测量最终 JSON 封装的用户提示词,包括 seq 字段、包装层与 JSON 转义,而不是将其截断。消费流期间和流完成后都会重新检查超时与调用方取消,因此即使 interceptor 或适配器忽略 abort也不能接受迟到的成功结果。格式错误或空输出、工具调用和非 stop 结束原因同样会 reject会话标题服务决定该 reject 属于自动警告还是显式调用方失败。
路由与输入验证完成后,辅助模块会在模型分发前追加仅写入日志的 `session/title-llm-request` 事件。它包含标题提供方 id、确切来源 seq、路由、系统提示词、消息列表以及该调用使用的输出 token 上限。追加操作共享标题能力的逐会话结算队列,因此取代当前请求的新请求不会与更早回退、请求记录或已接受标题的 flush 冲突。分发的 envelope 会深度冻结,携带 `purpose: 'session-title'`,且有意不包含 dsh-agent-loop 的进程本地请求身份。Interceptor 会与记录保持一致,而循环专用重建观察者不会把它与对话 header 比较。DeepSeek 适配器会将该 purpose 映射为关闭 thinking使少量输出预算全部用于可见标题文本其他适配器负责自身 purpose 专用行为。后续模型失败会保留请求记录;从未成为可分发请求的验证失败不会创建记录。该事件始终位于派生模型历史之外。
路由与输入验证完成后,辅助模块会在模型分发前直接通过 `Session` 追加仅写入日志的 `session/title-llm-request` 事件。它包含标题提供方 id、确切来源 seq、路由、系统提示词、消息列表以及该调用使用的输出 token 上限。持久化会尽快观察该记录;追加不需要标题专属标记、类型断言、结算队列或 flush。分发的 envelope 会深度冻结,携带 `purpose: 'session-title'`,且有意不包含 dsh-agent-loop 的进程本地请求身份。Interceptor 会与记录保持一致,而循环专用重建观察者不会把它与对话 header 比较。DeepSeek 适配器会将该 purpose 映射为关闭 thinking使少量输出预算全部用于可见标题文本其他适配器负责自身 purpose 专用行为。后续模型失败会保留请求记录;从未成为可分发请求的验证失败不会创建记录。该事件始终位于派生模型历史之外。
## 配置

View File

@@ -10,7 +10,6 @@ import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm'
import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import { deadline, MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import {
appendSessionTitleOutOfBand,
normalizeSessionTitle,
SessionTitleProviderId,
} from '@deepseek-ai/dsh-session-title'
@@ -43,10 +42,6 @@ declare module '@deepseek-ai/dsh-session' {
/** Log-only pre-dispatch record of one session-title model request. */
'session/title-llm-request': SessionTitleLlmRequestEventData
}
interface OutOfBandSessionEventMap {
'session/title-llm-request': true
}
}
/** Capability-owned timeout reason code for auxiliary title requests. */
@@ -264,14 +259,14 @@ export async function generateSessionTitleWithLlm(
purpose: 'session-title',
signal: callDeadline.signal,
})
await appendSessionTitleOutOfBand(ctx, request.session, 'session/title-llm-request', {
request.session.append('session/title-llm-request', {
titleProvider,
messageSeqs: selectedMessages.map(message => message.seq),
route,
system,
messages,
maxTokens: config.maxOutputTokens,
}, callDeadline.signal)
})
callDeadline.signal.throwIfAborted()
const assembler = new BlockAssembler()
for await (const chunk of ctx.llm.stream(options)) {

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 2be6f37ce37f525dd567772144e925886ad51b42
README.zh.md: 7f55b7bba912bf4611f5a058a0390d67b8860b24
# pnpm run verify-translation-pairing --write packages/session-title/session-title/README.md
README.md: 1939d00f7e78834ec19e2d6b4590cf12af297a30
README.zh.md: d373c212a193a82567686a634bad79726185e832

View File

@@ -9,10 +9,10 @@ Only text blocks from human `user/message` events are eligible. The first eligib
## Service: `SessionTitleService` (ctx key: `sessionTitle`)
- `get(session)` folds the latest accepted title from a live or replayed log.
- `refresh(session, signal?)` materializes the fallback when needed, then explicitly runs the registered provider over the current eligible messages. Provider errors and caller cancellation reject; cancellation does not roll back a fallback append already entering durability.
- `refresh(session, signal?)` materializes the fallback when needed, then explicitly runs the registered provider over the current eligible messages. Provider errors and caller cancellation reject; cancellation does not roll back an already accepted fallback event.
- `register(provider)` installs the sole optional provider and returns its awaitable Cordis effect disposer. A second registration throws immediately; disposal aborts pending and active calls, waits for their settlement, and only then permits another provider to register.
Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion joins an open turn or uses a flushed zero-step `session-title` turn through `ctx.sessions.appendOutOfBand()`. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their order before fallback durability waits, while overlapping automatic and explicit fallback requests share one session-local in-flight append. Service and bundled model-provider records use `appendSessionTitleOutOfBand()` to share a per-session settlement queue, so a replacement request record waits for an earlier title write without serializing the superseded model call itself. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes.
Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion appends a standalone log-only event directly through `Session` without opening a turn. Persistence observes that event eagerly and drains on ordinary lifecycle checkpoints; title publication itself does not force a flush. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their revision before provider work, while overlapping automatic and explicit fallback requests share one session-local in-flight append. The service and bundled model provider each append their own literal event type, so no generic title-write marker, cast, or settlement queue is needed. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes.
Forks inherit title events in their seed unchanged. The first-message cadence does not automatically retitle a child; the all-messages cadence may append a new revision after the child receives a later human prompt.

View File

@@ -9,10 +9,10 @@
## 服务:`SessionTitleService`ctx 键:`sessionTitle`
- `get(session)` 从活跃或回放日志折叠最新已接受标题。
- `refresh(session, signal?)` 在需要时物化回退,然后显式运行已注册提供方,处理当前符合条件的消息。提供方错误与调用方取消会 reject取消不会回滚已经进入持久化流程的回退追加
- `refresh(session, signal?)` 在需要时物化回退,然后显式运行已注册提供方,处理当前符合条件的消息。提供方错误与调用方取消会 reject取消不会回滚已接受的回退事件
- `register(provider)` 安装唯一可选提供方,并返回可等待的 Cordis effect disposer。第二次注册会立即抛出资源释放会中止待处理和活跃调用等待其结算之后才允许注册另一个提供方。
自动工作绝不会延迟主 agent 响应。只有当带标记、由循环构建的请求,其确切路由与当前已记录的 `request/header` 匹配时,提供方才会启动;即使 header 未变而无需新快照,也适用此规则。延迟完成会加入开放轮次,或使用已经 flush 的零步骤 `session-title` 轮次,并通过 `ctx.sessions.appendOutOfBand()` 追加。自动失败会发出警告并保留最新标题。新的全消息 revision、提供方资源释放、会话资源释放和显式刷新都会中止旧工作陈旧完成值无法追加。并发显式刷新会在等待回退持久化前预留顺序;重叠的自动/显式回退请求共享一个会话本地进行中追加。服务与随附模型提供方记录使用 `appendSessionTitleOutOfBand()`,共享逐会话结算队列,因此替换请求记录会等待更早标题写入,但无需串行等待被取代的模型调用本身。服务 teardown 会取消排队工作,并在卸载完成前排空忽略取消的调用。
自动工作绝不会延迟主 agent(智能体)响应。只有当带标记、由循环构建的请求,其确切路由与当前已记录的 `request/header` 匹配时,提供方才会启动;即使 header 未变而无需新快照,也适用此规则。延迟完成会直接通过 `Session` 追加一个独立的纯日志事件,而不打开轮次。持久化会尽快观察该事件,并在常规生命周期检查点排空;标题发布本身不会强制 flush。自动失败会发出警告并保留最新标题。新的全消息 revision、提供方资源释放、会话资源释放和显式刷新都会中止旧工作陈旧完成值无法追加。并发显式刷新会在提供方工作之前预留修订号;重叠的自动/显式回退请求共享一个会话本地进行中追加。服务与随附模型提供方各自追加自己的字面量事件类型,因此不需要通用标题写入标记、类型断言或结算队列。服务 teardown 会取消排队工作,并在卸载完成前排空忽略取消的调用。
Fork 会原样继承 seed 中的标题事件。首消息节奏不会自动为子会话重新生成标题;全消息节奏可以在子会话收到后续用户提示词后追加新 revision。

View File

@@ -9,10 +9,8 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
import { deepFreeze, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import type {
OutOfBandSessionEventType,
Session,
SessionEvent,
SessionEventMap,
} from '@deepseek-ai/dsh-session'
import { fallbackSessionTitle, normalizeSessionTitle } from './normalize.ts'
@@ -82,11 +80,6 @@ declare module 'cordis' {
}
declare module '@deepseek-ai/dsh-session' {
interface TurnTriggerMap {
/** Zero-step turn opened only to durably append a late title update. */
'session-title': { kind: 'session-title' }
}
interface SessionEventMap {
/**
* Latest-wins session title snapshot. Log-only: it never enters the model
@@ -94,50 +87,6 @@ declare module '@deepseek-ai/dsh-session' {
*/
'session/title': SessionTitleEventData
}
interface OutOfBandSessionEventMap {
'session/title': true
}
}
/** Per-session settlement tails for title-capability out-of-band writes. */
const SESSION_TITLE_WRITE_TAILS = new WeakMap<Session, Promise<void>>()
/** Convert either write outcome into a fulfilled queue tail. */
function settleSessionTitleWrite(): void {}
/**
* Serialize one title-capability out-of-band event with its session peers.
* Cancellation is checked when the write reaches the head of the queue; once
* the core append starts, its durability contract runs to completion.
* @param ctx - context exposing the live session store.
* @param session - exact live session that owns the title-capability event.
* @param type - plugin-declared log-only title event type.
* @param data - typed JSON payload for the event.
* @param signal - service or provider lifetime checked before publication starts.
* @returns the durably accepted event.
*/
export async function appendSessionTitleOutOfBand<T extends OutOfBandSessionEventType>(
ctx: Context,
session: Session,
type: T,
data: SessionEventMap[T],
signal: AbortSignal,
): Promise<SessionEvent<T>> {
const predecessor = SESSION_TITLE_WRITE_TAILS.get(session)
const run = Promise.resolve(predecessor).then(() => {
signal.throwIfAborted()
return ctx.sessions.appendOutOfBand(session, type, data, { kind: 'session-title' })
})
const tail = run.then(settleSessionTitleWrite, settleSessionTitleWrite)
SESSION_TITLE_WRITE_TAILS.set(session, tail)
try {
return await run
} finally {
if (SESSION_TITLE_WRITE_TAILS.get(session) === tail) {
SESSION_TITLE_WRITE_TAILS.delete(session)
}
}
}
/** One eligible human text message exposed to title providers. */
@@ -163,7 +112,7 @@ export interface SessionTitleProviderRequest {
readonly signal: AbortSignal
}
/** Provider output before service-owned normalization and durable acceptance. */
/** Provider output before service-owned normalization and log acceptance. */
export interface SessionTitleProviderResult {
/** Proposed title text. */
readonly title: string
@@ -360,7 +309,7 @@ export class SessionTitleService extends Service {
* Explicitly retry the registered provider, or materialize the built-in
* fallback when no provider is registered.
* @param session - exact live session to refresh.
* @param signal - optional caller cancellation; an in-progress fallback append may finish durably before rejection.
* @param signal - optional caller cancellation.
* @returns latest accepted title, or `undefined` when no eligible text exists.
*/
async refresh(session: Session, signal?: AbortSignal): Promise<SessionTitleSnapshot | undefined> {
@@ -509,7 +458,7 @@ export class SessionTitleService extends Service {
return this.track(run, work.registration)
}
/** Execute and durably accept one current provider revision. */
/** Execute and accept one current provider revision. */
private async runProvider(
session: Session,
work: ActiveProviderWork,
@@ -528,7 +477,7 @@ export class SessionTitleService extends Service {
})
this.assertCurrent(session, work)
const accepted = this.validateResult(result, messages)
await appendSessionTitleOutOfBand(this.ctx, session, 'session/title', {
session.append('session/title', {
title: accepted.title,
messageSeqs: [...accepted.messageSeqs],
source: {
@@ -536,7 +485,7 @@ export class SessionTitleService extends Service {
provider: work.registration.provider.id,
...accepted.model === undefined ? {} : { model: accepted.model },
},
}, work.signal)
})
return this.get(session)
} finally {
const state = this.work.get(session)
@@ -711,11 +660,20 @@ export class SessionTitleService extends Service {
if (title.length === 0) return undefined
const state = this.stateFor(session)
if (state.fallback !== undefined) return state.fallback
const fallback = appendSessionTitleOutOfBand(this.ctx, session, 'session/title', {
title,
messageSeqs: [first.seq],
source: { kind: 'fallback' },
}, this.lifetime.signal).then(() => this.get(session))
const fallback = Promise.resolve().then(() => {
this.assertServiceActive()
if (this.ctx.sessions.get(session.id) !== session) {
throw new Error(`session "${session.id}" is not live in this store`)
}
const accepted = this.get(session)
if (accepted !== undefined) return accepted
session.append('session/title', {
title,
messageSeqs: [first.seq],
source: { kind: 'fallback' },
})
return this.get(session)
})
state.fallback = fallback
try {
return await fallback

View File

@@ -15,8 +15,9 @@ export const name = 'session-title-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: the service validates provider revisions before their single durable
* append, and its remaining provider lifecycle state is process-local and covered by package tests.
* No runtime invariant: the service validates provider revisions before their
* title append, and its remaining lifecycle state is process-local and covered
* by package tests.
*/
const install: InvariantInstaller = () => {}

View File

@@ -30,9 +30,8 @@ async function appendPersistedTitle(ctx: Context, id: ReturnType<typeof SessionI
content: [{ type: 'text', text: 'Persist this session title' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
await new Promise(resolve => setTimeout(resolve, 0))
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
await ctx.sessionTitle.refresh(session)
}
async function expectPersistedTitle(ctx: Context, id: ReturnType<typeof SessionId>): Promise<void> {
@@ -41,13 +40,13 @@ async function expectPersistedTitle(ctx: Context, id: ReturnType<typeof SessionI
title: 'Persist this session title',
messageSeqs: [1],
source: { kind: 'fallback' },
eventSeq: 2,
eventSeq: 3,
})
expect(loaded.events.map(event => event.type)).toEqual([
'turn/start',
'user/message',
'session/title',
'turn/end',
'session/title',
])
}

View File

@@ -2,7 +2,6 @@ import { Context, type Fiber } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionTitleService, {
appendSessionTitleOutOfBand,
SessionTitleProviderId,
type Config,
type SessionTitleProvider,
@@ -10,16 +9,6 @@ import SessionTitleService, {
type SessionTitleProviderResult,
} from '@deepseek-ai/dsh-session-title'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
'test/title-provider-request': { revision: number }
}
interface OutOfBandSessionEventMap {
'test/title-provider-request': true
}
}
const CONFIG = {
fallbackMaxWords: 5,
fallbackMaxBytes: 40,
@@ -173,38 +162,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
expect(disposeSignal?.aborted).toBe(true)
})
it('rejects fallback refresh cancellation that arrives during durability flush', async () => {
const ctx = await setup()
const seed = new Session(SessionId('fallback-cancel-seed'))
seed.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const source = appendPrompt(seed, 'Persist this fallback despite caller cancellation')
seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const session = ctx.sessions.create(SessionId('fallback-cancel'), { seed: seed.events })
const flushStarted = deferred<undefined>()
const releaseFlush = deferred<undefined>()
ctx.on('session/flush', async (subject) => {
if (subject !== session) return
flushStarted.resolve(undefined)
await releaseFlush.promise
})
const controller = new AbortController()
const refresh = ctx.sessionTitle.refresh(session, controller.signal)
await flushStarted.promise
controller.abort(new Error('cancelled while fallback flushed'))
releaseFlush.resolve(undefined)
await expect(refresh).rejects.toThrow('cancelled while fallback flushed')
expect(ctx.sessionTitle.get(session)).toMatchObject({
messageSeqs: [source.seq],
source: { kind: 'fallback' },
})
})
it('shares one durable fallback across concurrent refreshes', async () => {
it('shares one fallback across concurrent refreshes', async () => {
const ctx = await setup()
const seed = new Session(SessionId('fallback-concurrency-seed'))
seed.append('turn/start', {
@@ -214,10 +172,6 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
const source = appendPrompt(seed, 'Create exactly one fallback title')
seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const session = ctx.sessions.create(SessionId('fallback-concurrency'), { seed: seed.events })
let flushes = 0
ctx.on('session/flush', (subject) => {
if (subject === session) flushes += 1
})
const results = await Promise.all([
ctx.sessionTitle.refresh(session),
@@ -226,136 +180,46 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
expect(results[0]).toEqual(results[1])
expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1)
expect(session.events.filter(event => event.type === 'turn/start'
&& event.data.trigger.kind === 'session-title')).toHaveLength(1)
expect(session.events.map(event => event.type)).toEqual([
'turn/start',
'user/message',
'turn/end',
'session/title',
])
expect(ctx.sessionTitle.get(session)?.messageSeqs).toEqual([source.seq])
expect(flushes).toBe(1)
})
it('reserves overlapping refresh order before fallback durability settles', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const seed = new Session(SessionId('refresh-order-seed'))
seed.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const source = appendPrompt(seed, 'Keep the newest explicit refresh')
seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const session = ctx.sessions.create(SessionId('refresh-order'), { seed: seed.events })
const flushStarted = deferred<undefined>()
const releaseFlush = deferred<undefined>()
let flushCount = 0
ctx.on('session/flush', async (subject) => {
if (subject !== session || ++flushCount !== 1) return
flushStarted.resolve(undefined)
await releaseFlush.promise
})
const result = deferred<SessionTitleProviderResult>()
it('lets the newest overlapping explicit refresh win', async () => {
const ctx = await setup()
const session = startSession(ctx, 'refresh-order')
const source = appendPrompt(session, 'Keep the newest explicit refresh')
await settle()
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const requests: SessionTitleProviderRequest[] = []
const results: Array<ReturnType<typeof deferred<SessionTitleProviderResult>>> = []
ctx.sessionTitle.register({
id: SessionTitleProviderId('refresh-order'),
automatic: 'first-message',
generate(request) {
requests.push(request)
const result = deferred<SessionTitleProviderResult>()
results.push(result)
return result.promise
},
})
const older = ctx.sessionTitle.refresh(session)
const olderOutcome = older.then(
() => undefined,
(error: unknown) => error,
)
await flushStarted.promise
await settle()
const newer = ctx.sessionTitle.refresh(session)
await settle()
expect(requests).toHaveLength(1)
expect(requests[0]?.signal.aborted).toBe(false)
releaseFlush.resolve(undefined)
await settle()
expect(requests).toHaveLength(1)
expect(requests[0]?.signal.aborted).toBe(false)
result.resolve({ title: 'Newest explicit title', messageSeqs: [source.seq] })
expect(requests).toHaveLength(2)
expect(requests[0]?.signal.aborted).toBe(true)
expect(requests[1]?.signal.aborted).toBe(false)
results[0]?.resolve({ title: 'Obsolete title', messageSeqs: [source.seq] })
await expect(older).rejects.toThrow(/superseded/)
results[1]?.resolve({ title: 'Newest explicit title', messageSeqs: [source.seq] })
await expect(newer).resolves.toMatchObject({ title: 'Newest explicit title' })
const olderError = await olderOutcome
expect(olderError).toBeInstanceOf(Error)
if (!(olderError instanceof Error)) throw new Error('expected older refresh to reject')
expect(olderError.message).toMatch(/superseded/)
})
it('serializes a newer provider write after the superseded write', async () => {
const ctx = await setup()
const session = startSession(ctx, 'refresh-provider-write-order')
const source = appendPrompt(session, 'Serialize explicit provider writes')
await settle()
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const flushStarted = deferred<undefined>()
const releaseFlush = deferred<undefined>()
let flushCount = 0
ctx.on('session/flush', async (subject) => {
if (subject !== session || ++flushCount !== 1) return
flushStarted.resolve(undefined)
await releaseFlush.promise
})
let generation = 0
ctx.sessionTitle.register({
id: SessionTitleProviderId('refresh-provider-write-order'),
automatic: 'first-message',
async generate(request) {
generation += 1
const revision = generation
await appendSessionTitleOutOfBand(ctx, request.session, 'test/title-provider-request', {
revision,
}, request.signal)
return {
title: `Generated title ${revision}`,
messageSeqs: [source.seq],
}
},
})
const older = ctx.sessionTitle.refresh(session)
const olderOutcome = older.then(
() => undefined,
(error: unknown) => error,
)
await flushStarted.promise
const middle = ctx.sessionTitle.refresh(session)
const middleOutcome = middle.then(
value => value,
(error: unknown) => error,
)
await settle()
expect(generation).toBe(2)
expect(session.events.filter(event => event.type === 'test/title-provider-request'))
.toHaveLength(1)
const newer = ctx.sessionTitle.refresh(session)
const newerOutcome = newer.then(
value => value,
(error: unknown) => error,
)
await settle()
expect(generation).toBe(3)
expect(session.events.filter(event => event.type === 'test/title-provider-request'))
.toHaveLength(1)
releaseFlush.resolve(undefined)
const newerResult = await newerOutcome
expect(newerResult).toMatchObject({ title: 'Generated title 3' })
const olderError = await olderOutcome
expect(olderError).toBeInstanceOf(Error)
if (!(olderError instanceof Error)) throw new Error('expected older refresh to reject')
expect(olderError.message).toMatch(/superseded/)
const middleError = await middleOutcome
expect(middleError).toBeInstanceOf(Error)
if (!(middleError instanceof Error)) throw new Error('expected middle refresh to reject')
expect(middleError.message).toMatch(/superseded/)
expect(session.events.filter(event => event.type === 'test/title-provider-request').map(event => event.data.revision))
.toEqual([1, 3])
})
it('cancels a queued fallback when the session-title service unloads', async () => {
@@ -430,31 +294,6 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
await expect(refreshOutcome).resolves.toEqual(expect.objectContaining({ message: 'session-title service disposed' }))
})
it('suppresses a queued fallback failure after service unload begins', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionTitleService, CONFIG)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const session = startSession(ctx, 'service-unload-flush')
appendPrompt(session, 'Fallback whose flush outlives the service')
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const flushStarted = deferred<undefined>()
const releaseFlush = deferred<undefined>()
ctx.on('session/flush', async (subject) => {
if (subject !== session) return
flushStarted.resolve(undefined)
await releaseFlush.promise
throw new Error('flush failed during service unload')
})
await flushStarted.promise
const disposal = fiber.dispose()
releaseFlush.resolve(undefined)
await disposal
expect(warn).not.toHaveBeenCalled()
})
it('warns when a detached session prevents queued fallback publication', async () => {
const ctx = await setup()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 74a1b504270ec18921156c490c9898a1b0ec1d0e
README.zh.md: 2c358f7ed48cfbbefacf00ce307253d1bdb0c3c8
# pnpm run verify-translation-pairing --write packages/subagent/subagent-inprocess/README.md
README.md: 220a3f44d0eb14dfed3241c9561800f86a90aecf
README.zh.md: cd03f01cff19c42b8dc91a906ebb89779c494a24

View File

@@ -14,7 +14,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.followup(prompt)` followed by `child.whenIdle()`.
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.
5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later between-turn records.
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.

View File

@@ -14,7 +14,7 @@
2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。
3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。
4. 发布子 agent保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。
5. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的零步骤轮次。
5. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续轮次间记录
子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 模型。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。

View File

@@ -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/telemetry/session-telemetry/README.md
README.md: 4905b4492b3c7266c2d18c32302782d1185d80b5
README.zh.md: dd70f6274878baa95c0c224c7fd9aff861a8ca32
README.md: 272c9abe78849be3d2bba2c54cd7e25bcbe2d4c2
README.zh.md: fdf35d423b0a650f4e6280e500436fe8bcf5a48c

View File

@@ -10,7 +10,7 @@ The telemetry seam: the CAPTURE side of session-event reporting, behind a backen
## Capture points
The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (emit the session's `shutdown` operational record at its own termination edge — where receivers key crash detection — then retire it, so a long-lived backend neither retains closed sessions nor re-marks them at unload), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (mark each session still alive at teardown, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`).
The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (emit the session's `shutdown` operational record at its own termination edge — where receivers key crash detection — then retire it, so a long-lived backend neither retains closed sessions nor re-marks them at unload), `agent/error` (the one live-bus relay; the session event vocabulary intentionally has no operational-error record), a dispose effect (mark each session still alive at teardown, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`).
## The redact waterfall

View File

@@ -10,7 +10,7 @@
## 捕获点
协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header并经投影从构造边界起回读日志来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏、交接;零 I/O`session/flush`(转发可选的 `flush()` 提示并返回 void循环所等待的并行任务绝不能等待遥测`session/disposed`(在会话自身的终止边缘发出该会话的 `shutdown` 运维记录,接收端正是在这个边缘锚定崩溃检测;随后将该会话退役,因此长生命周期的后端既不会保留已关闭的会话,也不会在卸载时再次标记它们)、`agent/error`(唯一的实时总线转发;轮次封闭机制在结构上决定了这些错误进不了日志)、一个 dispose effect拆卸时先标记每个仍存活的会话再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。
协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header并经投影从构造边界起回读日志来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏、交接;零 I/O`session/flush`(转发可选的 `flush()` 提示并返回 void循环所等待的并行任务绝不能等待遥测`session/disposed`(在会话自身的终止边缘发出该会话的 `shutdown` 运维记录,接收端正是在这个边缘锚定崩溃检测;随后将该会话退役,因此长生命周期的后端既不会保留已关闭的会话,也不会在卸载时再次标记它们)、`agent/error`(唯一的实时总线转发;会话事件词汇有意不包含运行错误记录)、一个 dispose effect拆卸时先标记每个仍存活的会话再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。
## 脱敏 waterfall

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 96dae46c9c6ecce6643bb408a5e57c2db2275a83
README.zh.md: 2c257b13a15ad3c6c1bf0c4dd44a04e308e8f0b0
# pnpm run verify-translation-pairing --write packages/ui/jsonrpc/README.md
README.md: 48eb6106fe4b015f114b264a312249a92128266f
README.zh.md: 8ec2d57a206d770d0b77ee69036457e3b2864303

View File

@@ -22,7 +22,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. `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`.
`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 between-turn records still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`.
## Model Experience

View File

@@ -22,7 +22,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger诊断应写
## 协议说明
`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续注入或插件持有的零步骤轮次仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。
`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续轮次间记录仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。
## 模型体验