@hienlh/ppm 0.9.2 → 0.9.3

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 (39) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/dist/web/assets/{browser-tab-CjUzlPYv.js → browser-tab-LFNnCzgB.js} +1 -1
  3. package/dist/web/assets/chat-tab-rYBo5Mff.js +8 -0
  4. package/dist/web/assets/{code-editor-aQQZUc2m.js → code-editor-BdM11-0K.js} +1 -1
  5. package/dist/web/assets/{database-viewer-ChyP1N3c.js → database-viewer-CINo6teP.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-ktwO5JbX.js → diff-viewer-_MPL-DRu.js} +1 -1
  7. package/dist/web/assets/{extension-webview-Bx1TlP6q.js → extension-webview-BU1T2a8n.js} +1 -1
  8. package/dist/web/assets/{git-graph-BIrGMX6e.js → git-graph-Dde-j8cK.js} +1 -1
  9. package/dist/web/assets/index-BkidPsSC.css +2 -0
  10. package/dist/web/assets/{index-C6KLr58u.js → index-CyXEMb4g.js} +4 -4
  11. package/dist/web/assets/keybindings-store-6_p_JT0B.js +1 -0
  12. package/dist/web/assets/{markdown-renderer-A7J2gdKT.js → markdown-renderer-Djgmbi23.js} +1 -1
  13. package/dist/web/assets/{postgres-viewer-C9-Acry_.js → postgres-viewer-FCpA6nh4.js} +1 -1
  14. package/dist/web/assets/{settings-tab-C17exmRv.js → settings-tab-Y37tD1kM.js} +1 -1
  15. package/dist/web/assets/{sqlite-viewer-Dr5oWCWA.js → sqlite-viewer-Cjl4uXyo.js} +1 -1
  16. package/dist/web/assets/{terminal-tab-CpyKvyfC.js → terminal-tab-CnYqGghP.js} +1 -1
  17. package/dist/web/assets/{use-monaco-theme-BjPAik5w.js → use-monaco-theme-DGjkK3eO.js} +1 -1
  18. package/dist/web/index.html +2 -2
  19. package/dist/web/monacoeditorwork/css.worker.bundle.js +122 -122
  20. package/dist/web/monacoeditorwork/editor.worker.bundle.js +78 -78
  21. package/dist/web/monacoeditorwork/html.worker.bundle.js +110 -110
  22. package/dist/web/monacoeditorwork/json.worker.bundle.js +108 -108
  23. package/dist/web/monacoeditorwork/ts.worker.bundle.js +81 -81
  24. package/dist/web/sw.js +1 -1
  25. package/docs/project-roadmap.md +3 -3
  26. package/docs/streaming-input-guide.md +267 -0
  27. package/package.json +1 -1
  28. package/snapshot-state.md +1526 -0
  29. package/src/providers/claude-agent-sdk.ts +16 -11
  30. package/src/web/components/chat/chat-tab.tsx +1 -0
  31. package/src/web/components/chat/message-list.tsx +8 -4
  32. package/src/web/components/layout/mobile-nav.tsx +44 -40
  33. package/test-session-ops.mjs +444 -0
  34. package/test-tokens.mjs +212 -0
  35. package/.claude.bak/agent-memory/tester/MEMORY.md +0 -3
  36. package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +0 -32
  37. package/dist/web/assets/chat-tab-moB4W7-w.js +0 -8
  38. package/dist/web/assets/index-DpBKDbIW.css +0 -2
  39. package/dist/web/assets/keybindings-store-D3Y5c5uS.js +0 -1
@@ -902,12 +902,12 @@ export class ClaudeAgentSdkProvider implements AIProvider {
902
902
  console.log(`[sdk] session=${sessionId} OAuth token refreshed for ${account.id} (${label}) — retrying`);
903
903
  yield { type: "account_retry" as const, reason: "Token refreshed", accountId: refreshedAccount.id, accountLabel: label };
904
904
  const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
905
- // Close failed query and old channel, create new channel + query with refreshed token
905
+ // Close failed query and old channel, create new channel + query with refreshed token.
906
+ // Resume existing SDK session so conversation context is preserved.
906
907
  streamCtrl.done();
907
908
  q.close();
908
909
  const { generator: authRetryGen, controller: authRetryCtrl } = createMessageChannel();
909
- authRetryCtrl.push(firstMsg);
910
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: undefined, env: retryEnv };
910
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: sdkId, env: retryEnv };
911
911
  const rq = query({
912
912
  prompt: authRetryGen,
913
913
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
@@ -953,13 +953,13 @@ export class ClaudeAgentSdkProvider implements AIProvider {
953
953
  }
954
954
  yield { type: "error", message: `Rate limited. Auto-retrying in ${backoff / 1000}s... (${rateLimitRetryCount}/${MAX_RATE_LIMIT_RETRIES})` };
955
955
  await new Promise((r) => setTimeout(r, backoff));
956
- // Close failed query and recreate with (potentially new) account env
956
+ // Close failed query and recreate with (potentially new) account env.
957
+ // Resume existing SDK session so conversation context is preserved.
957
958
  streamCtrl.done();
958
959
  q.close();
959
960
  const rlRetryEnv = this.buildQueryEnv(meta.projectPath, account);
960
961
  const { generator: rlRetryGen, controller: rlRetryCtrl } = createMessageChannel();
961
- rlRetryCtrl.push(firstMsg);
962
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: undefined, env: rlRetryEnv };
962
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: sdkId, env: rlRetryEnv };
963
963
  const rq = query({
964
964
  prompt: rlRetryGen,
965
965
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
@@ -1050,12 +1050,12 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1050
1050
  }
1051
1051
  yield { type: "error", message: `Rate limited. Auto-retrying in ${backoff / 1000}s... (${rateLimitRetryCount}/${MAX_RATE_LIMIT_RETRIES})` };
1052
1052
  await new Promise((r) => setTimeout(r, backoff));
1053
+ // Resume existing SDK session so conversation context is preserved.
1053
1054
  streamCtrl.done();
1054
1055
  q.close();
1055
1056
  const rlRetryEnv = this.buildQueryEnv(meta.projectPath, account);
1056
1057
  const { generator: rlRetryGen, controller: rlRetryCtrl } = createMessageChannel();
1057
- rlRetryCtrl.push(firstMsg);
1058
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: undefined, env: rlRetryEnv };
1058
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: sdkId, env: rlRetryEnv };
1059
1059
  const rq = query({
1060
1060
  prompt: rlRetryGen,
1061
1061
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
@@ -1068,7 +1068,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1068
1068
  yield { type: "error", message: `Rate limited. Retried ${MAX_RATE_LIMIT_RETRIES} times without success.` };
1069
1069
  continue;
1070
1070
  } else if (errCode === 401) {
1071
- // Refresh token and retry with fresh session (same logic as assistant-level auth retry)
1071
+ // Refresh token and retry resume existing SDK session to preserve context
1072
1072
  if (!authRetried) {
1073
1073
  authRetried = true;
1074
1074
  try {
@@ -1078,12 +1078,17 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1078
1078
  const label = refreshedAccount.label ?? refreshedAccount.email ?? "Unknown";
1079
1079
  console.log(`[sdk] 401 in result on account ${account.id} (${label}) — token refreshed, retrying`);
1080
1080
  yield { type: "account_retry" as const, reason: "Token refreshed", accountId: refreshedAccount.id, accountLabel: label };
1081
+ // Resume existing SDK session so conversation context is preserved.
1082
+ streamCtrl.done();
1083
+ q.close();
1081
1084
  const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
1082
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: undefined, env: retryEnv };
1085
+ const { generator: authRetryGen2, controller: authRetryCtrl2 } = createMessageChannel();
1086
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: sdkId, env: retryEnv };
1083
1087
  const rq = query({
1084
- prompt: message,
1088
+ prompt: authRetryGen2,
1085
1089
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
1086
1090
  });
1091
+ this.streamingSessions.set(sessionId, { meta, query: rq, controller: authRetryCtrl2 });
1087
1092
  this.activeQueries.set(sessionId, rq);
1088
1093
  eventSource = rq;
1089
1094
  continue retryLoop;
@@ -341,6 +341,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
341
341
  connectingElapsed={connectingElapsed}
342
342
  projectName={projectName}
343
343
  onFork={!isStreaming ? handleFork : undefined}
344
+ onSelectSession={handleSelectSession}
344
345
  />
345
346
 
346
347
  {/* Bottom toolbar */}
@@ -26,6 +26,7 @@ import {
26
26
  XCircle,
27
27
  ExternalLink,
28
28
  } from "lucide-react";
29
+ import { ChatWelcome } from "./chat-welcome";
29
30
  import { QuestionCard } from "./question-card";
30
31
  import type { Question } from "./question-card";
31
32
  import { useTabStore } from "@/stores/tab-store";
@@ -44,6 +45,8 @@ interface MessageListProps {
44
45
  projectName?: string;
45
46
  /** Called when user clicks Fork/Rewind — opens new forked chat tab */
46
47
  onFork?: (userMessage: string, messageId?: string) => void;
48
+ /** Called when user selects a recent session from the welcome screen */
49
+ onSelectSession?: (session: import("../../../types/chat").SessionInfo) => void;
47
50
  }
48
51
 
49
52
  export function MessageList({
@@ -53,6 +56,7 @@ export function MessageList({
53
56
  onApprovalResponse,
54
57
  isStreaming,
55
58
  phase,
59
+ onSelectSession,
56
60
  connectingElapsed,
57
61
  projectName,
58
62
  onFork,
@@ -70,10 +74,10 @@ export function MessageList({
70
74
 
71
75
  if (messages.length === 0 && !isStreaming) {
72
76
  return (
73
- <div className="flex flex-col items-center justify-center h-full gap-3 text-text-secondary">
74
- <Bot className="size-10 text-text-subtle" />
75
- <p className="text-sm">Send a message to start the conversation</p>
76
- </div>
77
+ <ChatWelcome
78
+ projectName={projectName || ""}
79
+ onSelectSession={onSelectSession || (() => {})}
80
+ />
77
81
  );
78
82
  }
79
83
 
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
2
2
  import {
3
3
  Terminal, MessageSquare, GitBranch, Database,
4
4
  FileDiff, FileCode, Settings, Menu, X, ArrowLeft, ArrowRight, SplitSquareVertical, MoveVertical, Layers, Plus,
5
- ChevronLeft, ChevronRight, Globe, Puzzle,
5
+ ChevronRight, Globe, Puzzle,
6
6
  } from "lucide-react";
7
7
  import { usePanelStore } from "@/stores/panel-store";
8
8
  import { useProjectStore, resolveOrder } from "@/stores/project-store";
@@ -42,7 +42,7 @@ export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
42
42
  const mobileScrollRef = useRef<HTMLDivElement>(null);
43
43
  const prevTabCount = useRef(tabs.length);
44
44
  const notifications = useNotificationStore((s) => s.notifications);
45
- const { canScrollLeft, canScrollRight, scrollLeft: doScrollLeft, scrollRight: doScrollRight } =
45
+ const { canScrollLeft, canScrollRight, scrollRight: doScrollRight } =
46
46
  useTabOverflow(mobileScrollRef);
47
47
  const hiddenUnread = getHiddenUnreadDirection(mobileScrollRef.current, tabRefs.current as Map<string, HTMLElement>, tabs, notifications);
48
48
 
@@ -114,20 +114,50 @@ export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
114
114
  return (
115
115
  <nav className="fixed bottom-0 left-0 right-0 md:hidden bg-background border-t border-border z-40 select-none">
116
116
  <div className="flex items-center h-12">
117
- <button onClick={onMenuPress} className="flex items-center justify-center size-12 shrink-0 text-text-secondary border-r border-border">
118
- <Menu className="size-5" />
119
- </button>
117
+ {/* Fixed section: Menu + Project + Add — curved right edge */}
118
+ <div className={cn(
119
+ "flex items-center shrink-0 bg-background relative z-10 rounded-r-2xl border-r border-border transition-shadow duration-200",
120
+ canScrollLeft && "shadow-[6px_0_12px_-4px_rgba(0,0,0,0.12)]",
121
+ )}>
122
+ <button onClick={onMenuPress} className="flex items-center justify-center size-12 shrink-0 text-text-secondary">
123
+ <Menu className="size-5" />
124
+ </button>
120
125
 
126
+ <div className="w-px self-stretch bg-border shrink-0" />
127
+
128
+ <button
129
+ onClick={onProjectsPress}
130
+ className="flex items-center justify-center size-12 shrink-0 text-text-secondary"
131
+ title="Switch project"
132
+ >
133
+ {activeInitials ? (
134
+ <div
135
+ className="size-7 rounded-full flex items-center justify-center text-[10px] font-bold text-white"
136
+ style={{ background: activeColor }}
137
+ >
138
+ {activeInitials}
139
+ </div>
140
+ ) : (
141
+ <Layers className="size-5" />
142
+ )}
143
+ </button>
144
+
145
+ <div className="w-px self-stretch bg-border shrink-0" />
146
+
147
+ <button
148
+ onClick={() => openCommandPalette()}
149
+ className={cn(
150
+ "flex items-center justify-center shrink-0 text-text-secondary gap-1.5 h-12",
151
+ tabs.length === 0 ? "px-4" : "w-12",
152
+ )}
153
+ >
154
+ <Plus className="size-4" />
155
+ {tabs.length === 0 && <span className="text-xs">New Tab</span>}
156
+ </button>
157
+ </div>
158
+
159
+ {/* Tab list — scrollable */}
121
160
  <div className="flex-1 min-w-0 relative flex items-center h-12">
122
- {/* Left scroll arrow */}
123
- {canScrollLeft && (
124
- <button onClick={doScrollLeft} className="absolute left-0 z-10 flex items-center justify-center size-8 bg-gradient-to-r from-background via-background to-transparent">
125
- <span className="relative">
126
- <ChevronLeft className="size-3.5 text-text-secondary" />
127
- {hiddenUnread.left && <span className={cn("absolute -top-1 -right-0.5 size-1.5 rounded-full", notificationColor(hiddenUnread.left))} />}
128
- </span>
129
- </button>
130
- )}
131
161
  <div ref={mobileScrollRef} className="flex-1 min-w-0 flex items-center h-12 overflow-x-auto scrollbar-none">
132
162
  {tabs.map((tab) => {
133
163
  const Icon = TAB_ICONS[tab.type];
@@ -179,32 +209,6 @@ export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
179
209
  </button>
180
210
  )}
181
211
  </div>
182
-
183
- {/* Add tab — opens command palette */}
184
- <button
185
- onClick={() => openCommandPalette()}
186
- className="flex items-center justify-center size-12 shrink-0 border-t-2 border-transparent text-text-secondary"
187
- >
188
- <Plus className="size-4" />
189
- </button>
190
-
191
- {/* Projects button (rightmost) */}
192
- <button
193
- onClick={onProjectsPress}
194
- className="flex items-center justify-center size-12 shrink-0 text-text-secondary border-l border-border"
195
- title="Switch project"
196
- >
197
- {activeInitials ? (
198
- <div
199
- className="size-7 rounded-full flex items-center justify-center text-[10px] font-bold text-white"
200
- style={{ background: activeColor }}
201
- >
202
- {activeInitials}
203
- </div>
204
- ) : (
205
- <Layers className="size-5" />
206
- )}
207
- </button>
208
212
  </div>
209
213
 
210
214
  {/* New tab action sheet */}
@@ -0,0 +1,444 @@
1
+ /**
2
+ * Test script: Advanced Session Operations
3
+ * Tests: listSessions, getSessionMessages, forkSession (mid-message), deleteSession, resumeSessionAt
4
+ *
5
+ * Usage: bun test-session-ops.mjs [test-name]
6
+ * Tests: list | messages | fork-mid | delete | resume-at | compact-summary | all
7
+ */
8
+ import {
9
+ query,
10
+ listSessions,
11
+ getSessionMessages,
12
+ getSessionInfo,
13
+ forkSession,
14
+ renameSession,
15
+ tagSession,
16
+ } from "@anthropic-ai/claude-agent-sdk";
17
+ import { existsSync, unlinkSync, readdirSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { resolve } from "node:path";
20
+
21
+ // Remove CLAUDECODE to avoid nested session error
22
+ delete process.env.CLAUDECODE;
23
+
24
+ const PROJECT_DIR = "/Users/hienlh/Projects/ppm";
25
+ const CLAUDE_PROJECTS_DIR = resolve(homedir(), ".claude/projects");
26
+ const testName = process.argv[2] || "all";
27
+
28
+ // Helpers
29
+ function log(label, data) {
30
+ console.log(`\n${"=".repeat(60)}`);
31
+ console.log(`[${label}]`);
32
+ console.log("=".repeat(60));
33
+ if (data !== undefined) console.log(typeof data === "string" ? data : JSON.stringify(data, null, 2));
34
+ }
35
+
36
+ function logOk(msg) { console.log(` ✅ ${msg}`); }
37
+ function logFail(msg) { console.log(` ❌ ${msg}`); }
38
+ function logInfo(msg) { console.log(` ℹ️ ${msg}`); }
39
+
40
+ // ─── Test 1: List Sessions ──────────────────────────────────────────────────
41
+ async function testListSessions() {
42
+ log("TEST: listSessions");
43
+
44
+ // List all sessions for PPM project
45
+ const sessions = await listSessions({ dir: PROJECT_DIR, limit: 10 });
46
+ logInfo(`Found ${sessions.length} sessions for PPM project`);
47
+
48
+ for (const s of sessions.slice(0, 5)) {
49
+ console.log(` - ${s.sessionId.slice(0, 8)}... | "${s.summary?.slice(0, 50)}" | modified=${new Date(s.lastModified).toISOString().slice(0, 16)} | tag=${s.tag ?? "none"}`);
50
+ }
51
+
52
+ if (sessions.length > 0) logOk("listSessions works");
53
+ else logFail("No sessions found");
54
+
55
+ return sessions;
56
+ }
57
+
58
+ // ─── Test 2: Get Session Messages ───────────────────────────────────────────
59
+ async function testGetMessages(sessionId) {
60
+ log("TEST: getSessionMessages");
61
+
62
+ if (!sessionId) {
63
+ const sessions = await listSessions({ dir: PROJECT_DIR, limit: 5 });
64
+ sessionId = sessions[0]?.sessionId;
65
+ if (!sessionId) { logFail("No session to test"); return; }
66
+ }
67
+
68
+ logInfo(`Reading messages from session ${sessionId.slice(0, 8)}...`);
69
+
70
+ // Test with no limit
71
+ const allMsgs = await getSessionMessages(sessionId, { dir: PROJECT_DIR });
72
+ logInfo(`Total messages: ${allMsgs.length}`);
73
+
74
+ // Show first few messages with UUIDs (needed for fork-mid test)
75
+ for (const [i, msg] of allMsgs.slice(0, 6).entries()) {
76
+ const preview = typeof msg.message === "object"
77
+ ? JSON.stringify(msg.message).slice(0, 80)
78
+ : String(msg.message).slice(0, 80);
79
+ console.log(` [${i}] type=${msg.type} uuid=${msg.uuid.slice(0, 8)}... | ${preview}`);
80
+ }
81
+
82
+ // Test with limit/offset
83
+ const page1 = await getSessionMessages(sessionId, { dir: PROJECT_DIR, limit: 2 });
84
+ const page2 = await getSessionMessages(sessionId, { dir: PROJECT_DIR, limit: 2, offset: 2 });
85
+ logInfo(`Pagination: page1=${page1.length} msgs, page2=${page2.length} msgs`);
86
+
87
+ if (allMsgs.length > 0) logOk(`getSessionMessages works (${allMsgs.length} messages)`);
88
+ else logFail("No messages found");
89
+
90
+ return allMsgs;
91
+ }
92
+
93
+ // ─── Test 3: Fork at Mid-Message ────────────────────────────────────────────
94
+ async function testForkMid() {
95
+ log("TEST: forkSession with upToMessageId");
96
+
97
+ // Find a session with multiple messages
98
+ const sessions = await listSessions({ dir: PROJECT_DIR, limit: 20 });
99
+ let sourceSession = null;
100
+ let sourceMessages = [];
101
+
102
+ for (const s of sessions) {
103
+ const msgs = await getSessionMessages(s.sessionId, { dir: PROJECT_DIR });
104
+ if (msgs.length >= 4) {
105
+ sourceSession = s;
106
+ sourceMessages = msgs;
107
+ break;
108
+ }
109
+ }
110
+
111
+ if (!sourceSession) {
112
+ logFail("No session with 4+ messages found");
113
+ return;
114
+ }
115
+
116
+ logInfo(`Source: ${sourceSession.sessionId.slice(0, 8)}... (${sourceMessages.length} messages)`);
117
+
118
+ // Fork at the 2nd message (mid-conversation)
119
+ const forkAtMsg = sourceMessages[1]; // 2nd message
120
+ logInfo(`Forking at message[1]: uuid=${forkAtMsg.uuid.slice(0, 8)}... type=${forkAtMsg.type}`);
121
+
122
+ try {
123
+ const result = await forkSession(sourceSession.sessionId, {
124
+ dir: PROJECT_DIR,
125
+ upToMessageId: forkAtMsg.uuid,
126
+ title: `[TEST] Fork at msg[1] - ${new Date().toISOString().slice(11, 19)}`,
127
+ });
128
+
129
+ logOk(`Fork created! New sessionId: ${result.sessionId}`);
130
+
131
+ // Verify forked session has fewer messages
132
+ const forkedMsgs = await getSessionMessages(result.sessionId, { dir: PROJECT_DIR });
133
+ logInfo(`Forked session has ${forkedMsgs.length} messages (source had ${sourceMessages.length})`);
134
+
135
+ if (forkedMsgs.length <= sourceMessages.length) {
136
+ logOk(`Mid-fork works: ${forkedMsgs.length} ≤ ${sourceMessages.length} messages`);
137
+ } else {
138
+ logFail(`Fork has more messages than source?!`);
139
+ }
140
+
141
+ // Get info about forked session
142
+ const info = await getSessionInfo(result.sessionId, { dir: PROJECT_DIR });
143
+ logInfo(`Fork info: title="${info?.summary}" created=${info?.createdAt ? new Date(info.createdAt).toISOString().slice(0, 16) : "?"}`);
144
+
145
+ return result.sessionId;
146
+ } catch (e) {
147
+ logFail(`forkSession failed: ${e.message}`);
148
+ console.error(e);
149
+ }
150
+ }
151
+
152
+ // ─── Test 4: Delete Session (JSONL) ─────────────────────────────────────────
153
+ async function testDelete(sessionIdToDelete) {
154
+ log("TEST: deleteSession (JSONL unlink)");
155
+
156
+ // If no specific session, create a disposable one first via forkSession
157
+ if (!sessionIdToDelete) {
158
+ logInfo("Creating disposable session via fork for delete test...");
159
+ const sessions = await listSessions({ dir: PROJECT_DIR, limit: 5 });
160
+ if (sessions.length === 0) { logFail("No sessions to fork from"); return; }
161
+
162
+ const result = await forkSession(sessions[0].sessionId, {
163
+ dir: PROJECT_DIR,
164
+ title: "[TEST] Disposable for delete test",
165
+ });
166
+ sessionIdToDelete = result.sessionId;
167
+ logInfo(`Created disposable: ${sessionIdToDelete}`);
168
+ }
169
+
170
+ // Find the JSONL file on disk
171
+ const projectDirs = readdirSync(CLAUDE_PROJECTS_DIR);
172
+ let jsonlPath = null;
173
+
174
+ for (const dir of projectDirs) {
175
+ const candidate = resolve(CLAUDE_PROJECTS_DIR, dir, `${sessionIdToDelete}.jsonl`);
176
+ if (existsSync(candidate)) {
177
+ jsonlPath = candidate;
178
+ break;
179
+ }
180
+ }
181
+
182
+ if (!jsonlPath) {
183
+ logFail(`JSONL file not found for session ${sessionIdToDelete}`);
184
+ return;
185
+ }
186
+
187
+ logInfo(`Found JSONL: ${jsonlPath}`);
188
+
189
+ // Delete the file
190
+ unlinkSync(jsonlPath);
191
+ logInfo("JSONL deleted");
192
+
193
+ // Verify it's gone from listSessions
194
+ const afterSessions = await listSessions({ dir: PROJECT_DIR, limit: 100 });
195
+ const stillExists = afterSessions.some(s => s.sessionId === sessionIdToDelete);
196
+
197
+ if (!stillExists) {
198
+ logOk("Session deleted successfully — no longer in listSessions");
199
+ } else {
200
+ logFail("Session still appears in listSessions after JSONL deletion!");
201
+ }
202
+ }
203
+
204
+ // ─── Test 5: resumeSessionAt ────────────────────────────────────────────────
205
+ async function testResumeAt() {
206
+ log("TEST: resumeSessionAt (resume from specific message)");
207
+
208
+ // Find a session with enough messages
209
+ const sessions = await listSessions({ dir: PROJECT_DIR, limit: 20 });
210
+ let sourceSession = null;
211
+ let sourceMessages = [];
212
+
213
+ for (const s of sessions) {
214
+ const msgs = await getSessionMessages(s.sessionId, { dir: PROJECT_DIR });
215
+ if (msgs.length >= 4) {
216
+ sourceSession = s;
217
+ sourceMessages = msgs;
218
+ break;
219
+ }
220
+ }
221
+
222
+ if (!sourceSession) {
223
+ logFail("No session with 4+ messages for resumeAt test");
224
+ return;
225
+ }
226
+
227
+ // Pick middle message to resume from
228
+ const midIdx = Math.floor(sourceMessages.length / 2);
229
+ const resumeAtMsg = sourceMessages[midIdx];
230
+ logInfo(`Source: ${sourceSession.sessionId.slice(0, 8)}... (${sourceMessages.length} msgs)`);
231
+ logInfo(`Resuming at message[${midIdx}]: uuid=${resumeAtMsg.uuid.slice(0, 8)}... type=${resumeAtMsg.type}`);
232
+
233
+ try {
234
+ // Use query() with resume + resumeSessionAt + forkSession to test
235
+ const q = query({
236
+ prompt: "Just say 'RESUME_AT_TEST_OK' and nothing else.",
237
+ options: {
238
+ resume: sourceSession.sessionId,
239
+ resumeSessionAt: resumeAtMsg.uuid,
240
+ forkSession: true,
241
+ cwd: PROJECT_DIR,
242
+ maxTurns: 1,
243
+ allowedTools: [],
244
+ permissionMode: "bypassPermissions",
245
+ systemPrompt: { type: "custom", value: "You are a test assistant. Reply exactly as instructed." },
246
+ },
247
+ });
248
+
249
+ let resultSessionId = null;
250
+ let gotText = false;
251
+
252
+ for await (const msg of q) {
253
+ if (msg.type === "assistant") {
254
+ const textBlocks = (msg.message?.content || []).filter(b => b.type === "text");
255
+ if (textBlocks.length > 0) {
256
+ logInfo(`Assistant replied: "${textBlocks.map(b => b.text).join("").slice(0, 100)}"`);
257
+ gotText = true;
258
+ }
259
+ }
260
+ if (msg.type === "result") {
261
+ resultSessionId = msg.session_id;
262
+ logInfo(`Result: subtype=${msg.subtype} session=${resultSessionId?.slice(0, 8)}... cost=$${msg.total_cost_usd?.toFixed(4)}`);
263
+ }
264
+ }
265
+
266
+ if (gotText && resultSessionId) {
267
+ logOk(`resumeSessionAt works! Forked session: ${resultSessionId}`);
268
+
269
+ // Check the forked session has truncated history
270
+ const forkedMsgs = await getSessionMessages(resultSessionId, { dir: PROJECT_DIR });
271
+ logInfo(`Forked session messages: ${forkedMsgs.length} (original: ${sourceMessages.length})`);
272
+
273
+ // Cleanup: delete test session
274
+ return resultSessionId;
275
+ } else {
276
+ logFail("resumeSessionAt did not produce expected output");
277
+ }
278
+ } catch (e) {
279
+ logFail(`resumeSessionAt failed: ${e.message}`);
280
+ console.error(e);
281
+ }
282
+ }
283
+
284
+ // ─── Test 6: Compact/Summary for Merge ──────────────────────────────────────
285
+ async function testCompactSummary() {
286
+ log("TEST: Generate summary for merge (using query + compact approach)");
287
+
288
+ // Find a session with enough content
289
+ const sessions = await listSessions({ dir: PROJECT_DIR, limit: 20 });
290
+ let sourceSession = null;
291
+ let sourceMessages = [];
292
+
293
+ for (const s of sessions) {
294
+ const msgs = await getSessionMessages(s.sessionId, { dir: PROJECT_DIR });
295
+ if (msgs.length >= 6) {
296
+ sourceSession = s;
297
+ sourceMessages = msgs;
298
+ break;
299
+ }
300
+ }
301
+
302
+ if (!sourceSession) {
303
+ logFail("No session with 6+ messages for compact test");
304
+ return;
305
+ }
306
+
307
+ logInfo(`Source: ${sourceSession.sessionId.slice(0, 8)}... (${sourceMessages.length} msgs)`);
308
+
309
+ // Extract conversation text for summary
310
+ const conversationText = sourceMessages.slice(0, 10).map((msg, i) => {
311
+ const content = msg.message?.content;
312
+ let text = "";
313
+ if (typeof content === "string") {
314
+ text = content;
315
+ } else if (Array.isArray(content)) {
316
+ text = content
317
+ .filter(b => b.type === "text")
318
+ .map(b => b.text)
319
+ .join("\n")
320
+ .slice(0, 500);
321
+ }
322
+ return `[${msg.type}] ${text.slice(0, 300)}`;
323
+ }).join("\n---\n");
324
+
325
+ logInfo(`Conversation excerpt (${conversationText.length} chars):\n${conversationText.slice(0, 500)}...`);
326
+
327
+ // Use a cheap query to generate summary
328
+ logInfo("Generating summary via Claude (short query)...");
329
+ try {
330
+ const summaryPrompt = `Summarize this conversation in 3-5 bullet points. Focus on: what was discussed, what decisions were made, what files were modified.
331
+
332
+ CONVERSATION:
333
+ ${conversationText.slice(0, 3000)}
334
+
335
+ Reply with ONLY the bullet points, no preamble.`;
336
+
337
+ const q = query({
338
+ prompt: summaryPrompt,
339
+ options: {
340
+ cwd: PROJECT_DIR,
341
+ maxTurns: 1,
342
+ allowedTools: [],
343
+ permissionMode: "bypassPermissions",
344
+ model: "haiku",
345
+ systemPrompt: { type: "custom", value: "You are a concise conversation summarizer." },
346
+ persistSession: false, // Don't save this summary session
347
+ },
348
+ });
349
+
350
+ let summary = "";
351
+ for await (const msg of q) {
352
+ if (msg.type === "assistant") {
353
+ const textBlocks = (msg.message?.content || []).filter(b => b.type === "text");
354
+ summary += textBlocks.map(b => b.text).join("");
355
+ }
356
+ if (msg.type === "result") {
357
+ logInfo(`Summary cost: $${msg.total_cost_usd?.toFixed(4)}`);
358
+ }
359
+ }
360
+
361
+ if (summary) {
362
+ logOk("Summary generated successfully:");
363
+ console.log(`\n${summary}\n`);
364
+ logInfo("This summary could be injected into a merge session's system prompt");
365
+ } else {
366
+ logFail("No summary text produced");
367
+ }
368
+ } catch (e) {
369
+ logFail(`Summary generation failed: ${e.message}`);
370
+ console.error(e);
371
+ }
372
+ }
373
+
374
+ // ─── Test 7: Tag Session ────────────────────────────────────────────────────
375
+ async function testTagSession() {
376
+ log("TEST: tagSession");
377
+
378
+ const sessions = await listSessions({ dir: PROJECT_DIR, limit: 5 });
379
+ if (sessions.length === 0) { logFail("No sessions"); return; }
380
+
381
+ const target = sessions[0];
382
+ logInfo(`Tagging session ${target.sessionId.slice(0, 8)}... (current tag: ${target.tag ?? "none"})`);
383
+
384
+ try {
385
+ await tagSession(target.sessionId, "test-tag", { dir: PROJECT_DIR });
386
+ const info = await getSessionInfo(target.sessionId, { dir: PROJECT_DIR });
387
+ logInfo(`After tag: tag="${info?.tag}"`);
388
+
389
+ if (info?.tag === "test-tag") {
390
+ logOk("tagSession works");
391
+ } else {
392
+ logFail(`Expected tag="test-tag", got "${info?.tag}"`);
393
+ }
394
+
395
+ // Clear tag
396
+ await tagSession(target.sessionId, null, { dir: PROJECT_DIR });
397
+ logInfo("Tag cleared");
398
+
399
+ } catch (e) {
400
+ logFail(`tagSession failed: ${e.message}`);
401
+ }
402
+ }
403
+
404
+ // ─── Runner ─────────────────────────────────────────────────────────────────
405
+ async function main() {
406
+ console.log(`\n🧪 Session Operations Test Suite`);
407
+ console.log(`Project: ${PROJECT_DIR}`);
408
+ console.log(`Test: ${testName}\n`);
409
+
410
+ const tests = {
411
+ list: testListSessions,
412
+ messages: testGetMessages,
413
+ "fork-mid": testForkMid,
414
+ delete: testDelete,
415
+ "resume-at": testResumeAt,
416
+ "compact-summary": testCompactSummary,
417
+ tag: testTagSession,
418
+ };
419
+
420
+ if (testName === "all") {
421
+ // Run non-destructive tests first
422
+ await testListSessions();
423
+ await testGetMessages();
424
+ await testTagSession();
425
+ await testForkMid();
426
+ // resume-at and compact-summary cost API tokens — ask first
427
+ console.log("\n⚠️ Remaining tests (resume-at, compact-summary) cost API tokens.");
428
+ console.log(" Run individually: bun test-session-ops.mjs resume-at");
429
+ console.log(" Run individually: bun test-session-ops.mjs compact-summary");
430
+ console.log(" Delete test: bun test-session-ops.mjs delete");
431
+ } else if (tests[testName]) {
432
+ await tests[testName]();
433
+ } else {
434
+ console.log(`Unknown test: ${testName}`);
435
+ console.log(`Available: ${Object.keys(tests).join(", ")}, all`);
436
+ }
437
+
438
+ log("DONE", `Finished ${testName} test(s)`);
439
+ }
440
+
441
+ main().catch(e => {
442
+ console.error("Fatal:", e);
443
+ process.exit(1);
444
+ });