mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-12 04:01:20 +00:00
Merge remote-tracking branch 'origin/worktree/session-format-04-live-assistant-stream' into worktree/session-format-05-v1-v2-chunk-migration
# Conflicts: # .agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.i18n.yaml # .agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.md # .agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.zh.md # .agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.i18n.yaml # .agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md # .agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md # apps/web/tests/scaffold.ts # docs/config-catalog.i18n.yaml # docs/config-catalog.md # docs/config-catalog.zh.md # packages/api/session-controller/README.i18n.yaml # packages/api/session-controller/README.md # packages/api/session-controller/README.zh.md # packages/api/session-controller/src/client/sessions/assistant-stream.ts # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/assistant-stream.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent/src/runtime-types.ts # packages/session/session-telemetry/tests/telemetry.spec.ts # packages/test-support/llm-replay/README.i18n.yaml # packages/test-support/llm-replay/README.md # packages/test-support/llm-replay/README.zh.md # packages/test-support/llm-replay/src/alpha-refusal-fixtures.ts # packages/test-support/llm-replay/src/index.ts # packages/test-support/llm-replay/tests/llm-replay.spec.ts # packages/test-support/session-snapshot/README.i18n.yaml # packages/test-support/session-snapshot/README.md # packages/test-support/session-snapshot/README.zh.md # packages/test-support/session-snapshot/src/suite.ts # packages/test-support/session-snapshot/tests/suite.spec.ts # snapshots/sdk/sdk.snapshot.ts # snapshots/session/headless.snapshot.ts
This commit is contained in:
@@ -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/api/session-controller/README.md
|
||||
README.md: 1b436cadc4992b53b3162b04be1ae256aa44f1d8
|
||||
README.zh.md: 38e8b5e9b164204d0ee274590fd7589eb3a33e24
|
||||
README.md: 0e023521615b00208c90f20a643ec2b87463d42d
|
||||
README.zh.md: ddbddff545eec58fa23805c9564cb5f87467b5a6
|
||||
|
||||
@@ -27,7 +27,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se
|
||||
|
||||
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent.
|
||||
|
||||
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedTime`, `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publish it while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps reopen follow, and an abandoned end publishes no durable settlement. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
|
||||
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedTime`, `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publish it while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes no durable settlement. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
|
||||
|
||||
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ kind: "package-reference"
|
||||
|
||||
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。
|
||||
|
||||
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedTime`、`startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 到达后再发布,而同一步骤中更早的 retry 保持可见。revision、密集 index 或 settlement 缺口会重新打开 follow,abandoned end 不发布持久 settlement。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
|
||||
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedTime`、`startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 到达后再发布,而同一步骤中更早的 retry 保持可见。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 不发布持久 settlement。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
|
||||
|
||||
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ export class ClientAssistantStream {
|
||||
this.durableCursor = Math.max(this.durableCursor, event.seq)
|
||||
this.transientInGap = 0
|
||||
if (this.attemptForSettlement(event) !== undefined) {
|
||||
if (this.pending.has(event.seq)) return { type: 'rebaseline' }
|
||||
this.pending.set(event.seq, entry)
|
||||
return undefined
|
||||
}
|
||||
@@ -115,6 +116,7 @@ export class ClientAssistantStream {
|
||||
acceptFrame(frame: SessionAssistantStreamFrame): ClientAssistantStreamResult {
|
||||
switch (frame.type) {
|
||||
case 'start':
|
||||
if (this.activeAttempt !== undefined || this.pending.size > 0) return { type: 'rebaseline' }
|
||||
this.pending.clear()
|
||||
this.activeAttempt = {
|
||||
attemptId: String(frame.attemptId),
|
||||
@@ -126,9 +128,11 @@ export class ClientAssistantStream {
|
||||
return undefined
|
||||
case 'chunk': {
|
||||
const attempt = this.activeAttempt
|
||||
if (attempt === undefined
|
||||
|| attempt.attemptId !== String(frame.attemptId)
|
||||
|| frame.index !== attempt.nextIndex) return { type: 'rebaseline' }
|
||||
// A controller mounted after the Host saw this attempt has no start
|
||||
// frame to reconstruct. Its durable settlement publishes directly;
|
||||
// ignore the transient suffix until the next known start.
|
||||
if (attempt === undefined || attempt.attemptId !== String(frame.attemptId)) return undefined
|
||||
if (frame.index !== attempt.nextIndex) return { type: 'rebaseline' }
|
||||
attempt.nextIndex += 1
|
||||
this.transientInGap += 1
|
||||
return {
|
||||
@@ -151,10 +155,10 @@ export class ClientAssistantStream {
|
||||
}
|
||||
case 'end': {
|
||||
const attempt = this.activeAttempt
|
||||
this.activeAttempt = undefined
|
||||
if (attempt === undefined || attempt.attemptId !== String(frame.attemptId)) {
|
||||
return { type: 'rebaseline' }
|
||||
return undefined
|
||||
}
|
||||
this.activeAttempt = undefined
|
||||
if (frame.index !== attempt.nextIndex) return { type: 'rebaseline' }
|
||||
if (frame.outcome.kind === 'abandoned') {
|
||||
return this.pending.size === 0 ? undefined : { type: 'rebaseline' }
|
||||
@@ -162,9 +166,7 @@ export class ClientAssistantStream {
|
||||
if (this.publishedSeqs.has(frame.outcome.seq)) return undefined
|
||||
const entry = this.pending.get(frame.outcome.seq)
|
||||
if (entry === undefined
|
||||
|| entry.event.type !== frame.outcome.eventType
|
||||
|| entry.event.data.turn !== attempt.turn
|
||||
|| entry.event.data.step !== attempt.step) {
|
||||
|| entry.event.type !== frame.outcome.eventType) {
|
||||
return { type: 'rebaseline' }
|
||||
}
|
||||
this.pending.delete(frame.outcome.seq)
|
||||
|
||||
@@ -369,7 +369,8 @@ export class SessionController extends TypertRemoteService {
|
||||
* Follow one Session log from its opening or resume cursor.
|
||||
* @param request - durable address and last committed sequence already held by the caller.
|
||||
* @param signal - cancellation owned by the Remote stream carrier.
|
||||
* @returns a complete opening snapshot followed by gap-free event frames.
|
||||
* @returns a complete opening snapshot followed by gap-free durable event
|
||||
* frames and optional cursorless assistant-stream frames.
|
||||
*/
|
||||
@Remote({ mode: 'stream' })
|
||||
follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
|
||||
|
||||
@@ -145,8 +145,6 @@ export class ApiSessionList {
|
||||
|
||||
private summarizeCold(header: SessionHeader): SessionSummary {
|
||||
const projections = this.projectionsFor(header, undefined)
|
||||
const raced = this.ctx.sessions.get(header.id)
|
||||
if (raced !== undefined) return this.summaryFor(raced)
|
||||
const metadata = projections?.values.sessionListMetadata
|
||||
return {
|
||||
sessionId: header.id,
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LlmAttemptId, createAssistantMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
SessionAssistantStreamBaseline,
|
||||
SessionAssistantStreamFrame,
|
||||
} from '../src/types.ts'
|
||||
import { ClientAssistantStream } from '../src/client/sessions/assistant-stream.ts'
|
||||
import type { SessionLiveEventEntry } from '../src/client/contract/events.ts'
|
||||
|
||||
const ATTEMPT = LlmAttemptId('session:1')
|
||||
|
||||
function entry(event: SessionEvent): SessionLiveEventEntry {
|
||||
return { type: 'event', event }
|
||||
}
|
||||
|
||||
function ordinary(seq: number): SessionLiveEventEntry {
|
||||
return entry({ type: 'turn/start', seq: SessionSeq(seq), time: seq, data: { turn: 1 } })
|
||||
}
|
||||
|
||||
function attemptEvent(seq: number, turn = 1, step = 1): SessionLiveEventEntry {
|
||||
return entry({
|
||||
type: 'assistant/attempt',
|
||||
seq: SessionSeq(seq),
|
||||
time: seq,
|
||||
data: { turn, step, stream: [] },
|
||||
})
|
||||
}
|
||||
|
||||
function messageEvent(
|
||||
seq: number,
|
||||
turn = 1,
|
||||
step = 1,
|
||||
surfaceOp: 'append' | { readonly op: 'replace'; readonly start: number; readonly end: number } = 'append',
|
||||
): SessionLiveEventEntry {
|
||||
return entry({
|
||||
type: 'assistant/message',
|
||||
seq: SessionSeq(seq),
|
||||
time: seq,
|
||||
data: {
|
||||
turn,
|
||||
step,
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
source: { provider: 'mock', model: 'mock' },
|
||||
}),
|
||||
stream: [],
|
||||
},
|
||||
surfaceOp: surfaceOp === 'append'
|
||||
? surfaceOp
|
||||
: { ...surfaceOp, start: SessionSeq(surfaceOp.start), end: SessionSeq(surfaceOp.end) },
|
||||
})
|
||||
}
|
||||
|
||||
function start(
|
||||
attemptId = ATTEMPT,
|
||||
startedAfterSeq = -1,
|
||||
): SessionAssistantStreamFrame {
|
||||
return {
|
||||
type: 'start', attemptId, revision: 1, startedTime: 10,
|
||||
startedAfterSeq: startedAfterSeq === -1 ? -1 : SessionSeq(startedAfterSeq),
|
||||
turn: 1, step: 1,
|
||||
}
|
||||
}
|
||||
|
||||
function chunkFrame(
|
||||
index: number,
|
||||
attemptId = ATTEMPT,
|
||||
): SessionAssistantStreamFrame {
|
||||
return {
|
||||
type: 'chunk', attemptId, revision: index + 2, index, time: 20 + index,
|
||||
chunk: { type: 'text-delta', index: 0, text: `chunk-${index}` },
|
||||
}
|
||||
}
|
||||
|
||||
function end(
|
||||
index: number,
|
||||
outcome: Extract<SessionAssistantStreamFrame, { type: 'end' }>['outcome'],
|
||||
attemptId = ATTEMPT,
|
||||
): SessionAssistantStreamFrame {
|
||||
return { type: 'end', attemptId, revision: index + 2, index, outcome }
|
||||
}
|
||||
|
||||
function baseline(nextIndex = 1): SessionAssistantStreamBaseline {
|
||||
return {
|
||||
revision: nextIndex + 1,
|
||||
activeAttempt: {
|
||||
attemptId: ATTEMPT,
|
||||
startedTime: 10,
|
||||
startedAfterSeq: -1,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
nextIndex,
|
||||
stream: [
|
||||
{ type: 'chunk', time: 20, chunk: { type: 'text-delta', index: 0, text: 'first' } },
|
||||
{ type: 'chunk', time: 21, chunk: { type: 'text-delta', index: 0, text: 'second' } },
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function opened(): ClientAssistantStream {
|
||||
const stream = new ClientAssistantStream()
|
||||
expect(stream.acceptFrame(start())).toBeUndefined()
|
||||
return stream
|
||||
}
|
||||
|
||||
describe('ClientAssistantStream', () => {
|
||||
it('replaces the durable window and reconstructs only the baseline prefix', () => {
|
||||
const stream = new ClientAssistantStream()
|
||||
const durable = ordinary(4)
|
||||
const visible = stream.replace([durable], baseline(1))
|
||||
|
||||
expect(visible[0]).toBe(durable)
|
||||
expect(visible.slice(1)).toEqual([expect.objectContaining({
|
||||
type: 'transient',
|
||||
event: expect.objectContaining({
|
||||
type: 'assistant/live-chunk',
|
||||
seq: 4.5,
|
||||
time: 20,
|
||||
}),
|
||||
})])
|
||||
|
||||
expect(stream.replace([], baseline(3))).toHaveLength(2)
|
||||
expect(stream.replace([])).toEqual([])
|
||||
})
|
||||
|
||||
it('passes through durable events not owned by the active attempt', () => {
|
||||
const stream = new ClientAssistantStream()
|
||||
stream.acceptFrame(start(ATTEMPT, 1))
|
||||
for (const durable of [
|
||||
ordinary(1),
|
||||
messageEvent(2, 1, 1, { op: 'replace', start: 0, end: 0 }),
|
||||
attemptEvent(0),
|
||||
attemptEvent(3, 2, 1),
|
||||
attemptEvent(4, 1, 2),
|
||||
]) {
|
||||
expect(stream.acceptDurable(durable)).toEqual({ type: 'publish', entry: durable })
|
||||
}
|
||||
})
|
||||
|
||||
it('stages one owned settlement and releases it from the matching end frame', () => {
|
||||
const stream = opened()
|
||||
const durable = messageEvent(2)
|
||||
expect(stream.acceptDurable(durable)).toBeUndefined()
|
||||
expect(stream.acceptFrame(chunkFrame(0))).toEqual(expect.objectContaining({ type: 'transient' }))
|
||||
expect(stream.acceptFrame(end(1, {
|
||||
kind: 'committed', eventType: 'assistant/message', seq: 2,
|
||||
}))).toEqual({ type: 'settlement', attemptId: String(ATTEMPT), entry: durable })
|
||||
})
|
||||
|
||||
it('rebaselines duplicate durable settlements or starts', () => {
|
||||
const duplicate = opened()
|
||||
const durable = attemptEvent(2)
|
||||
expect(duplicate.acceptDurable(durable)).toBeUndefined()
|
||||
expect(duplicate.acceptDurable(durable)).toEqual({ type: 'rebaseline' })
|
||||
expect(duplicate.acceptFrame(start(LlmAttemptId('session:2')))).toEqual({ type: 'rebaseline' })
|
||||
|
||||
const clean = new ClientAssistantStream()
|
||||
expect(clean.acceptFrame(start())).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to durable settlement for frames from an unknown attempt', () => {
|
||||
const stream = new ClientAssistantStream()
|
||||
const unknown = LlmAttemptId('session:unknown')
|
||||
expect(stream.acceptFrame(chunkFrame(0, unknown))).toBeUndefined()
|
||||
expect(stream.acceptFrame(end(0, { kind: 'abandoned' }, unknown))).toBeUndefined()
|
||||
const durable = attemptEvent(2)
|
||||
expect(stream.acceptDurable(durable)).toEqual({ type: 'publish', entry: durable })
|
||||
|
||||
const known = opened()
|
||||
expect(known.acceptFrame(chunkFrame(0, unknown))).toBeUndefined()
|
||||
expect(known.acceptFrame(end(0, { kind: 'abandoned' }, unknown))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rebaselines known attempts on chunk or terminal index mismatch', () => {
|
||||
const chunkMismatch = opened()
|
||||
expect(chunkMismatch.acceptFrame(chunkFrame(1))).toEqual({ type: 'rebaseline' })
|
||||
|
||||
const endMismatch = opened()
|
||||
expect(endMismatch.acceptFrame(end(1, { kind: 'abandoned' }))).toEqual({ type: 'rebaseline' })
|
||||
})
|
||||
|
||||
it('settles abandonment only when no durable settlement remains pending', () => {
|
||||
const empty = opened()
|
||||
expect(empty.acceptFrame(end(0, { kind: 'abandoned' }))).toBeUndefined()
|
||||
|
||||
const pending = opened()
|
||||
expect(pending.acceptDurable(attemptEvent(2))).toBeUndefined()
|
||||
expect(pending.acceptFrame(end(0, { kind: 'abandoned' }))).toEqual({ type: 'rebaseline' })
|
||||
})
|
||||
|
||||
it('rebaselines committed outcomes without one exact staged settlement', () => {
|
||||
const published = new ClientAssistantStream()
|
||||
published.replace([attemptEvent(2)], baseline(0))
|
||||
expect(published.acceptFrame(end(0, {
|
||||
kind: 'committed', eventType: 'assistant/attempt', seq: 2,
|
||||
}))).toBeUndefined()
|
||||
|
||||
const missing = opened()
|
||||
expect(missing.acceptFrame(end(0, {
|
||||
kind: 'committed', eventType: 'assistant/attempt', seq: 2,
|
||||
}))).toEqual({ type: 'rebaseline' })
|
||||
|
||||
const wrongType = opened()
|
||||
expect(wrongType.acceptDurable(messageEvent(2))).toBeUndefined()
|
||||
expect(wrongType.acceptFrame(end(0, {
|
||||
kind: 'committed', eventType: 'assistant/attempt', seq: 2,
|
||||
}))).toEqual({ type: 'rebaseline' })
|
||||
})
|
||||
})
|
||||
@@ -90,7 +90,7 @@ describe('sessions.list cold merge', () => {
|
||||
ctx.provide('sessionProjectionCache', {
|
||||
cachedSnapshot: () => undefined,
|
||||
cachedPredecessorTitle: (meta: SessionHeader) => meta.id === sid('legacy-title')
|
||||
? { asOfSeq: 3, values: { title: 'Cached predecessor title' } }
|
||||
? { asOfSeq: -1, values: { title: 'Cached predecessor title' } }
|
||||
: undefined,
|
||||
} as never)
|
||||
const remote = createSessionTestRemote(ctx, {
|
||||
@@ -112,7 +112,7 @@ describe('sessions.list cold merge', () => {
|
||||
sessionId: sid('legacy-title'),
|
||||
blank: false,
|
||||
updatedAt: 100,
|
||||
projections: { asOfSeq: 3, values: { title: 'Cached predecessor title' } },
|
||||
projections: { asOfSeq: -1, values: { title: 'Cached predecessor title' } },
|
||||
}),
|
||||
])
|
||||
expect(stat).not.toHaveBeenCalled()
|
||||
@@ -172,51 +172,6 @@ describe('sessions.list cold merge', () => {
|
||||
expect(inspect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prefers a live row attached during cache lookup without folding its seed', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const meta = header('attached-during-list', 100)
|
||||
providePersistence(ctx, {
|
||||
list: () => Promise.resolve([meta]),
|
||||
})
|
||||
const cacheLookup = vi.fn(() => {
|
||||
const session = ctx.sessions.create(meta.id, {
|
||||
seed: [
|
||||
{ type: 'turn/start', seq: SessionSeq(0), time: 200, data: { turn: 1 } },
|
||||
{
|
||||
type: 'user/message', seq: SessionSeq(1), time: 300,
|
||||
data: createUserMessage({ content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } }),
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
...meta.cwd === undefined ? {} : { cwd: meta.cwd },
|
||||
createdAt: meta.createdAt,
|
||||
},
|
||||
})
|
||||
ctx.agents.register({ id: session.id, session, status: 'running', ctx } as Agent)
|
||||
return undefined
|
||||
})
|
||||
ctx.provide('sessionProjectionCache', {
|
||||
cachedSnapshot: cacheLookup,
|
||||
cachedPredecessorTitle: () => undefined,
|
||||
} as never)
|
||||
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const response = await remote.list(request({}))
|
||||
if (!response.ok) throw new Error('list failed')
|
||||
expect(response.value.items).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: meta.id,
|
||||
blank: false,
|
||||
running: true,
|
||||
updatedAt: 100,
|
||||
}),
|
||||
])
|
||||
expect(cacheLookup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('attached updatedAt tracks human prompts', () => {
|
||||
|
||||
Reference in New Issue
Block a user