@springbrand/agent-runtime 0.3.0-alpha.16 → 0.3.0-alpha.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,8 @@
1
- import type { AgentEvent, Events, HarnessEvent } from "@earendil-works/pi-agent-core";
2
- import type { OperationResultRecord } from "@earendil-works/pi-agent-core/harness/session";
1
+ import type { AgentEvent as LoopEvent } from "@earendil-works/pi-agent-core";
2
+ import type { AgentEvent, SnapshotEvent } from "@earendil-works/pi-durable";
3
3
  import type { AssistantMessage } from "@earendil-works/pi-ai";
4
4
  import type { UIMessage, UIMessageChunk } from "ai";
5
+ import type { SessionHarness, SessionRunOutcome } from "../harness/session-harness";
5
6
  import { PiChunkEncoder, type ToolInputPreparer } from "./message";
6
7
 
7
8
  export interface HarnessStreamOptions {
@@ -11,89 +12,103 @@ export interface HarnessStreamOptions {
11
12
  readonly userMessageId?: string | null;
12
13
  readonly prepareToolInput?: ToolInputPreparer;
13
14
  }
14
-
15
- /** Project the final Host decision without rewriting Pi's canonical operation result. */
16
- export function projectSubmissionOutcome(
17
- record: OperationResultRecord,
18
- submission: { status: string; completedAt?: number | null; error?: string | null },
19
- ): OperationResultRecord {
20
- const status = submission.status === "aborted" ? "aborted"
21
- : submission.status === "error" ? "failed"
22
- : submission.status === "skipped" ? "declined" : "completed";
23
- const { error: _previousError, ...result } = record;
24
- return {
25
- ...result, status, endedAt: submission.completedAt ?? record.endedAt,
26
- ...((status === "failed" || status === "aborted") && submission.error
27
- ? { error: { code: status, message: submission.error } } : {}),
28
- };
15
+ export function projectSubmissionOutcome(record: SessionRunOutcome, submission: { status: string; completedAt?: number | null; error?: string | null }): SessionRunOutcome {
16
+ const status = submission.status === "aborted" ? "aborted" : submission.status === "error" ? "failed" : submission.status === "skipped" ? "declined" : "completed";
17
+ return { status, endedAt: submission.completedAt ?? record.endedAt, ...(submission.error ? { error: { code: status, message: submission.error } } : {}) };
29
18
  }
30
19
 
31
- /** One projection for both broadcast events and a resumed Pi watch. */
20
+ /** Translates native committed Pi deltas into the browser's AI SDK wire protocol. */
32
21
  export class HarnessStreamProjection {
33
22
  private readonly encoder: PiChunkEncoder;
34
- private terminal: AssistantMessage | undefined;
35
-
36
- constructor(options: HarnessStreamOptions) {
37
- this.encoder = new PiChunkEncoder(options);
38
- }
39
-
40
- restore(message: UIMessage, streaming: boolean): UIMessageChunk[] {
23
+ private terminal?: AssistantMessage;
24
+ private readonly shown = new Map<number, string>();
25
+ private readonly toolInputStarted = new Set<string>();
26
+ private readonly toolOutput = new Map<string, string>();
27
+ constructor(options: HarnessStreamOptions) { this.encoder = new PiChunkEncoder(options); }
28
+ restore(message: UIMessage, streaming: boolean, snapshot?: SnapshotEvent): UIMessageChunk[] {
29
+ this.shown.clear();
30
+ snapshot?.generation?.message?.content.forEach((block, index) => {
31
+ if (block.type === "text") this.shown.set(index, block.text);
32
+ if (block.type === "thinking") this.shown.set(index, block.thinking);
33
+ });
34
+ for (const tool of snapshot?.tools ?? []) if (tool.output !== undefined) this.toolOutput.set(tool.callId, tool.output);
41
35
  return this.encoder.restore(message, streaming);
42
36
  }
43
-
44
- event(event: HarnessEvent): UIMessageChunk[] {
37
+ private block(block: AssistantMessage["content"][number], contentIndex: number, settled = false): UIMessageChunk[] {
38
+ if (block.type === "toolCall" && (!block.id || !block.name)) return [];
39
+ if (block.type === "toolCall") {
40
+ // The encoder keeps the first input it sees, so only a settled message may publish arguments.
41
+ if (settled) return this.encoder.encode({ type: "tool_execution_start", toolCallId: block.id, toolName: block.name, args: block.arguments } as LoopEvent);
42
+ if (this.toolInputStarted.has(block.id)) return [];
43
+ this.toolInputStarted.add(block.id);
44
+ return [{ type: "tool-input-start", toolCallId: block.id, toolName: block.name, dynamic: true }];
45
+ }
46
+ if (block.type !== "text" && block.type !== "thinking") return [];
47
+ const start = block.type === "thinking"
48
+ ? this.encoder.encode({ type: "message_update", assistantMessageEvent: { type: "thinking_start", contentIndex } } as LoopEvent)
49
+ : [];
50
+ const text = block.type === "text" ? block.text : block.thinking;
51
+ const previous = this.shown.get(contentIndex) ?? "";
52
+ const delta = text.startsWith(previous) ? text.slice(previous.length) : text;
53
+ this.shown.set(contentIndex, text);
54
+ if (!delta) return start;
55
+ return [...start, ...this.encoder.encode({ type: "message_update", assistantMessageEvent: { type: block.type === "text" ? "text_delta" : "thinking_delta", contentIndex, delta } } as LoopEvent)];
56
+ }
57
+ /**
58
+ * Pi emits `run_start` only when the live run changes, so a stream bound to a continued run (after an approval or a
59
+ * slice release) sees tool or message frames first. The encoder's start is idempotent; lead the first frames with it.
60
+ */
61
+ private started(chunks: UIMessageChunk[]): UIMessageChunk[] {
62
+ return chunks.length === 0 ? chunks : [...this.encoder.start(), ...chunks];
63
+ }
64
+ event(event: AgentEvent): UIMessageChunk[] { return this.started(this.frames(event)); }
65
+ private frames(event: AgentEvent): UIMessageChunk[] {
45
66
  switch (event.type) {
46
- case "run_start":
47
- case "run_resume": return this.encoder.start();
48
- case "turn_start":
49
- this.terminal = undefined;
50
- return this.encoder.encode({ type: "turn_start" } as AgentEvent);
51
- case "message_update":
52
- return event.event.type === "error" ? [] : this.encoder.encode({
53
- type: "message_update", message: event.message, assistantMessageEvent: event.event,
54
- } as AgentEvent);
55
- case "tool_start": return this.encoder.encode({
56
- type: "tool_execution_start", toolCallId: event.toolCallId, toolName: event.toolName, args: event.args,
57
- } as AgentEvent);
58
- case "tool_update": return this.encoder.encode({
59
- type: "tool_execution_update", toolCallId: event.toolCallId, toolName: event.toolName, args: {}, partialResult: event.partialResult,
60
- } as AgentEvent);
61
- case "tool_end": return this.encoder.encode({
62
- type: "tool_execution_end", toolCallId: event.toolCallId, toolName: event.toolName, result: event.result, isError: event.isError,
63
- } as AgentEvent);
64
- case "message_end":
65
- if (event.message.role !== "assistant") return [];
66
- this.terminal = event.message;
67
- return this.encoder.encode({ type: "message_end", message: event.message } as AgentEvent, { terminal: false });
67
+ case "run_start": return this.encoder.start();
68
+ case "turn_start": this.terminal = undefined; this.shown.clear(); return this.encoder.encode({ type: "turn_start" } as LoopEvent);
69
+ case "message_start": return event.message.role === "assistant" ? event.message.content.flatMap((block, index) => this.block(block, index)) : [];
70
+ case "message_update": return event.changes.flatMap((change) => {
71
+ if (change.type === "message") return change.message.content.flatMap((block, index) => this.block(block, index));
72
+ if (change.type === "text_start" || change.type === "thinking_start" || change.type === "block" || change.type === "toolcall_start") return this.block(change.block, change.contentIndex);
73
+ if (change.type !== "text_delta" && change.type !== "thinking_delta") return [];
74
+ this.shown.set(change.contentIndex, (this.shown.get(change.contentIndex) ?? "") + change.delta);
75
+ return this.encoder.encode({ type: "message_update", assistantMessageEvent: change } as LoopEvent);
76
+ });
77
+ case "tool_execution_start": return this.encoder.encode(event as LoopEvent);
78
+ case "tool_execution_update": {
79
+ let output = this.toolOutput.get(event.toolCallId) ?? "";
80
+ if (event.output) output = "set" in event.output ? event.output.set : output.slice(event.output.trimStart ?? 0) + (event.output.append ?? "");
81
+ this.toolOutput.set(event.toolCallId, output);
82
+ return this.encoder.encode({ type: "tool_execution_update", toolCallId: event.toolCallId, toolName: event.toolName, args: {}, partialResult: { content: [{ type: "text", text: output }], details: event.details } } as LoopEvent);
83
+ }
84
+ case "tool_execution_end": {
85
+ const result = event.entry?.model?.[0];
86
+ return this.encoder.encode({ type: "tool_execution_end", toolCallId: event.toolCallId, toolName: event.toolName, result: result?.role === "toolResult" ? result : { content: [{ type: "text", text: "Tool execution interrupted" }] }, isError: result?.role !== "toolResult" || result.isError } as LoopEvent);
87
+ }
88
+ case "message_end": {
89
+ const message = event.entry.model?.[0];
90
+ if (message?.role !== "assistant") return [];
91
+ this.terminal = message;
92
+ return [...message.content.flatMap((block, index) => this.block(block, index, true)), ...this.encoder.encode({ type: "message_end", message }, { terminal: false })];
93
+ }
68
94
  default: return [];
69
95
  }
70
96
  }
71
-
72
- approvalRequested(toolCallId: string, approvalId: string): UIMessageChunk[] {
73
- return this.encoder.approvalRequested(toolCallId, approvalId);
74
- }
75
-
76
- finish(result: Pick<OperationResultRecord, "status" | "endedAt" | "error">): UIMessageChunk[] {
77
- return this.encoder.finish({
78
- stopReason: result.status === "aborted" ? "aborted" : result.status === "failed" ? "error" :
79
- this.terminal?.stopReason === "length" ? "length" : "stop",
80
- timestamp: result.endedAt,
81
- ...(result.error?.message ? { errorMessage: result.error.message } : {}),
82
- });
97
+ approvalRequested(toolCallId: string, approvalId: string): UIMessageChunk[] { return this.started(this.encoder.approvalRequested(toolCallId, approvalId)); }
98
+ finish(result: SessionRunOutcome): UIMessageChunk[] {
99
+ return this.started(this.encoder.finish({ stopReason: result.status === "aborted" ? "aborted" : result.status === "failed" ? "error" : this.terminal?.stopReason === "length" ? "length" : "stop", timestamp: result.endedAt, ...(result.error ? { errorMessage: result.error.message } : {}) }));
83
100
  }
84
101
  }
85
102
 
86
- const STREAM_EVENTS = ["run_start", "run_resume", "turn_start", "message_update", "tool_start", "tool_update", "tool_end", "message_end"] as const;
87
-
88
- export function bindHarnessStream(
89
- events: Events,
90
- options: HarnessStreamOptions & { readonly publish: (chunks: readonly UIMessageChunk[]) => Promise<void> },
91
- ) {
103
+ export async function bindHarnessStream(runtime: SessionHarness, options: HarnessStreamOptions & { readonly publish: (chunks: readonly UIMessageChunk[]) => Promise<void> }) {
92
104
  const projection = new HarnessStreamProjection(options);
93
- const unsubscribers = STREAM_EVENTS.map((type) => events.on(type, (event) => options.publish(projection.event(event))));
105
+ const watch = await runtime.watch((await import("@earendil-works/chord/context")).BACKGROUND_CONTEXT);
106
+ let delivery: Promise<void> = Promise.resolve();
107
+ watch.start((events) => delivery = (async () => { for (const event of events) await options.publish(projection.event(event)); })());
94
108
  return {
95
109
  publishApprovalRequest: (toolCallId: string, approvalId: string) => options.publish(projection.approvalRequested(toolCallId, approvalId)),
96
- flushTerminal: (result: OperationResultRecord) => options.publish(projection.finish(result)),
97
- unsubscribe: () => { for (const unsubscribe of unsubscribers) unsubscribe(); },
110
+ // The Host follows the terminal frame with the canonical transcript, which completes any frame Pi's stop dropped.
111
+ flushTerminal: async (result: SessionRunOutcome) => { await watch.stop(); await delivery; await options.publish(projection.finish(result)); },
112
+ unsubscribe: async () => { await watch.stop(); await delivery; },
98
113
  };
99
114
  }
@@ -1,8 +1,7 @@
1
- import type {
2
- AgentMessage,
3
- Entry,
4
- LaneSnapshot,
5
- } from "@earendil-works/pi-agent-core";
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import { createSession, type EntryId, type EntryRecord, type Session, type Storage, type SnapshotEvent } from "@earendil-works/pi-durable";
3
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
4
+ import { projectSessionEntry, type ProjectedSessionEntry as Entry } from "./session-entry";
6
5
  import type {
7
6
  AssistantMessage,
8
7
  JsonValue,
@@ -18,11 +17,31 @@ import {
18
17
  type PiToolApprovalView,
19
18
  type UIUserSidecar,
20
19
  } from "./message";
21
- import { openPiSessionStorage, type CloudflareSessionStorage } from "../session";
22
- import {
23
- isModelStreamStallMessage,
24
- isRecoverableAssistantError,
25
- } from "../models";
20
+ import { openCloudflareSession, RuntimeConversation } from "../session";
21
+ import { dropLegacyPiTables } from "../session/cloudflare/migration";
22
+
23
+ /**
24
+ * Pi Durable 1.1 has no public API that deletes a conversation; `Tx` can only create, fork and retire documents.
25
+ * Clear therefore deletes every record outside the newly selected conversation from the official SQLite schema
26
+ * version 1. A different schema version keeps the records rather than guessing at its layout.
27
+ */
28
+ function deleteConversationsExcept(sql: SqlStorage, keep: number): void {
29
+ const schema = sql.exec<{ version: number }>("SELECT version FROM durable_schema WHERE singleton=1").toArray()[0]?.version;
30
+ if (schema !== 1) {
31
+ console.warn("[conversation-clear:degraded]", `Unsupported Pi Durable SQLite schema ${String(schema)}; previous records kept`);
32
+ return;
33
+ }
34
+ const documents = `SELECT id FROM documents WHERE (scope_kind='conversation' AND owner_id<>?1)
35
+ OR (scope_kind='task' AND owner_id IN (SELECT id FROM tasks WHERE conversation_id<>?1))`;
36
+ sql.exec(`DELETE FROM document_revisions WHERE document_id IN (${documents})`, keep);
37
+ sql.exec(`DELETE FROM record_ids WHERE id IN (${documents}
38
+ UNION SELECT id FROM submissions WHERE conversation_id<>?1 UNION SELECT id FROM tasks WHERE conversation_id<>?1
39
+ UNION SELECT id FROM entries WHERE conversation_id<>?1 UNION SELECT id FROM conversations WHERE id<>?1)`, keep);
40
+ sql.exec(`DELETE FROM documents WHERE id IN (${documents})`, keep);
41
+ for (const [table, column] of [["submissions", "conversation_id"], ["tasks", "conversation_id"], ["entries", "conversation_id"], ["conversations", "id"]]) {
42
+ sql.exec(`DELETE FROM ${table} WHERE ${column}<>?`, keep);
43
+ }
44
+ }
26
45
 
27
46
  /**
28
47
  * 本文件负责保存 Pi 的规范消息,并按浏览器或 Extension Host 的需要投影历史。
@@ -204,52 +223,42 @@ function hostText(message: UserMessage | AssistantMessage): string {
204
223
  * Pi Session 保存规范分支,Runtime 数据库保存 Submission、审批和用户 sidecar;分开保存可避免把 UI 字段写进模型上下文。
205
224
  */
206
225
  export class ConversationHistory {
207
- private readonly store: CloudflareSessionStorage;
208
-
209
- private modelBranch() {
210
- return this.store.branch().filter((entry) =>
211
- entry.type !== "message" ||
212
- entry.message.role !== "assistant" ||
213
- (!isModelStreamStallMessage(entry.message.errorMessage) &&
214
- !isRecoverableAssistantError(entry.message))
215
- );
216
- }
217
-
218
- /** Resolve a browser-stable user ID to the Session entry generated by Harness. */
219
- private storedMessageId(id: string): string {
220
- const direct = this.store.entry(id);
221
- if (direct?.type === "message") return id;
222
- const submissionByMessage =
223
- this.durability.listSessionEntrySubmissionLinks();
224
- return this.store.branch().find((entry) => {
225
- if (entry.type !== "message" || entry.message.role !== "user") {
226
- return false;
227
- }
228
- const body = this.durability.readUserSidecar(entry.id);
229
- if (body) {
230
- const sidecar = JSON.parse(body) as UIUserSidecar & { id?: unknown };
231
- if (sidecar.id === id) return true;
232
- }
233
- const submissionId = submissionByMessage.get(entry.id);
234
- return submissionId !== undefined &&
235
- this.durability.findSubmissionProjection(submissionId)?.userMessageId ===
236
- id;
237
- })?.id ?? id;
238
- }
239
-
240
- /**
241
- * 用当前 Agent 的 SQLite、Runtime 持久化端口和 Pi 模型表创建 Transcript。
242
- *
243
- * `AgentRuntimeKernel` 每个实例构造时调用一次;`hasActiveTurn` 必须反映当前 Submission 生命周期。
244
- *
245
- * 官方 Storage 契约由同一 DO SQLite 实现;模型目录用于压缩,活动 Turn 检查阻止并发导入。
246
- */
247
226
  constructor(
248
- storage: DurableObjectStorage,
227
+ private readonly storage: DurableObjectStorage,
249
228
  private readonly durability: PiTranscriptDurability,
250
229
  private readonly hasActiveTurn: () => boolean,
251
- ) {
252
- this.store = openPiSessionStorage(storage);
230
+ ) {}
231
+
232
+ private async withSession<T>(run: (session: Session, store: Storage) => Promise<T>): Promise<T> {
233
+ const store = await openCloudflareSession(this.storage);
234
+ const session = createSession(store);
235
+ try { return await run(session, store); }
236
+ finally { await session.close(BACKGROUND_CONTEXT); }
237
+ }
238
+ private async nativeEntries(maxEntryId?: EntryId): Promise<EntryRecord[]> {
239
+ return this.withSession(async (session, store) => {
240
+ const pointer = await session.snapshot(RuntimeConversation, BACKGROUND_CONTEXT);
241
+ if (!pointer?.id) return [];
242
+ const entries: EntryRecord[] = [];
243
+ let cursor;
244
+ do {
245
+ const page = await store.scanEntries({ conversationId: pointer.id, ...(maxEntryId !== undefined ? { maxEntryId } : {}), order: "ascending" }, 200, cursor, BACKGROUND_CONTEXT);
246
+ entries.push(...page.items); cursor = page.next;
247
+ } while (cursor);
248
+ return entries;
249
+ });
250
+ }
251
+ private async storedMessageId(id: string): Promise<string> {
252
+ const links = this.durability.listSessionEntrySubmissionLinks();
253
+ for (const entry of await this.nativeEntries()) {
254
+ const key = String(entry.id);
255
+ if (key === id || (entry.data as { displayId?: string } | undefined)?.displayId === id) return key;
256
+ const body = this.durability.readUserSidecar(key);
257
+ if (body && (JSON.parse(body) as { id?: string }).id === id) return key;
258
+ const owner = links.get(key);
259
+ if (owner && this.durability.findSubmissionProjection(owner)?.userMessageId === id) return key;
260
+ }
261
+ return id;
253
262
  }
254
263
 
255
264
  // #region 规范消息写入
@@ -261,33 +270,39 @@ export class ConversationHistory {
261
270
  *
262
271
  * sidecar 只随首次成功插入写入,使消息 ID 同时承担幂等键,重复请求不能覆盖原视图。
263
272
  */
264
- append(
273
+ async append(
265
274
  id: string,
266
275
  message: AgentMessage,
267
- options: {
268
- submissionId?: string;
269
- userMessage?: UIMessage & { role: "user" };
270
- } = {},
271
- ): boolean {
272
- const inserted = this.store.appendMessage({
273
- id,
274
- message,
275
- ...(options.submissionId
276
- ? { submissionId: options.submissionId }
277
- : {}),
278
- });
279
- if (inserted && options.userMessage) {
280
- const sidecar = captureUIUserSidecar(options.userMessage);
281
- this.durability.upsertUserSidecar(id, JSON.stringify(sidecar));
282
- }
283
- return inserted;
276
+ options: { submissionId?: string; userMessage?: UIMessage & { role: "user" } } = {},
277
+ ): Promise<boolean> {
278
+ if ((await this.nativeEntries()).some((entry) => (entry.data as { displayId?: string } | undefined)?.displayId === id)) return false;
279
+ return this.storage.transaction(() => this.withSession(async (session) => {
280
+ const pointer = await session.snapshot(RuntimeConversation, BACKGROUND_CONTEXT);
281
+ let conversationId = pointer?.id;
282
+ if (!conversationId) conversationId = await session.commit(async (tx) => {
283
+ const conversation = await tx.createConversation({ ownership: { kind: "ownerless" } });
284
+ (await tx.doc(RuntimeConversation)).id = conversation.id;
285
+ return conversation.id;
286
+ }, BACKGROUND_CONTEXT);
287
+ await session.commit(async (tx) => {
288
+ const custom = message.role === "custom";
289
+ const entry = await tx.appendEntry(conversationId!, {
290
+ kind: custom ? "springbrand.custom" : message.role === "toolResult" ? "pi.tool-result" : `pi.${message.role}`,
291
+ // Custom messages reach the model as user context, as Pi's convertToLlm did before the native harness.
292
+ model: [custom ? { role: "user", content: [...message.content], timestamp: message.timestamp } : message],
293
+ data: JSON.parse(JSON.stringify({ displayId: id, createdAt: message.timestamp, ...(custom ? { message } : {}), ...(options.submissionId ? { submissionId: options.submissionId } : {}), ...(message.role === "toolResult" ? { diagnostics: [] } : {}) })),
294
+ });
295
+ if (options.userMessage) this.durability.upsertUserSidecar(String(entry.id), JSON.stringify({ id: options.userMessage.id, ...captureUIUserSidecar(options.userMessage) }));
296
+ }, BACKGROUND_CONTEXT);
297
+ return true;
298
+ }));
284
299
  }
285
300
 
286
301
  /** Persist a stable marker so browser history can distinguish a user stop from other aborts. */
287
302
  appendTurnAbortedMarker(
288
303
  submissionId: string,
289
304
  timestamp: number,
290
- ): boolean {
305
+ ): Promise<boolean> {
291
306
  return this.append(`${submissionId}:turn-aborted`, {
292
307
  role: "custom",
293
308
  customType: "turn-aborted",
@@ -312,7 +327,7 @@ export class ConversationHistory {
312
327
  * 这里只返回 `message` entry;compaction 等 Session entry 由上下文转换阶段处理,不能混入原始消息列表。
313
328
  */
314
329
  async canonicalMessages(): Promise<AgentMessage[]> {
315
- return this.modelBranch().flatMap((entry) =>
330
+ return (await this.storedEntries()).map(({ entry }) => entry).flatMap((entry) =>
316
331
  entry.type === "message" ? [entry.message] : []
317
332
  );
318
333
  }
@@ -325,10 +340,13 @@ export class ConversationHistory {
325
340
  * 同时检查 entry 类型和消息角色,防止其他 Session entry 或助手消息被当作 regenerate 起点。
326
341
  */
327
342
  async findUserMessage(id: string): Promise<UserMessage | undefined> {
328
- const entry = this.store.entry(this.storedMessageId(id));
329
- return entry?.type === "message" && entry.message.role === "user"
330
- ? entry.message
331
- : undefined;
343
+ const key = await this.storedMessageId(id);
344
+ if (!Number.isSafeInteger(Number(key)) || Number(key) <= 0) return undefined;
345
+ return this.withSession(async (_session, store) => {
346
+ const native = await store.entry(Number(key) as EntryId, BACKGROUND_CONTEXT);
347
+ const message = native?.entry.model?.[0];
348
+ return message?.role === "user" ? message : undefined;
349
+ });
332
350
  }
333
351
 
334
352
  // #endregion
@@ -336,12 +354,12 @@ export class ConversationHistory {
336
354
  // #region 分支、压缩与导入
337
355
 
338
356
  /** Resolve the branch position before the user prompt that will be regenerated. */
339
- regenerationTarget(id: string): string | null {
340
- const entry = this.store.entry(this.storedMessageId(id));
341
- if (entry?.type !== "message" || entry.message.role !== "user") {
342
- throw new Error(`Unknown regeneration user message: ${id}`);
343
- }
344
- return entry.parentId;
357
+ async regenerationTarget(id: string): Promise<EntryId | null> {
358
+ const key = await this.storedMessageId(id);
359
+ const entries = await this.nativeEntries();
360
+ const index = entries.findIndex((entry) => String(entry.id) === key && entry.model?.[0]?.role === "user");
361
+ if (index < 0) throw new Error(`Unknown regeneration user message: ${id}`);
362
+ return entries[index - 1]?.id ?? null;
345
363
  }
346
364
 
347
365
  /**
@@ -353,35 +371,18 @@ export class ConversationHistory {
353
371
  *
354
372
  * 待确认:当前代码没有拒绝以助手消息开头的历史,而这类消息没有 Submission 关联,会被浏览器投影略过。
355
373
  */
356
- importSnapshot(snapshot: PiCanonicalTranscriptSnapshot): void {
357
- if (this.hasActiveTurn()) {
358
- throw new Error("Cannot import messages while a SpringBrand Turn is active");
374
+ async importSnapshot(snapshot: PiCanonicalTranscriptSnapshot): Promise<void> {
375
+ if (this.hasActiveTurn()) throw new Error("Cannot import messages while a Turn is active");
376
+ await this.storage.transaction(async () => {
377
+ let submissionId: string | undefined;
378
+ for (const entry of snapshot.entries) {
379
+ if (entry.message.role === "user") submissionId = `import:${entry.id}`;
380
+ const inserted = await this.append(entry.id, entry.message, {
381
+ submissionId,
382
+ ...(entry.message.role === "user" && entry.userSidecar ? { userMessage: { id: entry.id, role: "user" as const, parts: [...entry.userSidecar.parts], metadata: entry.userSidecar.metadata } } : {}),
383
+ });
384
+ if (!inserted) throw new Error(`Session message "${entry.id}" already exists`);
359
385
  }
360
- this.durability.transaction(() => {
361
- let currentSubmissionId: string | undefined;
362
- for (const entry of snapshot.entries) {
363
- if (entry.message.role === "user") {
364
- currentSubmissionId = `import:${entry.id}`;
365
- }
366
- const appended = this.append(entry.id, entry.message, {
367
- submissionId: currentSubmissionId,
368
- ...(entry.message.role === "user" && entry.userSidecar
369
- ? {
370
- userMessage: {
371
- id: entry.id,
372
- role: "user",
373
- parts: [...entry.userSidecar.parts],
374
- ...(entry.userSidecar.metadata === undefined
375
- ? {}
376
- : { metadata: entry.userSidecar.metadata }),
377
- } satisfies UIMessage & { role: "user" },
378
- }
379
- : {}),
380
- });
381
- if (!appended) {
382
- throw new Error(`SpringBrand Session message "${entry.id}" already exists`);
383
- }
384
- }
385
386
  });
386
387
  }
387
388
 
@@ -390,7 +391,7 @@ export class ConversationHistory {
390
391
  throughMessageId: string,
391
392
  ): Promise<PiCanonicalTranscriptSnapshot> {
392
393
  const stored = await this.storedMessages();
393
- const storedId = this.storedMessageId(throughMessageId);
394
+ const storedId = await this.storedMessageId(throughMessageId);
394
395
  let cutoff = stored.findIndex((entry) => entry.id === storedId);
395
396
  if (cutoff < 0) {
396
397
  const targetSubmissionId = stored.find((entry) => {
@@ -442,8 +443,19 @@ export class ConversationHistory {
442
443
  *
443
444
  * 两类 Transcript 数据必须一起删除,否则复用消息 ID 时可能重新读到旧附件或 UI 元数据。
444
445
  */
445
- clear(): void {
446
- this.store.clear();
446
+ async clear(): Promise<void> {
447
+ await this.storage.transaction(() => this.withSession(async (session) => {
448
+ const next = await session.commit(async (tx) => {
449
+ const conversation = await tx.createConversation({ ownership: { kind: "ownerless" } });
450
+ (await tx.doc(RuntimeConversation)).id = conversation.id;
451
+ return conversation.id;
452
+ }, BACKGROUND_CONTEXT);
453
+ deleteConversationsExcept(this.storage.sql, next);
454
+ await dropLegacyPiTables(this.storage, session);
455
+ // Entry associations name records that no longer exist.
456
+ this.storage.sql.exec("CREATE TABLE IF NOT EXISTS pi_session_entry_submission (entry_id TEXT PRIMARY KEY, submission_id TEXT NOT NULL)");
457
+ this.storage.sql.exec("DELETE FROM pi_session_entry_submission");
458
+ }));
447
459
  this.durability.clearUserSidecars();
448
460
  }
449
461
 
@@ -463,39 +475,17 @@ export class ConversationHistory {
463
475
  }
464
476
 
465
477
  /** Reconnect uses Pi's complete lane view, including uncommitted model/tool progress. */
466
- async browserSnapshot(snapshot: LaneSnapshot): Promise<UIMessage[]> {
467
- const entries = await this.storedMessages(snapshot.transcript);
468
- const operation = snapshot.operation;
469
- const streaming = operation?.streamingMessage;
470
- const streamingId = streaming ? `streaming:${operation.id}` : undefined;
471
- if (streaming && streamingId) {
472
- entries.push({
473
- id: streamingId,
474
- submissionId: operation.id,
475
- message: streaming,
476
- createdAt: streaming.timestamp,
477
- });
478
- }
478
+ async browserSnapshot(snapshot: SnapshotEvent, submissionId: string): Promise<UIMessage[]> {
479
+ const entries = await this.storedMessages((await this.nativeEntries(snapshot.entries.at(-1)?.id)).map(projectSessionEntry));
480
+ const streaming = snapshot.generation?.message;
481
+ const streamingId = streaming ? `streaming:${submissionId}` : undefined;
482
+ if (streaming && streamingId) entries.push({ id: streamingId, submissionId, message: streaming, createdAt: streaming.timestamp });
479
483
  let messages = await this.projectBrowserMessages(entries, streamingId);
480
- for (const tool of operation?.runningTools ?? []) {
481
- const toolResult = tool.result;
482
- if (!toolResult) continue;
483
- messages = messages.map((message) => {
484
- const metadata = message.metadata as { turnId?: string } | undefined;
485
- if (message.role !== "assistant" || metadata?.turnId !== operation?.id) {
486
- return message;
487
- }
488
- return applyPiToolResult(message, {
489
- role: "toolResult",
490
- toolCallId: tool.toolCallId,
491
- toolName: tool.toolName,
492
- ...toolResult,
493
- content: toolResult.content ?? [],
494
- details: toolResult.details as JsonValue | undefined,
495
- isError: tool.status === "settled" && tool.isError,
496
- timestamp: snapshot.operation!.startedAt,
497
- }, { preliminary: tool.status === "running" });
498
- });
484
+ for (const tool of snapshot.tools) {
485
+ if (tool.output === undefined && tool.details === undefined) continue;
486
+ messages = messages.map((message) => message.role === "assistant" && (message.metadata as { turnId?: string } | undefined)?.turnId === submissionId
487
+ ? applyPiToolResult(message, { role: "toolResult", toolCallId: tool.callId, toolName: tool.name, content: tool.output ? [{ type: "text", text: tool.output }] : [], details: tool.details, isError: false, timestamp: streaming?.timestamp ?? 0 }, { preliminary: tool.status !== "done" })
488
+ : message);
499
489
  }
500
490
  return messages;
501
491
  }
@@ -504,6 +494,10 @@ export class ConversationHistory {
504
494
  entries: readonly StoredTranscriptMessage[],
505
495
  streamingId?: string,
506
496
  ): Promise<UIMessage[]> {
497
+ const lastAnswer = new Map<string, number>();
498
+ entries.forEach((entry, index) => {
499
+ if (entry.submissionId && entry.message.role === "assistant" && entry.message.stopReason !== "aborted") lastAnswer.set(entry.submissionId, index);
500
+ });
507
501
  const result: UIMessage[] = [];
508
502
  const seenAssistantSubmissions = new Set<string>();
509
503
  const seenUserSubmissions = new Set<string>();
@@ -519,7 +513,7 @@ export class ConversationHistory {
519
513
  const submissionId = pendingFailedSubmissionId;
520
514
  pendingFailedSubmissionId = undefined;
521
515
  const submission = this.durability.findSubmissionProjection(submissionId);
522
- if (!submission || (submission.status !== "error" &&
516
+ if (!submission || (submission.status !== "error" && submission.status !== "aborted" &&
523
517
  !(submission.status === "skipped" && submission.error))) return;
524
518
  const completedAt = submission.completedAt ?? submission.createdAt;
525
519
  result.push({
@@ -537,7 +531,8 @@ export class ConversationHistory {
537
531
  0,
538
532
  completedAt - submission.createdAt,
539
533
  ),
540
- turnStatus: "error",
534
+ turnStatus: submission.status === "aborted" ? "aborted" : "error",
535
+ ...(submission.status === "aborted" ? { interruptedByUser: true } : {}),
541
536
  ...(submission.error
542
537
  ? { error: publicAssistantError(submission.error) }
543
538
  : {}),
@@ -548,7 +543,9 @@ export class ConversationHistory {
548
543
  let activeAssistant:
549
544
  | { submissionId: string; index: number }
550
545
  | undefined;
551
- for (const entry of entries) {
546
+ for (const [index, entry] of entries.entries()) {
547
+ // Pi retains interrupted attempts; a later answer replaces them only in the browser.
548
+ if (entry.submissionId && entry.message.role === "assistant" && entry.message.stopReason === "aborted" && (lastAnswer.get(entry.submissionId) ?? -1) > index) continue;
552
549
  if (
553
550
  pendingFailedSubmissionId &&
554
551
  entry.submissionId !== pendingFailedSubmissionId
@@ -599,6 +596,8 @@ export class ConversationHistory {
599
596
  entry.message.role === "custom" &&
600
597
  entry.message.customType === "turn-aborted"
601
598
  ) {
599
+ // The marker is this stop's terminal; the no-answer fallback would add a second one.
600
+ if (pendingFailedSubmissionId === entry.submissionId) pendingFailedSubmissionId = undefined;
602
601
  const submission = this.durability.findSubmissionProjection(
603
602
  entry.submissionId,
604
603
  );
@@ -812,7 +811,7 @@ export class ConversationHistory {
812
811
  * UI uses the message event time; Pi entry timestamps describe storage commits.
813
812
  */
814
813
  async storedMessages(
815
- snapshotEntries: readonly Entry[] = this.store.branch(),
814
+ snapshotEntries?: readonly Entry[],
816
815
  ): Promise<StoredTranscriptMessage[]> {
817
816
  return (await this.storedEntries(snapshotEntries)).flatMap((stored) => {
818
817
  const { entry } = stored;
@@ -828,13 +827,14 @@ export class ConversationHistory {
828
827
 
829
828
  /** Read every Session entry with its recoverable Submission association. */
830
829
  async storedEntries(
831
- snapshotEntries: readonly Entry[] = this.store.branch(),
830
+ snapshotEntries?: readonly Entry[],
832
831
  ): Promise<StoredSessionEntry[]> {
832
+ const projected = snapshotEntries ?? (await this.nativeEntries()).map(projectSessionEntry);
833
833
  const submissionByEntry =
834
834
  this.durability.listSessionEntrySubmissionLinks();
835
- return snapshotEntries.map((entry) => ({
835
+ return projected.map((entry) => ({
836
836
  id: entry.id,
837
- submissionId: submissionByEntry.get(entry.id) ?? null,
837
+ submissionId: submissionByEntry.get(entry.id) ?? entry.submissionId ?? null,
838
838
  entry,
839
839
  createdAt: entry.timestamp,
840
840
  }));
@@ -1,6 +1,18 @@
1
1
  import type { UIMessage } from "ai";
2
2
  import type { UserMessage } from "@earendil-works/pi-ai";
3
3
 
4
+ declare module "@earendil-works/pi-agent-core" {
5
+ interface CustomAgentMessages {
6
+ springbrand: {
7
+ role: "custom";
8
+ customType: string;
9
+ content: readonly { type: "text"; text: string }[];
10
+ display?: boolean;
11
+ timestamp: number;
12
+ };
13
+ }
14
+ }
15
+
4
16
  export type PiCanonicalUserInput = UserMessage;
5
17
 
6
18
  export type UIChatTrigger = "submit-message" | "regenerate-message";