@hienlh/ppm 0.9.1 → 0.9.2
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.
- package/.claude.bak/agent-memory/tester/MEMORY.md +3 -0
- package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +32 -0
- package/CHANGELOG.md +7 -0
- package/dist/web/assets/{browser-tab-CrkhFCaw.js → browser-tab-CjUzlPYv.js} +1 -1
- package/dist/web/assets/chat-tab-moB4W7-w.js +8 -0
- package/dist/web/assets/{code-editor-CBIPzlP2.js → code-editor-aQQZUc2m.js} +1 -1
- package/dist/web/assets/{database-viewer-BqOJR_zi.js → database-viewer-ChyP1N3c.js} +1 -1
- package/dist/web/assets/{diff-viewer-CcLyp4eY.js → diff-viewer-ktwO5JbX.js} +1 -1
- package/dist/web/assets/{extension-webview-NiZ7Ybvv.js → extension-webview-Bx1TlP6q.js} +1 -1
- package/dist/web/assets/{git-graph-CoTvMrIo.js → git-graph-BIrGMX6e.js} +1 -1
- package/dist/web/assets/{index-C8byznLO.js → index-C6KLr58u.js} +3 -3
- package/dist/web/assets/index-DpBKDbIW.css +2 -0
- package/dist/web/assets/keybindings-store-D3Y5c5uS.js +1 -0
- package/dist/web/assets/{markdown-renderer-DPLdR9xc.js → markdown-renderer-A7J2gdKT.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BeiK4lCa.js → postgres-viewer-C9-Acry_.js} +1 -1
- package/dist/web/assets/{settings-tab-D3AvU4lu.js → settings-tab-C17exmRv.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-nA2sD4Yv.js → sqlite-viewer-Dr5oWCWA.js} +1 -1
- package/dist/web/assets/{terminal-tab-BBi0pEji.js → terminal-tab-CpyKvyfC.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-B5pG2d1w.js → use-monaco-theme-BjPAik5w.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/monacoeditorwork/css.worker.bundle.js +122 -122
- package/dist/web/monacoeditorwork/editor.worker.bundle.js +78 -78
- package/dist/web/monacoeditorwork/html.worker.bundle.js +110 -110
- package/dist/web/monacoeditorwork/json.worker.bundle.js +108 -108
- package/dist/web/monacoeditorwork/ts.worker.bundle.js +81 -81
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/web/components/chat/message-input.tsx +29 -29
- package/src/web/components/chat/usage-badge.tsx +17 -4
- package/dist/web/assets/chat-tab-C6jpiwh7.js +0 -8
- package/dist/web/assets/index-KwC2YrG4.css +0 -2
- package/dist/web/assets/keybindings-store-DPYzBe_M.js +0 -1
- package/docs/streaming-input-guide.md +0 -267
- package/snapshot-state.md +0 -1526
- package/test-session-ops.mjs +0 -444
- package/test-tokens.mjs +0 -212
package/test-session-ops.mjs
DELETED
|
@@ -1,444 +0,0 @@
|
|
|
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
|
-
});
|
package/test-tokens.mjs
DELETED
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
/**
|
|
3
|
-
* Test script to check access token & refresh token expiry behavior.
|
|
4
|
-
*
|
|
5
|
-
* Usage:
|
|
6
|
-
* bun test-tokens.mjs # test all accounts in dev DB
|
|
7
|
-
* bun test-tokens.mjs <account-id> # test specific account
|
|
8
|
-
* bun test-tokens.mjs --refresh <id> # force refresh and show new expiry
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import Database from "bun:sqlite";
|
|
12
|
-
import { join } from "node:path";
|
|
13
|
-
import { homedir } from "node:os";
|
|
14
|
-
import { createDecipheriv } from "node:crypto";
|
|
15
|
-
|
|
16
|
-
// --- Config ---
|
|
17
|
-
const DB_PATH = join(homedir(), ".ppm", "ppm.dev.db");
|
|
18
|
-
const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
19
|
-
const OAUTH_TOKEN_URL = "https://api.anthropic.com/v1/oauth/token";
|
|
20
|
-
const PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
|
|
21
|
-
|
|
22
|
-
// --- Crypto (matches src/lib/account-crypto.ts) ---
|
|
23
|
-
function getEncryptionKey() {
|
|
24
|
-
const keyPath = join(homedir(), ".ppm", "account.key");
|
|
25
|
-
try {
|
|
26
|
-
const hex = require("node:fs").readFileSync(keyPath, "utf-8").trim();
|
|
27
|
-
return Buffer.from(hex, "hex");
|
|
28
|
-
} catch {
|
|
29
|
-
console.error(`Cannot find encryption key at ${keyPath}`);
|
|
30
|
-
process.exit(1);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function decrypt(encryptedValue) {
|
|
35
|
-
if (!encryptedValue || encryptedValue === "") return "";
|
|
36
|
-
const parts = encryptedValue.split(":");
|
|
37
|
-
if (parts.length !== 3) return encryptedValue; // not encrypted
|
|
38
|
-
const [ivHex, authTagHex, cipherHex] = parts;
|
|
39
|
-
const key = getEncryptionKey();
|
|
40
|
-
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivHex, "hex"));
|
|
41
|
-
decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
|
|
42
|
-
let decrypted = decipher.update(Buffer.from(cipherHex, "hex"), undefined, "utf8");
|
|
43
|
-
decrypted += decipher.final("utf8");
|
|
44
|
-
return decrypted;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// --- Helpers ---
|
|
48
|
-
function formatExpiry(expiresAt) {
|
|
49
|
-
if (!expiresAt) return "N/A (no expiry)";
|
|
50
|
-
const now = Math.floor(Date.now() / 1000);
|
|
51
|
-
const diff = expiresAt - now;
|
|
52
|
-
const absMin = Math.abs(Math.floor(diff / 60));
|
|
53
|
-
const absHr = Math.floor(absMin / 60);
|
|
54
|
-
const remMin = absMin % 60;
|
|
55
|
-
const date = new Date(expiresAt * 1000).toLocaleString("vi-VN", { timeZone: "Asia/Saigon" });
|
|
56
|
-
if (diff > 0) {
|
|
57
|
-
return `${date} (còn ${absHr}h${remMin}m)`;
|
|
58
|
-
} else {
|
|
59
|
-
return `${date} (HẾT HẠN ${absHr}h${remMin}m trước)`;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function testAccessToken(token) {
|
|
64
|
-
try {
|
|
65
|
-
const res = await fetch(PROFILE_URL, {
|
|
66
|
-
headers: {
|
|
67
|
-
Accept: "application/json",
|
|
68
|
-
Authorization: `Bearer ${token}`,
|
|
69
|
-
"anthropic-beta": "oauth-2025-04-20",
|
|
70
|
-
"User-Agent": "ppm-token-test/1.0",
|
|
71
|
-
},
|
|
72
|
-
signal: AbortSignal.timeout(10_000),
|
|
73
|
-
});
|
|
74
|
-
if (res.status === 200) {
|
|
75
|
-
const data = await res.json();
|
|
76
|
-
return { status: "VALID", code: 200, email: data.account?.email, name: data.account?.display_name };
|
|
77
|
-
}
|
|
78
|
-
if (res.status === 429) return { status: "VALID (rate-limited)", code: 429 };
|
|
79
|
-
const body = await res.text().catch(() => "");
|
|
80
|
-
return { status: "INVALID", code: res.status, error: body.slice(0, 200) };
|
|
81
|
-
} catch (e) {
|
|
82
|
-
return { status: "ERROR", error: e.message };
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async function testRefreshToken(refreshToken) {
|
|
87
|
-
if (!refreshToken || refreshToken === "") {
|
|
88
|
-
return { status: "NO_TOKEN", error: "Empty refresh token (temporary account)" };
|
|
89
|
-
}
|
|
90
|
-
try {
|
|
91
|
-
const res = await fetch(OAUTH_TOKEN_URL, {
|
|
92
|
-
method: "POST",
|
|
93
|
-
headers: { "Content-Type": "application/json" },
|
|
94
|
-
body: JSON.stringify({
|
|
95
|
-
grant_type: "refresh_token",
|
|
96
|
-
client_id: OAUTH_CLIENT_ID,
|
|
97
|
-
refresh_token: refreshToken,
|
|
98
|
-
}),
|
|
99
|
-
});
|
|
100
|
-
if (res.ok) {
|
|
101
|
-
const data = await res.json();
|
|
102
|
-
return {
|
|
103
|
-
status: "VALID",
|
|
104
|
-
code: 200,
|
|
105
|
-
newAccessToken: data.access_token?.slice(0, 20) + "...",
|
|
106
|
-
newRefreshToken: data.refresh_token ? "YES (rotated)" : "NO (same)",
|
|
107
|
-
expiresIn: data.expires_in,
|
|
108
|
-
expiresInReadable: `${Math.floor(data.expires_in / 3600)}h${Math.floor((data.expires_in % 3600) / 60)}m`,
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
const body = await res.text().catch(() => "");
|
|
112
|
-
return { status: "INVALID", code: res.status, error: body.slice(0, 300) };
|
|
113
|
-
} catch (e) {
|
|
114
|
-
return { status: "ERROR", error: e.message };
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// --- Main ---
|
|
119
|
-
const args = process.argv.slice(2);
|
|
120
|
-
const doRefresh = args.includes("--refresh");
|
|
121
|
-
const targetId = args.find((a) => a !== "--refresh");
|
|
122
|
-
|
|
123
|
-
console.log("=".repeat(70));
|
|
124
|
-
console.log("PPM Token Expiry Test");
|
|
125
|
-
console.log(`DB: ${DB_PATH}`);
|
|
126
|
-
console.log(`Time: ${new Date().toLocaleString("vi-VN", { timeZone: "Asia/Saigon" })}`);
|
|
127
|
-
console.log("=".repeat(70));
|
|
128
|
-
|
|
129
|
-
let db;
|
|
130
|
-
try {
|
|
131
|
-
db = new Database(DB_PATH, { readonly: true });
|
|
132
|
-
} catch (e) {
|
|
133
|
-
console.error(`Cannot open DB: ${e.message}`);
|
|
134
|
-
process.exit(1);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const query = targetId
|
|
138
|
-
? db.prepare("SELECT * FROM accounts WHERE id = ?").all(targetId)
|
|
139
|
-
: db.prepare("SELECT * FROM accounts ORDER BY priority ASC, created_at ASC").all();
|
|
140
|
-
|
|
141
|
-
if (query.length === 0) {
|
|
142
|
-
console.log("No accounts found.");
|
|
143
|
-
process.exit(0);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
for (const row of query) {
|
|
147
|
-
console.log("\n" + "-".repeat(70));
|
|
148
|
-
console.log(`Account: ${row.label ?? "N/A"}`);
|
|
149
|
-
console.log(` ID: ${row.id}`);
|
|
150
|
-
console.log(` Email: ${row.email ?? "N/A"}`);
|
|
151
|
-
console.log(` Status: ${row.status}`);
|
|
152
|
-
console.log(` Expires: ${formatExpiry(row.expires_at)}`);
|
|
153
|
-
|
|
154
|
-
let accessToken, refreshToken;
|
|
155
|
-
try {
|
|
156
|
-
accessToken = decrypt(row.access_token);
|
|
157
|
-
refreshToken = decrypt(row.refresh_token);
|
|
158
|
-
} catch (e) {
|
|
159
|
-
console.log(` ⚠ Decrypt failed: ${e.message}`);
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const isOAuth = accessToken.startsWith("sk-ant-oat");
|
|
164
|
-
console.log(` Type: ${isOAuth ? "OAuth token" : "API key"}`);
|
|
165
|
-
console.log(` Has refresh token: ${refreshToken && refreshToken !== "" ? "YES" : "NO"}`);
|
|
166
|
-
|
|
167
|
-
if (!isOAuth) {
|
|
168
|
-
console.log(` → API keys don't expire. Skipping token tests.`);
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Test 1: Is access token still valid?
|
|
173
|
-
console.log("\n [1] Testing access token against profile API...");
|
|
174
|
-
const accessResult = await testAccessToken(accessToken);
|
|
175
|
-
if (accessResult.status.startsWith("VALID")) {
|
|
176
|
-
console.log(` ✓ Access token: ${accessResult.status}`);
|
|
177
|
-
if (accessResult.email) console.log(` Email: ${accessResult.email}, Name: ${accessResult.name}`);
|
|
178
|
-
} else {
|
|
179
|
-
console.log(` ✗ Access token: ${accessResult.status} (HTTP ${accessResult.code})`);
|
|
180
|
-
if (accessResult.error) console.log(` Error: ${accessResult.error}`);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Test 2: Is refresh token still valid?
|
|
184
|
-
if (doRefresh) {
|
|
185
|
-
console.log("\n [2] Testing refresh token (will actually refresh!)...");
|
|
186
|
-
const refreshResult = await testRefreshToken(refreshToken);
|
|
187
|
-
if (refreshResult.status === "VALID") {
|
|
188
|
-
console.log(` ✓ Refresh token: VALID`);
|
|
189
|
-
console.log(` New access token: ${refreshResult.newAccessToken}`);
|
|
190
|
-
console.log(` New refresh token: ${refreshResult.newRefreshToken}`);
|
|
191
|
-
console.log(` New expires_in: ${refreshResult.expiresIn}s (${refreshResult.expiresInReadable})`);
|
|
192
|
-
console.log(` ⚠ NOTE: Old access token may now be invalidated!`);
|
|
193
|
-
console.log(` ⚠ Run this script WITHOUT --refresh to avoid side effects.`);
|
|
194
|
-
} else {
|
|
195
|
-
console.log(` ✗ Refresh token: ${refreshResult.status}`);
|
|
196
|
-
if (refreshResult.error) console.log(` Error: ${refreshResult.error}`);
|
|
197
|
-
}
|
|
198
|
-
} else {
|
|
199
|
-
console.log("\n [2] Refresh token: SKIPPED (use --refresh to test)");
|
|
200
|
-
console.log(` ⚠ --refresh sẽ tạo access token MỚI, token cũ có thể bị invalidate!`);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
console.log("\n" + "=".repeat(70));
|
|
205
|
-
console.log("Summary:");
|
|
206
|
-
console.log(" - Access token (sk-ant-oat*): thường hết hạn sau ~1h (expires_in từ OAuth)");
|
|
207
|
-
console.log(" - Refresh token: không có expiry rõ ràng, chết khi Anthropic trả invalid_grant");
|
|
208
|
-
console.log(" - Khi refresh: Anthropic CÓ THỂ rotate refresh token (trả token mới)");
|
|
209
|
-
console.log(" - PPM auto-refresh mỗi 5 phút cho token sắp hết hạn (<5 phút)");
|
|
210
|
-
console.log("=".repeat(70));
|
|
211
|
-
|
|
212
|
-
db.close();
|