@hienlh/ppm 0.9.19 → 0.9.21

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 (30) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/web/assets/{browser-tab-DQooX_NG.js → browser-tab-h-o4sr-C.js} +1 -1
  3. package/dist/web/assets/{chat-tab-DN1gkctd.js → chat-tab-CAtJTlUG.js} +1 -1
  4. package/dist/web/assets/{code-editor-CQS14TkD.js → code-editor-BpqGrgO5.js} +1 -1
  5. package/dist/web/assets/{database-viewer-Cubi4CgO.js → database-viewer-CAoT6uGQ.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-Dhe-nwBH.js → diff-viewer-topWA5Ta.js} +1 -1
  7. package/dist/web/assets/{extension-webview-DKk1VRdk.js → extension-webview-CaKx8yvJ.js} +1 -1
  8. package/dist/web/assets/{git-graph-1I4ZysBp.js → git-graph-CtzzQol9.js} +1 -1
  9. package/dist/web/assets/index-CrA8grdC.js +37 -0
  10. package/dist/web/assets/keybindings-store-BkysY7OH.js +1 -0
  11. package/dist/web/assets/{markdown-renderer-BSnLPKA9.js → markdown-renderer-nuQkYTwi.js} +1 -1
  12. package/dist/web/assets/{postgres-viewer-C0ymjGr_.js → postgres-viewer-D8RVcxoD.js} +1 -1
  13. package/dist/web/assets/{settings-tab-DAEDfps3.js → settings-tab-BO8VGVhV.js} +1 -1
  14. package/dist/web/assets/{sqlite-viewer-DpJ1s0OV.js → sqlite-viewer-CeOBrFyF.js} +1 -1
  15. package/dist/web/assets/{tab-store-CeOacjuH.js → tab-store-D_bvdnNN.js} +1 -1
  16. package/dist/web/assets/{terminal-tab-Dpch-fU5.js → terminal-tab-CqTiEwFF.js} +1 -1
  17. package/dist/web/assets/{use-monaco-theme-VqvGfpV-.js → use-monaco-theme-BFgASSqT.js} +1 -1
  18. package/dist/web/index.html +2 -2
  19. package/dist/web/sw.js +1 -1
  20. package/docs/streaming-input-guide.md +267 -0
  21. package/package.json +1 -1
  22. package/snapshot-state.md +1526 -0
  23. package/src/providers/claude-agent-sdk.ts +57 -8
  24. package/src/web/app.tsx +11 -8
  25. package/test-session-ops.mjs +444 -0
  26. package/test-tokens.mjs +212 -0
  27. package/.claude.bak/agent-memory/tester/MEMORY.md +0 -3
  28. package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +0 -32
  29. package/dist/web/assets/index-BzIjHwjQ.js +0 -37
  30. package/dist/web/assets/keybindings-store-B4z1Uu7-.js +0 -1
@@ -784,6 +784,41 @@ export class ClaudeAgentSdkProvider implements AIProvider {
784
784
  continue;
785
785
  }
786
786
 
787
+ // Intercept SDK's internal api_retry with 401 — the SDK will retry up to 10 times
788
+ // with exponential backoff using the same expired token, wasting 2+ minutes.
789
+ // Instead, refresh the OAuth token immediately and restart the query.
790
+ if (subtype === "api_retry" && (msg as any).error_status === 401 && account && !authRetried) {
791
+ authRetried = true;
792
+ try {
793
+ await accountService.refreshAccessToken(account.id, false);
794
+ const refreshedAccount = accountService.getWithTokens(account.id);
795
+ if (refreshedAccount) {
796
+ const label = refreshedAccount.label ?? refreshedAccount.email ?? "Unknown";
797
+ console.log(`[sdk] session=${sessionId} intercepted api_retry 401 — refreshing token for ${account.id} (${label})`);
798
+ yield { type: "account_retry" as const, reason: "Token refreshed", accountId: refreshedAccount.id, accountLabel: label };
799
+ const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
800
+ streamCtrl.done();
801
+ q.close();
802
+ const { generator: earlyAuthGen, controller: earlyAuthCtrl } = createMessageChannel();
803
+ const currentSdkId = getSessionMapping(sessionId);
804
+ const canResume = currentSdkId && currentSdkId !== sessionId;
805
+ if (!canResume) earlyAuthCtrl.push(firstMsg);
806
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: canResume ? currentSdkId : undefined, env: retryEnv };
807
+ const rq = query({
808
+ prompt: earlyAuthGen,
809
+ options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
810
+ });
811
+ this.streamingSessions.set(sessionId, { meta, query: rq, controller: earlyAuthCtrl });
812
+ this.activeQueries.set(sessionId, rq);
813
+ eventSource = rq;
814
+ continue retryLoop;
815
+ }
816
+ } catch (refreshErr) {
817
+ console.error(`[sdk] session=${sessionId} early OAuth refresh failed:`, refreshErr);
818
+ accountSelector.onAuthError(account.id);
819
+ }
820
+ }
821
+
787
822
  // Yield system events so streaming loop can transition phases
788
823
  // (e.g. connecting → thinking when hooks/init arrive)
789
824
  yield { type: "system" as any, subtype } as any;
@@ -912,11 +947,16 @@ export class ClaudeAgentSdkProvider implements AIProvider {
912
947
  yield { type: "account_retry" as const, reason: "Token refreshed", accountId: refreshedAccount.id, accountLabel: label };
913
948
  const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
914
949
  // Close failed query and old channel, create new channel + query with refreshed token.
915
- // Resume existing SDK session so conversation context is preserved.
950
+ // Re-resolve sdkId: the init event may have mapped ppmId → real SDK session_id
951
+ // after sdkId was originally resolved. Using the stale value would try to
952
+ // resume a non-existent session, causing the SDK to hang forever.
916
953
  streamCtrl.done();
917
954
  q.close();
918
955
  const { generator: authRetryGen, controller: authRetryCtrl } = createMessageChannel();
919
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: sdkId, env: retryEnv };
956
+ const currentSdkId = getSessionMapping(sessionId);
957
+ const canResume = currentSdkId && currentSdkId !== sessionId;
958
+ if (!canResume) authRetryCtrl.push(firstMsg);
959
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: canResume ? currentSdkId : undefined, env: retryEnv };
920
960
  const rq = query({
921
961
  prompt: authRetryGen,
922
962
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
@@ -963,12 +1003,15 @@ export class ClaudeAgentSdkProvider implements AIProvider {
963
1003
  yield { type: "error", message: `Rate limited. Auto-retrying in ${backoff / 1000}s... (${rateLimitRetryCount}/${MAX_RATE_LIMIT_RETRIES})` };
964
1004
  await new Promise((r) => setTimeout(r, backoff));
965
1005
  // Close failed query and recreate with (potentially new) account env.
966
- // Resume existing SDK session so conversation context is preserved.
1006
+ // Re-resolve sdkId to pick up init-event mapping (see auth retry comment).
967
1007
  streamCtrl.done();
968
1008
  q.close();
969
1009
  const rlRetryEnv = this.buildQueryEnv(meta.projectPath, account);
970
1010
  const { generator: rlRetryGen, controller: rlRetryCtrl } = createMessageChannel();
971
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: sdkId, env: rlRetryEnv };
1011
+ const rlCurrentSdkId = getSessionMapping(sessionId);
1012
+ const rlCanResume = rlCurrentSdkId && rlCurrentSdkId !== sessionId;
1013
+ if (!rlCanResume) rlRetryCtrl.push(firstMsg);
1014
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: rlCanResume ? rlCurrentSdkId : undefined, env: rlRetryEnv };
972
1015
  const rq = query({
973
1016
  prompt: rlRetryGen,
974
1017
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
@@ -1059,12 +1102,15 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1059
1102
  }
1060
1103
  yield { type: "error", message: `Rate limited. Auto-retrying in ${backoff / 1000}s... (${rateLimitRetryCount}/${MAX_RATE_LIMIT_RETRIES})` };
1061
1104
  await new Promise((r) => setTimeout(r, backoff));
1062
- // Resume existing SDK session so conversation context is preserved.
1105
+ // Re-resolve sdkId to pick up init-event mapping (see auth retry comment).
1063
1106
  streamCtrl.done();
1064
1107
  q.close();
1065
1108
  const rlRetryEnv = this.buildQueryEnv(meta.projectPath, account);
1066
1109
  const { generator: rlRetryGen, controller: rlRetryCtrl } = createMessageChannel();
1067
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: sdkId, env: rlRetryEnv };
1110
+ const rlCurrentSdkId2 = getSessionMapping(sessionId);
1111
+ const rlCanResume2 = rlCurrentSdkId2 && rlCurrentSdkId2 !== sessionId;
1112
+ if (!rlCanResume2) rlRetryCtrl.push(firstMsg);
1113
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: rlCanResume2 ? rlCurrentSdkId2 : undefined, env: rlRetryEnv };
1068
1114
  const rq = query({
1069
1115
  prompt: rlRetryGen,
1070
1116
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
@@ -1087,12 +1133,15 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1087
1133
  const label = refreshedAccount.label ?? refreshedAccount.email ?? "Unknown";
1088
1134
  console.log(`[sdk] 401 in result on account ${account.id} (${label}) — token refreshed, retrying`);
1089
1135
  yield { type: "account_retry" as const, reason: "Token refreshed", accountId: refreshedAccount.id, accountLabel: label };
1090
- // Resume existing SDK session so conversation context is preserved.
1136
+ // Re-resolve sdkId to pick up init-event mapping (see auth retry comment).
1091
1137
  streamCtrl.done();
1092
1138
  q.close();
1093
1139
  const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
1094
1140
  const { generator: authRetryGen2, controller: authRetryCtrl2 } = createMessageChannel();
1095
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: sdkId, env: retryEnv };
1141
+ const authCurrentSdkId2 = getSessionMapping(sessionId);
1142
+ const authCanResume2 = authCurrentSdkId2 && authCurrentSdkId2 !== sessionId;
1143
+ if (!authCanResume2) authRetryCtrl2.push(firstMsg);
1144
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: authCanResume2 ? authCurrentSdkId2 : undefined, env: retryEnv };
1096
1145
  const rq = query({
1097
1146
  prompt: authRetryGen2,
1098
1147
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
package/src/web/app.tsx CHANGED
@@ -10,11 +10,9 @@ import { ProjectBottomSheet } from "@/components/layout/project-bottom-sheet";
10
10
  import { LoginScreen } from "@/components/auth/login-screen";
11
11
  import { useProjectStore, resolveOrder } from "@/stores/project-store";
12
12
  import { useTabStore } from "@/stores/tab-store";
13
- import { usePanelStore } from "@/stores/panel-store";
14
13
  import {
15
14
  fetchWorkspaceFromServer,
16
15
  resolveWorkspaceConflict,
17
- savePanelLayout,
18
16
  } from "@/stores/panel-utils";
19
17
  import {
20
18
  useSettingsStore,
@@ -160,21 +158,26 @@ export function App() {
160
158
  }
161
159
  if (!target) return;
162
160
 
163
- useProjectStore.getState().setActiveProject(target);
164
-
165
- // Fetch server workspace + compare with localStorage (latest-wins)
161
+ // Fetch server workspace BEFORE activating project.
162
+ // setActiveProject triggers switchProject which creates an empty layout
163
+ // with a new timestamp if localStorage is empty (new tunnel/device).
164
+ // By pre-populating localStorage, switchProject picks up the server data.
166
165
  const serverLayout = await fetchWorkspaceFromServer(target.name);
167
166
  if (serverLayout) {
168
167
  const localRaw = localStorage.getItem(`ppm-panels-${target.name}`);
169
168
  const localLayout = localRaw ? JSON.parse(localRaw) : null;
170
169
  const resolved = resolveWorkspaceConflict(localLayout, serverLayout);
171
170
  if (resolved && resolved === serverLayout) {
172
- // Server wins — overwrite localStorage and reload panels
173
- savePanelLayout(target.name, resolved);
174
- usePanelStore.getState().reloadProject(target.name);
171
+ // Server wins — write directly to localStorage (no server sync needed)
172
+ localStorage.setItem(
173
+ `ppm-panels-${target.name}`,
174
+ JSON.stringify(serverLayout),
175
+ );
175
176
  }
176
177
  }
177
178
 
179
+ useProjectStore.getState().setActiveProject(target);
180
+
178
181
  // Auto-open target tab from URL (type-based)
179
182
  queueMicrotask(() => {
180
183
  if (urlState.tabType) {
@@ -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
+ });