refactor(session): remove synthetic log-only turns
This commit is contained in:
@@ -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}',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' } })
|
||||
})
|
||||
})
|
||||
@@ -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[] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ Claude Code/Codex 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。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
- `setSandboxMode(session, mode)`:逐会话覆盖的唯一写入路径:恰好追加一条 `sandbox/mode` 事件。切换本身就是事件;不会在带外修改模式。
|
||||
- `SANDBOX_MODES`:所有模式,用于选项展示与运行时验证。
|
||||
|
||||
可选的 `./invariant` 配套组件会拒绝伪造的持久 `sandbox/mode` 事件,只要其值不在该封闭词汇中;Session 与其配套组件拥有周围的存储与轮次封闭规则。
|
||||
可选的 `./invariant` 配套组件会拒绝伪造的持久 `sandbox/mode` 事件,只要其值不在该封闭词汇中;Session 与其配套组件拥有周围的存储与核心执行封闭规则。
|
||||
|
||||
## 逐会话 store
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 专用行为。后续模型失败会保留请求记录;从未成为可分发请求的验证失败不会创建记录。该事件始终位于派生模型历史之外。
|
||||
|
||||
## 配置
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = () => {}
|
||||
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 的工具限制,也不会建立权限子集。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` 提供。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
Reference in New Issue
Block a user