@hellcoder/companion 0.96.0
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/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- package/server/ws-bridge.ts +1279 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { hasContainerClaudeAuth } from "./claude-container-auth.js";
|
|
6
|
+
|
|
7
|
+
describe("hasContainerClaudeAuth", () => {
|
|
8
|
+
let tempHome: string;
|
|
9
|
+
let prevHome: string | undefined;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
tempHome = mkdtempSync(join(tmpdir(), "claude-auth-test-"));
|
|
13
|
+
prevHome = process.env.HOME;
|
|
14
|
+
process.env.HOME = tempHome;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
if (prevHome === undefined) {
|
|
19
|
+
delete process.env.HOME;
|
|
20
|
+
} else {
|
|
21
|
+
process.env.HOME = prevHome;
|
|
22
|
+
}
|
|
23
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns true when auth env vars are provided", () => {
|
|
27
|
+
expect(hasContainerClaudeAuth({ ANTHROPIC_API_KEY: "sk-test" })).toBe(true);
|
|
28
|
+
expect(hasContainerClaudeAuth({ ANTHROPIC_AUTH_TOKEN: "tok-test" })).toBe(true);
|
|
29
|
+
expect(hasContainerClaudeAuth({ CLAUDE_CODE_AUTH_TOKEN: "tok-test" })).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns true when known auth files exist under ~/.claude", () => {
|
|
33
|
+
const claudeDir = join(tempHome, ".claude");
|
|
34
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
35
|
+
writeFileSync(join(claudeDir, ".credentials.json"), "{\"token\":\"x\"}");
|
|
36
|
+
|
|
37
|
+
expect(hasContainerClaudeAuth()).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns false when neither env nor auth files are present", () => {
|
|
41
|
+
expect(hasContainerClaudeAuth()).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns true when Claude running inside a container has a plausible auth source:
|
|
7
|
+
* - explicit auth env vars, or
|
|
8
|
+
* - known auth files under ~/.claude that can be copied into the container.
|
|
9
|
+
*/
|
|
10
|
+
export function hasContainerClaudeAuth(envVars?: Record<string, string>): boolean {
|
|
11
|
+
if (
|
|
12
|
+
!!envVars?.ANTHROPIC_API_KEY
|
|
13
|
+
|| !!envVars?.ANTHROPIC_AUTH_TOKEN
|
|
14
|
+
|| !!envVars?.CLAUDE_CODE_AUTH_TOKEN
|
|
15
|
+
|| !!envVars?.CLAUDE_CODE_OAUTH_TOKEN
|
|
16
|
+
) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const home = process.env.HOME || process.env.USERPROFILE || homedir();
|
|
21
|
+
const candidates = [
|
|
22
|
+
join(home, ".claude", ".credentials.json"),
|
|
23
|
+
join(home, ".claude", "auth.json"),
|
|
24
|
+
join(home, ".claude", ".auth.json"),
|
|
25
|
+
join(home, ".claude", "credentials.json"),
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
return candidates.some((p) => existsSync(p));
|
|
29
|
+
}
|
|
30
|
+
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
function readSnapshot(relativePath: string): string {
|
|
6
|
+
return readFileSync(resolve(process.cwd(), relativePath), "utf-8");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function extractAliasBody(tsSource: string, alias: string): string {
|
|
10
|
+
const match = tsSource.match(new RegExp(`export declare type ${alias} = \\{([\\s\\S]*?)\\n\\};`));
|
|
11
|
+
return match?.[1] || "";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("Claude protocol compatibility (offline Agent SDK snapshot)", () => {
|
|
15
|
+
it("includes all Claude message categories used by the bridge", () => {
|
|
16
|
+
const sdk = readSnapshot("server/protocol/claude-upstream/sdk.d.ts.txt");
|
|
17
|
+
|
|
18
|
+
expect(sdk).toContain("export declare type SDKAssistantMessage = {");
|
|
19
|
+
expect(sdk).toContain("type: 'assistant';");
|
|
20
|
+
|
|
21
|
+
expect(sdk).toContain("export declare type SDKPartialAssistantMessage = {");
|
|
22
|
+
expect(sdk).toContain("type: 'stream_event';");
|
|
23
|
+
|
|
24
|
+
expect(sdk).toContain("export declare type SDKResultSuccess = {");
|
|
25
|
+
expect(sdk).toContain("export declare type SDKResultError = {");
|
|
26
|
+
expect(sdk).toContain("type: 'result';");
|
|
27
|
+
|
|
28
|
+
expect(sdk).toContain("export declare type SDKToolProgressMessage = {");
|
|
29
|
+
expect(sdk).toContain("type: 'tool_progress';");
|
|
30
|
+
|
|
31
|
+
expect(sdk).toContain("export declare type SDKToolUseSummaryMessage = {");
|
|
32
|
+
expect(sdk).toContain("type: 'tool_use_summary';");
|
|
33
|
+
|
|
34
|
+
expect(sdk).toContain("export declare type SDKAuthStatusMessage = {");
|
|
35
|
+
expect(sdk).toContain("type: 'auth_status';");
|
|
36
|
+
|
|
37
|
+
expect(sdk).toContain("export declare type SDKUserMessage = {");
|
|
38
|
+
expect(sdk).toContain("type: 'user';");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("keeps system init/status subtypes expected by ws-bridge", () => {
|
|
42
|
+
const sdk = readSnapshot("server/protocol/claude-upstream/sdk.d.ts.txt");
|
|
43
|
+
|
|
44
|
+
const systemInitBody = extractAliasBody(sdk, "SDKSystemMessage");
|
|
45
|
+
expect(systemInitBody).toContain("type: 'system';");
|
|
46
|
+
expect(systemInitBody).toContain("subtype: 'init';");
|
|
47
|
+
|
|
48
|
+
const systemStatusBody = extractAliasBody(sdk, "SDKStatusMessage");
|
|
49
|
+
expect(systemStatusBody).toContain("type: 'system';");
|
|
50
|
+
expect(systemStatusBody).toContain("subtype: 'status';");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("keeps result and tool fields required by the UI", () => {
|
|
54
|
+
const sdk = readSnapshot("server/protocol/claude-upstream/sdk.d.ts.txt");
|
|
55
|
+
|
|
56
|
+
const resultSuccessBody = extractAliasBody(sdk, "SDKResultSuccess");
|
|
57
|
+
for (const field of ["duration_ms", "num_turns", "total_cost_usd", "stop_reason", "usage", "modelUsage"]) {
|
|
58
|
+
expect(resultSuccessBody).toContain(`${field}:`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const toolProgressBody = extractAliasBody(sdk, "SDKToolProgressMessage");
|
|
62
|
+
for (const field of ["tool_use_id", "tool_name", "elapsed_time_seconds"]) {
|
|
63
|
+
expect(toolProgressBody).toContain(`${field}:`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const userBody = extractAliasBody(sdk, "SDKUserMessage");
|
|
67
|
+
for (const field of ["message", "parent_tool_use_id", "session_id"]) {
|
|
68
|
+
expect(userBody).toContain(`${field}:`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
function readFile(relativePath: string): string {
|
|
6
|
+
return readFileSync(resolve(process.cwd(), relativePath), "utf-8");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function extractCaseMethods(source: string, start: string, end: string): Set<string> {
|
|
10
|
+
const afterStart = source.split(start)[1];
|
|
11
|
+
if (!afterStart) return new Set();
|
|
12
|
+
const block = afterStart.split(end)[0] || "";
|
|
13
|
+
return new Set([...block.matchAll(/case "([^"]+)":/g)].map((m) => m[1]));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function extractTypeLiterals(tsSource: string): Set<string> {
|
|
17
|
+
return new Set([...tsSource.matchAll(/type:\s*'([^']+)'/g)].map((m) => m[1]));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("Claude ws-bridge method drift vs upstream Agent SDK snapshot", () => {
|
|
21
|
+
/**
|
|
22
|
+
* CLI message routing now lives in claude-adapter.ts (ClaudeAdapter.handleRawMessage).
|
|
23
|
+
* This test verifies that the adapter handles all upstream CLI message types.
|
|
24
|
+
*/
|
|
25
|
+
it("keeps handled CLI message types aligned with upstream (or explicit local allowlist)", () => {
|
|
26
|
+
const adapter = readFile("server/claude-adapter.ts");
|
|
27
|
+
const sdk = readFile("server/protocol/claude-upstream/sdk.d.ts.txt");
|
|
28
|
+
|
|
29
|
+
// Extract case "xxx": from the routeCLIMessage switch in claude-adapter.ts
|
|
30
|
+
const handledFromCLI = extractCaseMethods(
|
|
31
|
+
adapter,
|
|
32
|
+
"private routeCLIMessage(msg: CLIMessage): void {",
|
|
33
|
+
"// -- System message handling",
|
|
34
|
+
);
|
|
35
|
+
expect(handledFromCLI.size).toBeGreaterThan(0);
|
|
36
|
+
|
|
37
|
+
const upstreamMessageTypes = extractTypeLiterals(sdk);
|
|
38
|
+
|
|
39
|
+
// Messages we intentionally support in raw CLI transport but are not part of SDKMessage union.
|
|
40
|
+
// - control_request / keep_alive: core transport types not in SDK
|
|
41
|
+
// - user: CLI echoes back user messages (including subagent tool_result blocks)
|
|
42
|
+
// - rate_limit_event: Claude API rate-limit status (allowed/throttled)
|
|
43
|
+
// - control_cancel_request: cancels a pending control_request (v2.1.81+)
|
|
44
|
+
// - streamlined_text / streamlined_tool_use_summary: simplified output mode (@internal, v2.1.81+)
|
|
45
|
+
// - prompt_suggestion: predicted next user prompts (v2.1.81+)
|
|
46
|
+
const localRawTransportTypes = new Set([
|
|
47
|
+
"control_request", "keep_alive", "user", "rate_limit_event",
|
|
48
|
+
"control_cancel_request", "streamlined_text", "streamlined_tool_use_summary", "prompt_suggestion",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
for (const method of handledFromCLI) {
|
|
52
|
+
expect(
|
|
53
|
+
upstreamMessageTypes.has(method) || localRawTransportTypes.has(method),
|
|
54
|
+
`Unhandled by upstream snapshot (CLI message type): ${method}`,
|
|
55
|
+
).toBe(true);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* System subtypes (init, status) are now handled in claude-adapter.ts
|
|
61
|
+
* instead of ws-bridge.ts. This test verifies they are still present.
|
|
62
|
+
*/
|
|
63
|
+
it("keeps system subtypes handled by ws-bridge aligned with upstream", () => {
|
|
64
|
+
const adapter = readFile("server/claude-adapter.ts");
|
|
65
|
+
const sdk = readFile("server/protocol/claude-upstream/sdk.d.ts.txt");
|
|
66
|
+
|
|
67
|
+
const upstreamInit = sdk.includes("export declare type SDKSystemMessage = {")
|
|
68
|
+
&& sdk.includes("subtype: 'init';");
|
|
69
|
+
const upstreamStatus = sdk.includes("export declare type SDKStatusMessage = {")
|
|
70
|
+
&& sdk.includes("subtype: 'status';");
|
|
71
|
+
|
|
72
|
+
expect(upstreamInit).toBe(true);
|
|
73
|
+
expect(upstreamStatus).toBe(true);
|
|
74
|
+
|
|
75
|
+
expect(adapter).toContain('if (msg.subtype === "init")');
|
|
76
|
+
expect(adapter).toContain('if (msg.subtype === "status")');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { discoverClaudeSessions } from "./claude-session-discovery.js";
|
|
6
|
+
|
|
7
|
+
const tempRoots: string[] = [];
|
|
8
|
+
|
|
9
|
+
function createTempProjectsRoot(): string {
|
|
10
|
+
const root = mkdtempSync(join(tmpdir(), "claude-projects-test-"));
|
|
11
|
+
tempRoots.push(root);
|
|
12
|
+
return root;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function writeSessionFile(
|
|
16
|
+
projectsRoot: string,
|
|
17
|
+
projectDirName: string,
|
|
18
|
+
fileName: string,
|
|
19
|
+
payload: {
|
|
20
|
+
sessionId: string;
|
|
21
|
+
cwd: string;
|
|
22
|
+
gitBranch?: string;
|
|
23
|
+
slug?: string;
|
|
24
|
+
},
|
|
25
|
+
mtimeMs: number,
|
|
26
|
+
) {
|
|
27
|
+
const projectDir = join(projectsRoot, projectDirName);
|
|
28
|
+
mkdirSync(projectDir, { recursive: true });
|
|
29
|
+
const filePath = join(projectDir, fileName);
|
|
30
|
+
const content = `${JSON.stringify({ type: "file-history-snapshot" })}\n${JSON.stringify(payload)}\n`;
|
|
31
|
+
writeFileSync(filePath, content, "utf-8");
|
|
32
|
+
const mtime = new Date(mtimeMs);
|
|
33
|
+
utimesSync(filePath, mtime, mtime);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
for (const root of tempRoots.splice(0)) {
|
|
38
|
+
rmSync(root, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("discoverClaudeSessions", () => {
|
|
43
|
+
it("discovers persisted Claude sessions with cwd/branch metadata", () => {
|
|
44
|
+
const root = createTempProjectsRoot();
|
|
45
|
+
// Validate that a normal JSONL session file is parsed into resumable metadata.
|
|
46
|
+
writeSessionFile(
|
|
47
|
+
root,
|
|
48
|
+
"-Users-test-repo",
|
|
49
|
+
"session-a.jsonl",
|
|
50
|
+
{
|
|
51
|
+
sessionId: "session-a",
|
|
52
|
+
cwd: "/Users/test/repo",
|
|
53
|
+
gitBranch: "main",
|
|
54
|
+
slug: "curious-babbage",
|
|
55
|
+
},
|
|
56
|
+
1000,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const sessions = discoverClaudeSessions({ projectsRoot: root, limit: 10 });
|
|
60
|
+
|
|
61
|
+
expect(sessions).toHaveLength(1);
|
|
62
|
+
expect(sessions[0]).toMatchObject({
|
|
63
|
+
sessionId: "session-a",
|
|
64
|
+
cwd: "/Users/test/repo",
|
|
65
|
+
gitBranch: "main",
|
|
66
|
+
slug: "curious-babbage",
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("deduplicates by sessionId and keeps the most recently active record", () => {
|
|
71
|
+
const root = createTempProjectsRoot();
|
|
72
|
+
// Validate that when the same session appears in multiple files, the newest one wins.
|
|
73
|
+
writeSessionFile(
|
|
74
|
+
root,
|
|
75
|
+
"-Users-test-repo",
|
|
76
|
+
"session-a-old.jsonl",
|
|
77
|
+
{
|
|
78
|
+
sessionId: "session-a",
|
|
79
|
+
cwd: "/Users/test/repo",
|
|
80
|
+
gitBranch: "main",
|
|
81
|
+
},
|
|
82
|
+
1000,
|
|
83
|
+
);
|
|
84
|
+
writeSessionFile(
|
|
85
|
+
root,
|
|
86
|
+
"-Users-test-repo",
|
|
87
|
+
"session-a-new.jsonl",
|
|
88
|
+
{
|
|
89
|
+
sessionId: "session-a",
|
|
90
|
+
cwd: "/Users/test/repo",
|
|
91
|
+
gitBranch: "feature/new-ui",
|
|
92
|
+
},
|
|
93
|
+
2000,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const sessions = discoverClaudeSessions({ projectsRoot: root, limit: 10 });
|
|
97
|
+
|
|
98
|
+
expect(sessions).toHaveLength(1);
|
|
99
|
+
expect(sessions[0].gitBranch).toBe("feature/new-ui");
|
|
100
|
+
expect(sessions[0].lastActivityAt).toBe(2000);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("applies the requested limit after sorting by recency", () => {
|
|
104
|
+
const root = createTempProjectsRoot();
|
|
105
|
+
// Validate that callers can bound result size for responsive UI pickers.
|
|
106
|
+
writeSessionFile(
|
|
107
|
+
root,
|
|
108
|
+
"-Users-test-repo",
|
|
109
|
+
"session-1.jsonl",
|
|
110
|
+
{ sessionId: "session-1", cwd: "/Users/test/repo-1" },
|
|
111
|
+
1000,
|
|
112
|
+
);
|
|
113
|
+
writeSessionFile(
|
|
114
|
+
root,
|
|
115
|
+
"-Users-test-repo",
|
|
116
|
+
"session-2.jsonl",
|
|
117
|
+
{ sessionId: "session-2", cwd: "/Users/test/repo-2" },
|
|
118
|
+
2000,
|
|
119
|
+
);
|
|
120
|
+
writeSessionFile(
|
|
121
|
+
root,
|
|
122
|
+
"-Users-test-repo",
|
|
123
|
+
"session-3.jsonl",
|
|
124
|
+
{ sessionId: "session-3", cwd: "/Users/test/repo-3" },
|
|
125
|
+
3000,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const sessions = discoverClaudeSessions({ projectsRoot: root, limit: 2 });
|
|
129
|
+
|
|
130
|
+
expect(sessions.map((s) => s.sessionId)).toEqual(["session-3", "session-2"]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
export interface DiscoveredClaudeSession {
|
|
6
|
+
sessionId: string;
|
|
7
|
+
cwd: string;
|
|
8
|
+
gitBranch?: string;
|
|
9
|
+
slug?: string;
|
|
10
|
+
lastActivityAt: number;
|
|
11
|
+
sourceFile: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DiscoverClaudeSessionsOptions {
|
|
15
|
+
limit?: number;
|
|
16
|
+
projectsRoot?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_DISCOVERY_LIMIT = 200;
|
|
20
|
+
const MAX_DISCOVERY_LIMIT = 1000;
|
|
21
|
+
const METADATA_SCAN_BYTES = 1024 * 1024; // 1 MiB from file head is enough for first metadata records.
|
|
22
|
+
|
|
23
|
+
function extractMetadataFromJsonl(
|
|
24
|
+
filePath: string,
|
|
25
|
+
): Pick<DiscoveredClaudeSession, "sessionId" | "cwd" | "gitBranch" | "slug"> | null {
|
|
26
|
+
let content = "";
|
|
27
|
+
const buffer = Buffer.allocUnsafe(METADATA_SCAN_BYTES);
|
|
28
|
+
let fd: number | null = null;
|
|
29
|
+
try {
|
|
30
|
+
fd = openSync(filePath, "r");
|
|
31
|
+
const bytesRead = readSync(fd, buffer, 0, METADATA_SCAN_BYTES, 0);
|
|
32
|
+
content = buffer.subarray(0, bytesRead).toString("utf-8");
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
} finally {
|
|
36
|
+
if (fd !== null) {
|
|
37
|
+
try {
|
|
38
|
+
closeSync(fd);
|
|
39
|
+
} catch {
|
|
40
|
+
// no-op
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const lines = content.split("\n");
|
|
46
|
+
for (const line of lines) {
|
|
47
|
+
const trimmed = line.trim();
|
|
48
|
+
if (!trimmed.startsWith("{")) continue;
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(trimmed) as {
|
|
51
|
+
sessionId?: unknown;
|
|
52
|
+
cwd?: unknown;
|
|
53
|
+
gitBranch?: unknown;
|
|
54
|
+
slug?: unknown;
|
|
55
|
+
};
|
|
56
|
+
if (typeof parsed.sessionId !== "string" || typeof parsed.cwd !== "string") {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
sessionId: parsed.sessionId,
|
|
61
|
+
cwd: parsed.cwd,
|
|
62
|
+
gitBranch: typeof parsed.gitBranch === "string" ? parsed.gitBranch : undefined,
|
|
63
|
+
slug: typeof parsed.slug === "string" ? parsed.slug : undefined,
|
|
64
|
+
};
|
|
65
|
+
} catch {
|
|
66
|
+
// Ignore malformed/truncated line fragments near chunk boundary.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function discoverClaudeSessions(
|
|
74
|
+
options: DiscoverClaudeSessionsOptions = {},
|
|
75
|
+
): DiscoveredClaudeSession[] {
|
|
76
|
+
const projectsRoot = options.projectsRoot
|
|
77
|
+
|| process.env.CLAUDE_PROJECTS_DIR
|
|
78
|
+
|| join(homedir(), ".claude", "projects");
|
|
79
|
+
const requestedLimit = Number.isFinite(options.limit) ? Number(options.limit) : DEFAULT_DISCOVERY_LIMIT;
|
|
80
|
+
const limit = Math.max(1, Math.min(MAX_DISCOVERY_LIMIT, Math.floor(requestedLimit || DEFAULT_DISCOVERY_LIMIT)));
|
|
81
|
+
|
|
82
|
+
if (!existsSync(projectsRoot)) return [];
|
|
83
|
+
|
|
84
|
+
const sessionFiles: Array<{ filePath: string; mtimeMs: number }> = [];
|
|
85
|
+
let projectDirs: string[] = [];
|
|
86
|
+
try {
|
|
87
|
+
projectDirs = readdirSync(projectsRoot);
|
|
88
|
+
} catch {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const projectDir of projectDirs) {
|
|
93
|
+
const projectPath = join(projectsRoot, projectDir);
|
|
94
|
+
let stats: ReturnType<typeof statSync>;
|
|
95
|
+
try {
|
|
96
|
+
stats = statSync(projectPath);
|
|
97
|
+
} catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (!stats.isDirectory()) continue;
|
|
101
|
+
|
|
102
|
+
let entries: string[] = [];
|
|
103
|
+
try {
|
|
104
|
+
entries = readdirSync(projectPath);
|
|
105
|
+
} catch {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
if (!entry.endsWith(".jsonl")) continue;
|
|
111
|
+
const filePath = join(projectPath, entry);
|
|
112
|
+
try {
|
|
113
|
+
const fileStats = statSync(filePath);
|
|
114
|
+
if (!fileStats.isFile()) continue;
|
|
115
|
+
sessionFiles.push({
|
|
116
|
+
filePath,
|
|
117
|
+
mtimeMs: fileStats.mtimeMs,
|
|
118
|
+
});
|
|
119
|
+
} catch {
|
|
120
|
+
// Skip deleted/corrupt entries.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
sessionFiles.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
126
|
+
|
|
127
|
+
const uniqueBySessionId = new Map<string, DiscoveredClaudeSession>();
|
|
128
|
+
for (const candidate of sessionFiles) {
|
|
129
|
+
if (uniqueBySessionId.size >= limit) break;
|
|
130
|
+
|
|
131
|
+
const metadata = extractMetadataFromJsonl(candidate.filePath);
|
|
132
|
+
if (!metadata) continue;
|
|
133
|
+
|
|
134
|
+
const prev = uniqueBySessionId.get(metadata.sessionId);
|
|
135
|
+
if (prev && prev.lastActivityAt >= candidate.mtimeMs) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
uniqueBySessionId.set(metadata.sessionId, {
|
|
140
|
+
sessionId: metadata.sessionId,
|
|
141
|
+
cwd: metadata.cwd,
|
|
142
|
+
gitBranch: metadata.gitBranch,
|
|
143
|
+
slug: metadata.slug,
|
|
144
|
+
lastActivityAt: candidate.mtimeMs,
|
|
145
|
+
sourceFile: candidate.filePath,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return Array.from(uniqueBySessionId.values())
|
|
150
|
+
.sort((a, b) => b.lastActivityAt - a.lastActivityAt)
|
|
151
|
+
.slice(0, limit)
|
|
152
|
+
.map((session) => ({
|
|
153
|
+
...session,
|
|
154
|
+
// Defensive fallback if older records don't carry sessionId in JSONL.
|
|
155
|
+
sessionId: session.sessionId || basename(session.sourceFile, ".jsonl"),
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import {
|
|
6
|
+
clearClaudeSessionHistoryCacheForTests,
|
|
7
|
+
getClaudeSessionHistoryPage,
|
|
8
|
+
} from "./claude-session-history.js";
|
|
9
|
+
|
|
10
|
+
const tempRoots: string[] = [];
|
|
11
|
+
|
|
12
|
+
function createTempProjectsRoot(): string {
|
|
13
|
+
const root = mkdtempSync(join(tmpdir(), "claude-history-test-"));
|
|
14
|
+
tempRoots.push(root);
|
|
15
|
+
return root;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function writeSessionHistoryFile(
|
|
19
|
+
projectsRoot: string,
|
|
20
|
+
projectDirName: string,
|
|
21
|
+
sessionId: string,
|
|
22
|
+
lines: Array<Record<string, unknown>>,
|
|
23
|
+
) {
|
|
24
|
+
const projectDir = join(projectsRoot, projectDirName);
|
|
25
|
+
mkdirSync(projectDir, { recursive: true });
|
|
26
|
+
const filePath = join(projectDir, `${sessionId}.jsonl`);
|
|
27
|
+
const content = lines.map((line) => JSON.stringify(line)).join("\n");
|
|
28
|
+
writeFileSync(filePath, content, "utf-8");
|
|
29
|
+
return filePath;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
clearClaudeSessionHistoryCacheForTests();
|
|
34
|
+
for (const root of tempRoots.splice(0)) {
|
|
35
|
+
rmSync(root, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("getClaudeSessionHistoryPage", () => {
|
|
40
|
+
it("paginates chronologically while deduping assistant updates by message id", () => {
|
|
41
|
+
// Validate the loader merges repeated assistant updates into one message
|
|
42
|
+
// and returns cursor pages from newest backwards.
|
|
43
|
+
const root = createTempProjectsRoot();
|
|
44
|
+
const sessionId = "session-a";
|
|
45
|
+
writeSessionHistoryFile(root, "-Users-test-repo", sessionId, [
|
|
46
|
+
{ type: "file-history-snapshot" },
|
|
47
|
+
{
|
|
48
|
+
type: "user",
|
|
49
|
+
sessionId,
|
|
50
|
+
uuid: "u-1",
|
|
51
|
+
timestamp: "2026-02-20T10:00:00.000Z",
|
|
52
|
+
message: { role: "user", content: "First prompt" },
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
type: "assistant",
|
|
56
|
+
sessionId,
|
|
57
|
+
uuid: "a-1-1",
|
|
58
|
+
timestamp: "2026-02-20T10:00:01.000Z",
|
|
59
|
+
message: {
|
|
60
|
+
role: "assistant",
|
|
61
|
+
id: "assistant-1",
|
|
62
|
+
model: "claude-opus",
|
|
63
|
+
stop_reason: null,
|
|
64
|
+
content: [{ type: "thinking", thinking: "Thinking..." }],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
type: "assistant",
|
|
69
|
+
sessionId,
|
|
70
|
+
uuid: "a-1-2",
|
|
71
|
+
timestamp: "2026-02-20T10:00:02.000Z",
|
|
72
|
+
message: {
|
|
73
|
+
role: "assistant",
|
|
74
|
+
id: "assistant-1",
|
|
75
|
+
model: "claude-opus",
|
|
76
|
+
stop_reason: "end_turn",
|
|
77
|
+
content: [{ type: "text", text: "Merged final answer." }],
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
type: "user",
|
|
82
|
+
sessionId,
|
|
83
|
+
uuid: "u-meta",
|
|
84
|
+
isMeta: true,
|
|
85
|
+
timestamp: "2026-02-20T10:00:03.000Z",
|
|
86
|
+
message: { role: "user", content: "<command-name>/exit</command-name>" },
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
type: "user",
|
|
90
|
+
sessionId,
|
|
91
|
+
uuid: "u-tool-result",
|
|
92
|
+
timestamp: "2026-02-20T10:00:04.000Z",
|
|
93
|
+
message: {
|
|
94
|
+
role: "user",
|
|
95
|
+
content: [{ type: "tool_result", tool_use_id: "tu-1", content: "tool output" }],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
type: "user",
|
|
100
|
+
sessionId,
|
|
101
|
+
uuid: "u-2",
|
|
102
|
+
timestamp: "2026-02-20T10:00:05.000Z",
|
|
103
|
+
message: { role: "user", content: "Second prompt" },
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
type: "assistant",
|
|
107
|
+
sessionId,
|
|
108
|
+
uuid: "a-2",
|
|
109
|
+
timestamp: "2026-02-20T10:00:06.000Z",
|
|
110
|
+
message: {
|
|
111
|
+
role: "assistant",
|
|
112
|
+
id: "assistant-2",
|
|
113
|
+
model: "claude-opus",
|
|
114
|
+
stop_reason: "tool_use",
|
|
115
|
+
content: [{ type: "tool_use", id: "tool-1", name: "Read", input: { file_path: "README.md" } }],
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
const latestPage = getClaudeSessionHistoryPage({
|
|
121
|
+
sessionId,
|
|
122
|
+
projectsRoot: root,
|
|
123
|
+
limit: 2,
|
|
124
|
+
cursor: 0,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(latestPage).not.toBeNull();
|
|
128
|
+
expect(latestPage?.totalMessages).toBe(4);
|
|
129
|
+
expect(latestPage?.hasMore).toBe(true);
|
|
130
|
+
expect(latestPage?.nextCursor).toBe(2);
|
|
131
|
+
expect(latestPage?.messages.map((msg) => msg.role)).toEqual(["user", "assistant"]);
|
|
132
|
+
expect(latestPage?.messages[0].content).toBe("Second prompt");
|
|
133
|
+
|
|
134
|
+
const olderPage = getClaudeSessionHistoryPage({
|
|
135
|
+
sessionId,
|
|
136
|
+
projectsRoot: root,
|
|
137
|
+
limit: 2,
|
|
138
|
+
cursor: latestPage?.nextCursor,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(olderPage).not.toBeNull();
|
|
142
|
+
expect(olderPage?.hasMore).toBe(false);
|
|
143
|
+
expect(olderPage?.messages.map((msg) => msg.role)).toEqual(["user", "assistant"]);
|
|
144
|
+
expect(olderPage?.messages[1].content).toContain("Merged final answer.");
|
|
145
|
+
expect(olderPage?.messages[1].content).toContain("Thinking...");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("returns null when the session file cannot be found", () => {
|
|
149
|
+
// Validate a clean null response for unknown sessions so routes can return 404.
|
|
150
|
+
const root = createTempProjectsRoot();
|
|
151
|
+
const page = getClaudeSessionHistoryPage({
|
|
152
|
+
sessionId: "missing-session",
|
|
153
|
+
projectsRoot: root,
|
|
154
|
+
limit: 10,
|
|
155
|
+
});
|
|
156
|
+
expect(page).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
});
|