@downcity/agent 1.1.166 → 1.1.170

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.
Files changed (51) hide show
  1. package/bin/agent/remote/transports/HttpRemoteAgentTransport.d.ts.map +1 -1
  2. package/bin/agent/remote/transports/HttpRemoteAgentTransport.js +2 -0
  3. package/bin/agent/remote/transports/HttpRemoteAgentTransport.js.map +1 -1
  4. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionExecutor.d.ts +1 -0
  5. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionExecutor.d.ts.map +1 -1
  6. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionExecutor.js +2 -1
  7. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionExecutor.js.map +1 -1
  8. package/bin/executor/messages/SessionAttachmentMapper.d.ts +10 -0
  9. package/bin/executor/messages/SessionAttachmentMapper.d.ts.map +1 -1
  10. package/bin/executor/messages/SessionAttachmentMapper.js +49 -0
  11. package/bin/executor/messages/SessionAttachmentMapper.js.map +1 -1
  12. package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.d.ts.map +1 -1
  13. package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.js +2 -1
  14. package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.js.map +1 -1
  15. package/bin/executor/types/SessionMessages.d.ts +2 -0
  16. package/bin/executor/types/SessionMessages.d.ts.map +1 -1
  17. package/bin/session/browse/Browse.d.ts +8 -0
  18. package/bin/session/browse/Browse.d.ts.map +1 -1
  19. package/bin/session/browse/Browse.js +58 -6
  20. package/bin/session/browse/Browse.js.map +1 -1
  21. package/bin/session/index.d.ts +2 -2
  22. package/bin/session/index.d.ts.map +1 -1
  23. package/bin/session/index.js +2 -2
  24. package/bin/session/index.js.map +1 -1
  25. package/bin/session/services/SessionStateService.d.ts.map +1 -1
  26. package/bin/session/services/SessionStateService.js +3 -2
  27. package/bin/session/services/SessionStateService.js.map +1 -1
  28. package/bin/session/services/SessionViewService.d.ts.map +1 -1
  29. package/bin/session/services/SessionViewService.js +8 -4
  30. package/bin/session/services/SessionViewService.js.map +1 -1
  31. package/bin/session/storage/Paths.d.ts +8 -0
  32. package/bin/session/storage/Paths.d.ts.map +1 -1
  33. package/bin/session/storage/Paths.js +10 -0
  34. package/bin/session/storage/Paths.js.map +1 -1
  35. package/bin/types/agent/SessionTypes.d.ts +26 -2
  36. package/bin/types/agent/SessionTypes.d.ts.map +1 -1
  37. package/package.json +3 -3
  38. package/scripts/assistant-file-resource.test.mjs +59 -0
  39. package/scripts/session-history-archive.test.mjs +188 -0
  40. package/src/agent/remote/transports/HttpRemoteAgentTransport.ts +1 -0
  41. package/src/executor/composer/compaction/jsonl/JsonlSessionCompactionExecutor.ts +3 -2
  42. package/src/executor/messages/SessionAttachmentMapper.ts +61 -0
  43. package/src/executor/store/history/jsonl/JsonlSessionHistoryStore.ts +2 -1
  44. package/src/executor/types/SessionMessages.ts +2 -0
  45. package/src/session/browse/Browse.ts +72 -6
  46. package/src/session/index.ts +2 -0
  47. package/src/session/services/SessionStateService.ts +6 -2
  48. package/src/session/services/SessionViewService.ts +16 -3
  49. package/src/session/storage/Paths.ts +19 -0
  50. package/src/types/agent/SessionTypes.ts +26 -2
  51. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,188 @@
1
+ /**
2
+ * @file 验证 session.history 的 compact archive 分层读取语义。
3
+ *
4
+ * 关键点(中文)
5
+ * - compact summary 是模型上下文消息,但不应作为用户可见 history item 返回。
6
+ * - 调用方通过 previous_archive_id 再传入 archive_id,读取上一层被覆盖历史。
7
+ * - history 分页字段使用 snake_case,保持 SDK 对外数据命名一致。
8
+ */
9
+
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import fs from "node:fs/promises";
15
+
16
+ import { Agent } from "../bin/index.js";
17
+
18
+ function user_message(session_id, index) {
19
+ return {
20
+ id: `u:${index}`,
21
+ role: "user",
22
+ metadata: {
23
+ v: 1,
24
+ ts: index,
25
+ sessionId: session_id,
26
+ source: "ingress",
27
+ },
28
+ parts: [{ type: "text", text: `user ${index}` }],
29
+ };
30
+ }
31
+
32
+ function assistant_message(session_id, index) {
33
+ return {
34
+ id: `a:${index}`,
35
+ role: "assistant",
36
+ metadata: {
37
+ v: 1,
38
+ ts: index,
39
+ sessionId: session_id,
40
+ source: "egress",
41
+ },
42
+ parts: [{ type: "text", text: `assistant ${index}` }],
43
+ };
44
+ }
45
+
46
+ function compact_summary(session_id, archive_id, text) {
47
+ return {
48
+ id: `summary:${archive_id}`,
49
+ role: "assistant",
50
+ metadata: {
51
+ v: 1,
52
+ ts: 1000,
53
+ sessionId: session_id,
54
+ source: "compact",
55
+ kind: "summary",
56
+ archiveId: archive_id,
57
+ sourceRange: {
58
+ fromId: "start",
59
+ toId: "end",
60
+ count: 2,
61
+ },
62
+ },
63
+ parts: [{ type: "text", text }],
64
+ };
65
+ }
66
+
67
+ async function write_jsonl(file_path, messages) {
68
+ await fs.mkdir(path.dirname(file_path), { recursive: true });
69
+ await fs.writeFile(
70
+ file_path,
71
+ messages.map((message) => JSON.stringify(message)).join("\n") + "\n",
72
+ "utf8",
73
+ );
74
+ }
75
+
76
+ async function write_archive(file_path, session_id, messages) {
77
+ await fs.mkdir(path.dirname(file_path), { recursive: true });
78
+ await fs.writeFile(
79
+ file_path,
80
+ JSON.stringify(
81
+ {
82
+ v: 1,
83
+ sessionId: session_id,
84
+ archivedAt: Date.now(),
85
+ messages,
86
+ },
87
+ null,
88
+ 2,
89
+ ),
90
+ "utf8",
91
+ );
92
+ }
93
+
94
+ test("session.history hides compact summary and reads archive layers by archive_id", async () => {
95
+ const agent_path = await fs.mkdtemp(
96
+ path.join(os.tmpdir(), "downcity-agent-history-archive-"),
97
+ );
98
+ const agent = new Agent({
99
+ id: "history_archive_agent",
100
+ path: agent_path,
101
+ });
102
+ const session = await agent.session_collection().create_session({
103
+ sessionId: "history_archive_session",
104
+ });
105
+
106
+ const messages_dir = path.join(
107
+ agent_path,
108
+ ".downcity",
109
+ "agents",
110
+ encodeURIComponent("history_archive_agent"),
111
+ "sessions",
112
+ encodeURIComponent(session.id),
113
+ "messages",
114
+ );
115
+ const messages_path = path.join(messages_dir, "messages.jsonl");
116
+ const archive_dir = path.join(messages_dir, "archive");
117
+
118
+ try {
119
+ await write_jsonl(messages_path, [
120
+ compact_summary(session.id, "compact-B", "summary B"),
121
+ user_message(session.id, 31),
122
+ assistant_message(session.id, 32),
123
+ ]);
124
+ await write_archive(path.join(archive_dir, "compact-B.json"), session.id, [
125
+ compact_summary(session.id, "compact-A", "summary A"),
126
+ user_message(session.id, 16),
127
+ assistant_message(session.id, 17),
128
+ ]);
129
+ await write_archive(path.join(archive_dir, "compact-A.json"), session.id, [
130
+ user_message(session.id, 1),
131
+ assistant_message(session.id, 2),
132
+ ]);
133
+
134
+ const current_page = await session.history({
135
+ limit: 1,
136
+ });
137
+ assert.equal(current_page.total, 2);
138
+ assert.equal(current_page.has_more, true);
139
+ assert.equal(current_page.next_cursor, "1");
140
+ assert.equal(current_page.archive_id, undefined);
141
+ assert.equal(current_page.previous_archive_id, "compact-B");
142
+ assert.deepEqual(
143
+ current_page.items.map((message) => message.id),
144
+ ["u:31"],
145
+ );
146
+
147
+ const next_current_page = await session.history({
148
+ cursor: current_page.next_cursor,
149
+ });
150
+ assert.equal(next_current_page.has_more, false);
151
+ assert.deepEqual(
152
+ next_current_page.items.map((message) => message.id),
153
+ ["a:32"],
154
+ );
155
+
156
+ const previous_page = await session.history({
157
+ archive_id: current_page.previous_archive_id,
158
+ });
159
+ assert.equal(previous_page.archive_id, "compact-B");
160
+ assert.equal(previous_page.previous_archive_id, "compact-A");
161
+ assert.equal(previous_page.total, 2);
162
+ assert.deepEqual(
163
+ previous_page.items.map((message) => message.id),
164
+ ["u:16", "a:17"],
165
+ );
166
+
167
+ const oldest_page = await session.history({
168
+ archive_id: previous_page.previous_archive_id,
169
+ });
170
+ assert.equal(oldest_page.archive_id, "compact-A");
171
+ assert.equal(oldest_page.previous_archive_id, undefined);
172
+ assert.deepEqual(
173
+ oldest_page.items.map((message) => message.id),
174
+ ["u:1", "a:2"],
175
+ );
176
+
177
+ const timeline_page = await session.history({
178
+ view: "timeline",
179
+ archive_id: "compact-B",
180
+ });
181
+ assert.deepEqual(
182
+ timeline_page.items.map((event) => event.text),
183
+ ["user 16", "assistant 17"],
184
+ );
185
+ } finally {
186
+ await agent.dispose();
187
+ }
188
+ });
@@ -191,6 +191,7 @@ export class HttpRemoteAgentTransport implements RemoteAgentTransport {
191
191
  const query = new URLSearchParams();
192
192
  if (input?.limit !== undefined) query.set("limit", String(input.limit));
193
193
  if (input?.cursor) query.set("cursor", input.cursor);
194
+ if (input?.archive_id) query.set("archive_id", input.archive_id);
194
195
  if (input?.order) query.set("order", input.order);
195
196
  if (input?.view) query.set("view", input.view);
196
197
  const payload = await read_http_json<{
@@ -39,6 +39,7 @@ type SessionCompactDeps = {
39
39
  loadAll: () => Promise<SessionMessageV1[]>;
40
40
  createSummaryMessage: (params: {
41
41
  text: string;
42
+ archiveId?: string;
42
43
  sourceRange?: SessionMetadataV1["sourceRange"];
43
44
  }) => SessionMessageV1;
44
45
  getArchiveDirPath: () => string;
@@ -141,13 +142,13 @@ export async function compactSessionMessagesIfNeeded(
141
142
 
142
143
  const fromId = String(older[0]?.id || "");
143
144
  const toId = String(older[older.length - 1]?.id || "");
145
+ const archiveId = `compact-${Date.now()}-${generateId()}`;
144
146
  const summaryMsg = deps.createSummaryMessage({
145
147
  text: summary,
148
+ archiveId,
146
149
  sourceRange: fromId && toId ? { fromId, toId, count: older.length } : undefined,
147
150
  });
148
151
 
149
- const archiveId = `compact-${Date.now()}-${generateId()}`;
150
-
151
152
  // phase 2:写入(短锁,且避免覆盖新追加)
152
153
  // - 以“当前最新 session messages”为准重算 currentOlder / currentKept,避免覆盖并发新消息。
153
154
  await deps.withWriteLock(async () => {
@@ -16,6 +16,7 @@ import {
16
16
  isTextUIPart,
17
17
  type FileUIPart,
18
18
  } from "ai";
19
+ import type { SessionUserMessagePart } from "@/types/sdk/AgentSessionPrompt.js";
19
20
  import type { SessionMessageV1 } from "@/executor/types/SessionMessages.js";
20
21
  import { parseChatMessageMarkup } from "@/executor/messages/ChatMessageMarkup.js";
21
22
 
@@ -102,6 +103,66 @@ async function hydrateFileUrlPart(
102
103
  }
103
104
  }
104
105
 
106
+ async function hydrateFileUrlPartStrict(
107
+ part: FileUIPart,
108
+ project_root?: string,
109
+ ): Promise<FileUIPart> {
110
+ const url = String(part.url || "").trim();
111
+ const file_path = resolveHydratableFilePath(project_root, url);
112
+ if (!file_path) return part;
113
+
114
+ try {
115
+ const buffer = await fs.readFile(file_path);
116
+ const media_type =
117
+ String(part.mediaType || "").trim() ||
118
+ guessAttachmentMediaTypeFromPath(file_path) ||
119
+ "application/octet-stream";
120
+ return {
121
+ ...part,
122
+ mediaType: media_type,
123
+ url: buildDataUrl(media_type, buffer),
124
+ };
125
+ } catch (error) {
126
+ const message = error instanceof Error ? error.message : String(error);
127
+ throw new Error(`读取本地附件失败:${file_path}。${message}`);
128
+ }
129
+ }
130
+
131
+ /**
132
+ * 在用户 prompt 入库前,将本地图片 file part 转成 data URL。
133
+ *
134
+ * 关键点(中文)
135
+ * - 只处理调用侧直接传入的图片 file part。
136
+ * - 已经是 data URL 或远程 URL 的附件保持原样。
137
+ * - 本地文件读取失败时直接报错,避免模型请求拿到不可访问的本地路径。
138
+ */
139
+ export async function hydrateUserPromptFileParts(
140
+ parts: SessionUserMessagePart[],
141
+ project_root?: string,
142
+ ): Promise<SessionUserMessagePart[]> {
143
+ if (!Array.isArray(parts) || parts.length === 0) return [];
144
+
145
+ const out: SessionUserMessagePart[] = [];
146
+ for (const part of parts) {
147
+ if (!isFileUIPart(part as FileUIPart)) {
148
+ out.push(part);
149
+ continue;
150
+ }
151
+
152
+ const file_part = part as FileUIPart;
153
+ const media_type = String(file_part.mediaType || "").trim();
154
+ if (!media_type.startsWith("image/")) {
155
+ out.push(part);
156
+ continue;
157
+ }
158
+
159
+ const next_part = await hydrateFileUrlPartStrict(file_part, project_root);
160
+ out.push(next_part as SessionUserMessagePart);
161
+ }
162
+
163
+ return out;
164
+ }
165
+
105
166
  /**
106
167
  * 将历史中的资源 file part 临时转换为模型可消费的 data URL。
107
168
  *
@@ -445,7 +445,7 @@ export class JsonlSessionHistoryStore implements SessionHistoryStore {
445
445
  sessionId: this.sessionId,
446
446
  withWriteLock: (fn) => this.withWriteLock(fn),
447
447
  loadAll: () => this.list(),
448
- createSummaryMessage: ({ text, sourceRange }) => ({
448
+ createSummaryMessage: ({ text, archiveId, sourceRange }) => ({
449
449
  id: `a:${this.sessionId}:${generateId()}`,
450
450
  role: "assistant",
451
451
  metadata: {
@@ -454,6 +454,7 @@ export class JsonlSessionHistoryStore implements SessionHistoryStore {
454
454
  sessionId: this.sessionId,
455
455
  source: "compact",
456
456
  kind: "summary",
457
+ ...(archiveId ? { archiveId } : {}),
457
458
  ...(sourceRange ? { sourceRange } : {}),
458
459
  },
459
460
  parts: [{ type: "text", text: String(text ?? "") }],
@@ -64,6 +64,8 @@ export type SessionMetadataV1 = {
64
64
  kind?: SessionMessageKind;
65
65
  /** 当前消息来自入站、出站还是 compact。 */
66
66
  source?: SessionMessageSource;
67
+ /** compact 摘要对应的 archive 文件 ID,用于用户历史按层读取更早消息。 */
68
+ archiveId?: string;
67
69
  /** compact 摘要所覆盖的原始消息范围。 */
68
70
  sourceRange?: SessionMessageSourceRangeV1;
69
71
  /**
@@ -83,6 +83,13 @@ type SessionBrowseBaseInput = {
83
83
  executing?: boolean;
84
84
  };
85
85
 
86
+ type SessionArchiveFileV1 = {
87
+ v?: unknown;
88
+ sessionId?: unknown;
89
+ archivedAt?: unknown;
90
+ messages?: unknown;
91
+ };
92
+
86
93
  function decodeMaybe(input: string): string {
87
94
  try {
88
95
  return decodeURIComponent(input);
@@ -122,6 +129,11 @@ function encodeCursor(offset: number): string | undefined {
122
129
  return String(Math.floor(offset));
123
130
  }
124
131
 
132
+ function normalizeArchiveId(input: unknown): string | undefined {
133
+ const value = String(input || "").trim();
134
+ return value || undefined;
135
+ }
136
+
125
137
  function stringifyForDisplay(input: unknown, maxChars = 2400): string {
126
138
  if (input === undefined) return "";
127
139
  if (input === null) return "null";
@@ -359,6 +371,53 @@ export async function loadSessionMessagesFromPath(
359
371
  return messages;
360
372
  }
361
373
 
374
+ /**
375
+ * 读取 compact archive 文件中的消息。
376
+ *
377
+ * 关键点(中文)
378
+ * - archive 是 compact 时被覆盖的“上一层历史”,不是当前运行态事实源。
379
+ * - 这里不读取 inflight,因为 archive 层永远是已完成的历史快照。
380
+ */
381
+ export async function loadSessionArchiveMessagesFromPath(
382
+ filePath: string,
383
+ ): Promise<SessionMessageV1[]> {
384
+ try {
385
+ const raw = (await fs.readJson(filePath)) as SessionArchiveFileV1 | null;
386
+ const items = Array.isArray(raw?.messages) ? raw.messages : [];
387
+ return items.filter((item): item is SessionMessageV1 => {
388
+ if (!item || typeof item !== "object") return false;
389
+ const candidate = item as Partial<SessionMessageV1>;
390
+ return (
391
+ (candidate.role === "user" || candidate.role === "assistant") &&
392
+ Array.isArray(candidate.parts)
393
+ );
394
+ });
395
+ } catch {
396
+ return [];
397
+ }
398
+ }
399
+
400
+ function isCompactSummaryMessage(message: SessionMessageV1): boolean {
401
+ const metadata = (message.metadata || null) as SessionMetadataV1 | null;
402
+ return metadata?.source === "compact" || metadata?.kind === "summary";
403
+ }
404
+
405
+ function resolvePreviousArchiveId(messages: SessionMessageV1[]): string | undefined {
406
+ for (const message of messages) {
407
+ if (!isCompactSummaryMessage(message)) continue;
408
+ const metadata = (message.metadata || null) as SessionMetadataV1 | null;
409
+ const archiveId = normalizeArchiveId(metadata?.archiveId);
410
+ if (archiveId) return archiveId;
411
+ }
412
+ return undefined;
413
+ }
414
+
415
+ function filterUserVisibleHistoryMessages(
416
+ messages: SessionMessageV1[],
417
+ ): SessionMessageV1[] {
418
+ return messages.filter((message) => !isCompactSummaryMessage(message));
419
+ }
420
+
362
421
  /**
363
422
  * 基于 metadata + messages 构建 SDK session 详情。
364
423
  */
@@ -409,9 +468,12 @@ export function buildSessionHistoryPage(params: {
409
468
  const order = params.input?.order || "asc";
410
469
  const limit = normalizeLimit(params.input?.limit, 50, 500);
411
470
  const cursor = normalizeCursor(params.input?.cursor);
471
+ const archive_id = normalizeArchiveId(params.input?.archive_id);
472
+ const previous_archive_id = resolvePreviousArchiveId(params.messages);
473
+ const visibleMessages = filterUserVisibleHistoryMessages(params.messages);
412
474
 
413
475
  if (view === "timeline") {
414
- const allEvents = params.messages.flatMap((message) => toSessionTimelineEvents(message));
476
+ const allEvents = visibleMessages.flatMap((message) => toSessionTimelineEvents(message));
415
477
  const orderedEvents = order === "desc" ? [...allEvents].reverse() : allEvents;
416
478
  const pageItems = orderedEvents.slice(cursor, cursor + limit);
417
479
  const nextOffset = cursor + pageItems.length;
@@ -421,14 +483,16 @@ export function buildSessionHistoryPage(params: {
421
483
  items: pageItems,
422
484
  total: orderedEvents.length,
423
485
  ...(nextOffset < orderedEvents.length
424
- ? { nextCursor: encodeCursor(nextOffset) }
486
+ ? { next_cursor: encodeCursor(nextOffset) }
425
487
  : {}),
426
- hasMore: nextOffset < orderedEvents.length,
488
+ has_more: nextOffset < orderedEvents.length,
489
+ ...(archive_id ? { archive_id } : {}),
490
+ ...(previous_archive_id ? { previous_archive_id } : {}),
427
491
  };
428
492
  }
429
493
 
430
494
  const orderedMessages =
431
- order === "desc" ? [...params.messages].reverse() : [...params.messages];
495
+ order === "desc" ? [...visibleMessages].reverse() : [...visibleMessages];
432
496
  const pageItems = orderedMessages.slice(cursor, cursor + limit);
433
497
  const nextOffset = cursor + pageItems.length;
434
498
  return {
@@ -437,9 +501,11 @@ export function buildSessionHistoryPage(params: {
437
501
  items: pageItems,
438
502
  total: orderedMessages.length,
439
503
  ...(nextOffset < orderedMessages.length
440
- ? { nextCursor: encodeCursor(nextOffset) }
504
+ ? { next_cursor: encodeCursor(nextOffset) }
441
505
  : {}),
442
- hasMore: nextOffset < orderedMessages.length,
506
+ has_more: nextOffset < orderedMessages.length,
507
+ ...(archive_id ? { archive_id } : {}),
508
+ ...(previous_archive_id ? { previous_archive_id } : {}),
443
509
  };
444
510
  }
445
511
 
@@ -16,6 +16,7 @@ export {
16
16
  getSdkAgentArchivedSessionMetaPath,
17
17
  getSdkAgentArchivedSessionsDirPath,
18
18
  getSdkAgentSessionArchiveDirPath,
19
+ getSdkAgentSessionArchiveFilePath,
19
20
  getSdkAgentSessionDirPath,
20
21
  getSdkAgentSessionInflightPath,
21
22
  getSdkAgentSessionMessagesDirPath,
@@ -49,6 +50,7 @@ export {
49
50
  buildSessionInfo,
50
51
  listArchivedAgentSessionSummaryPage,
51
52
  listAgentSessionSummaryPage,
53
+ loadSessionArchiveMessagesFromPath,
52
54
  loadSessionMessagesFromPath,
53
55
  resolveSessionMessagePreview,
54
56
  toSessionTimelineEvents,
@@ -20,6 +20,7 @@ import {
20
20
  } from "@/session/index.js";
21
21
  import { ensureSessionTitle } from "@/session/SessionTitle.js";
22
22
  import { persistSdkAssistantResult } from "@/session/storage/Persistence.js";
23
+ import { hydrateUserPromptFileParts } from "@executor/messages/SessionAttachmentMapper.js";
23
24
  import type { Executor } from "@executor/Executor.js";
24
25
  import type { JsonlSessionHistoryStore } from "@/executor/store/history/jsonl/JsonlSessionHistoryStore.js";
25
26
  import type {
@@ -324,8 +325,11 @@ export class SessionStateService {
324
325
  return message;
325
326
  }
326
327
 
327
- // query 是 parts 数组,直接用其构造 user 消息
328
- const parts = Array.isArray(query) ? query : [];
328
+ // query 是 parts 数组,先把本地图片附件转换为模型可消费的 data URL。
329
+ const parts = await hydrateUserPromptFileParts(
330
+ Array.isArray(query) ? query : [],
331
+ this.project_root,
332
+ );
329
333
  const message: SessionUserMessageV1 = {
330
334
  id: `u:${this.session_id}:${generateId()}`,
331
335
  role: "user",
@@ -11,7 +11,9 @@ import { nanoid } from "nanoid";
11
11
  import {
12
12
  buildSessionHistoryPage,
13
13
  buildSessionInfo,
14
+ getSdkAgentSessionArchiveFilePath,
14
15
  ensureSessionTitle,
16
+ loadSessionArchiveMessagesFromPath,
15
17
  readSessionMetadata,
16
18
  } from "@/session/index.js";
17
19
  import { buildSessionSystemBlocks } from "@/session/SessionSystemBuilder.js";
@@ -179,7 +181,8 @@ export class SessionViewService<TSession extends Pick<AgentSession, "set">> {
179
181
  async history(
180
182
  input?: AgentSessionHistoryInput,
181
183
  ): Promise<AgentSessionHistoryPage> {
182
- const [metadata, messages] = await Promise.all([
184
+ const archive_id = String(input?.archive_id || "").trim();
185
+ const [metadata, current_messages] = await Promise.all([
183
186
  readSessionMetadata({
184
187
  projectRoot: this.project_root,
185
188
  agentId: this.agent_id,
@@ -187,13 +190,23 @@ export class SessionViewService<TSession extends Pick<AgentSession, "set">> {
187
190
  }),
188
191
  this.history_store.list(),
189
192
  ]);
193
+ const page_messages = archive_id
194
+ ? await loadSessionArchiveMessagesFromPath(
195
+ getSdkAgentSessionArchiveFilePath(
196
+ this.project_root,
197
+ this.agent_id,
198
+ this.session_id,
199
+ archive_id,
200
+ ),
201
+ )
202
+ : current_messages;
190
203
  const session = await this.build_info({
191
204
  metadata,
192
- messages,
205
+ messages: current_messages,
193
206
  });
194
207
  return buildSessionHistoryPage({
195
208
  session,
196
- messages,
209
+ messages: page_messages,
197
210
  input,
198
211
  });
199
212
  }
@@ -185,6 +185,25 @@ export function getSdkAgentSessionArchiveDirPath(
185
185
  );
186
186
  }
187
187
 
188
+ /**
189
+ * 单个 session 的 compact archive JSON 文件路径。
190
+ *
191
+ * 关键点(中文)
192
+ * - `archiveId` 来自 compact summary metadata,不由调用方拼接扩展名。
193
+ * - 这里统一 encode,避免 archiveId 中的特殊字符影响文件路径。
194
+ */
195
+ export function getSdkAgentSessionArchiveFilePath(
196
+ projectRoot: string,
197
+ agentId: string,
198
+ sessionId: string,
199
+ archiveId: string,
200
+ ): string {
201
+ return path.join(
202
+ getSdkAgentSessionArchiveDirPath(projectRoot, agentId, sessionId),
203
+ `${encodeURIComponent(String(archiveId || "").trim())}.json`,
204
+ );
205
+ }
206
+
188
207
  /**
189
208
  * 单个 session 的 inflight assistant 路径。
190
209
  *
@@ -272,6 +272,14 @@ export interface AgentSessionHistoryInput {
272
272
  limit?: number;
273
273
  /** 分页游标。 */
274
274
  cursor?: string;
275
+ /**
276
+ * 要读取的 compact archive 层 ID。
277
+ *
278
+ * 说明(中文)
279
+ * - 省略时读取当前 `messages.jsonl`。
280
+ * - 传入时读取 `messages/archive/<archive_id>.json` 中保存的上一层历史。
281
+ */
282
+ archive_id?: string;
275
283
  /**
276
284
  * 返回顺序。
277
285
  *
@@ -315,9 +323,25 @@ export interface AgentSessionHistoryPage {
315
323
  */
316
324
  total: number;
317
325
  /** 下一页游标。 */
318
- nextCursor?: string;
326
+ next_cursor?: string;
319
327
  /** 是否仍有更多数据。 */
320
- hasMore: boolean;
328
+ has_more: boolean;
329
+ /**
330
+ * 当前读取的 compact archive 层 ID。
331
+ *
332
+ * 说明(中文)
333
+ * - 省略表示当前页来自主历史 `messages.jsonl`。
334
+ * - 有值表示当前页来自指定 compact archive。
335
+ */
336
+ archive_id?: string;
337
+ /**
338
+ * 当前层更早一次 compact 对应的 archive ID。
339
+ *
340
+ * 关键点(中文)
341
+ * - compact summary 不会出现在 `items` 中。
342
+ * - 若当前层包含 compact summary,则这里暴露其 archive ID,供调用方继续 `session.history({ archive_id })`。
343
+ */
344
+ previous_archive_id?: string;
321
345
  }
322
346
 
323
347
  /**