@snoglobe/helios 0.3.6 → 0.4.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/dist/__tests__/db-helper.d.ts +8 -0
- package/dist/__tests__/db-helper.d.ts.map +1 -0
- package/dist/__tests__/db-helper.js +15 -0
- package/dist/__tests__/db-helper.js.map +1 -0
- package/dist/config/project.test.d.ts +2 -0
- package/dist/config/project.test.d.ts.map +1 -0
- package/dist/config/project.test.js +219 -0
- package/dist/config/project.test.js.map +1 -0
- package/dist/core/orchestrator-integration.test.d.ts +2 -0
- package/dist/core/orchestrator-integration.test.d.ts.map +1 -0
- package/dist/core/orchestrator-integration.test.js +674 -0
- package/dist/core/orchestrator-integration.test.js.map +1 -0
- package/dist/core/orchestrator.d.ts +1 -0
- package/dist/core/orchestrator.d.ts.map +1 -1
- package/dist/core/orchestrator.js +81 -37
- package/dist/core/orchestrator.js.map +1 -1
- package/dist/core/orchestrator.test.d.ts +2 -0
- package/dist/core/orchestrator.test.d.ts.map +1 -0
- package/dist/core/orchestrator.test.js +982 -0
- package/dist/core/orchestrator.test.js.map +1 -0
- package/dist/core/state-machine.d.ts.map +1 -1
- package/dist/core/state-machine.js +3 -0
- package/dist/core/state-machine.js.map +1 -1
- package/dist/core/state-machine.test.d.ts +2 -0
- package/dist/core/state-machine.test.d.ts.map +1 -0
- package/dist/core/state-machine.test.js +95 -0
- package/dist/core/state-machine.test.js.map +1 -0
- package/dist/core/stickies.d.ts.map +1 -1
- package/dist/core/stickies.js +3 -0
- package/dist/core/stickies.js.map +1 -1
- package/dist/core/stickies.test.d.ts +2 -0
- package/dist/core/stickies.test.d.ts.map +1 -0
- package/dist/core/stickies.test.js +63 -0
- package/dist/core/stickies.test.js.map +1 -0
- package/dist/core/task-poller.d.ts.map +1 -1
- package/dist/core/task-poller.js +2 -1
- package/dist/core/task-poller.js.map +1 -1
- package/dist/init.d.ts +2 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +19 -0
- package/dist/init.js.map +1 -1
- package/dist/memory/context-gate.test.d.ts +2 -0
- package/dist/memory/context-gate.test.d.ts.map +1 -0
- package/dist/memory/context-gate.test.js +318 -0
- package/dist/memory/context-gate.test.js.map +1 -0
- package/dist/memory/experiment-tracker.test.d.ts +2 -0
- package/dist/memory/experiment-tracker.test.d.ts.map +1 -0
- package/dist/memory/experiment-tracker.test.js +325 -0
- package/dist/memory/experiment-tracker.test.js.map +1 -0
- package/dist/memory/memory-store.d.ts +2 -0
- package/dist/memory/memory-store.d.ts.map +1 -1
- package/dist/memory/memory-store.js +35 -28
- package/dist/memory/memory-store.js.map +1 -1
- package/dist/memory/memory-store.test.d.ts +2 -0
- package/dist/memory/memory-store.test.d.ts.map +1 -0
- package/dist/memory/memory-store.test.js +144 -0
- package/dist/memory/memory-store.test.js.map +1 -0
- package/dist/memory/token-estimator.test.d.ts +2 -0
- package/dist/memory/token-estimator.test.d.ts.map +1 -0
- package/dist/memory/token-estimator.test.js +42 -0
- package/dist/memory/token-estimator.test.js.map +1 -0
- package/dist/metrics/analyzer.test.d.ts +2 -0
- package/dist/metrics/analyzer.test.d.ts.map +1 -0
- package/dist/metrics/analyzer.test.js +65 -0
- package/dist/metrics/analyzer.test.js.map +1 -0
- package/dist/metrics/parser.test.d.ts +2 -0
- package/dist/metrics/parser.test.d.ts.map +1 -0
- package/dist/metrics/parser.test.js +106 -0
- package/dist/metrics/parser.test.js.map +1 -0
- package/dist/metrics/store.d.ts +2 -0
- package/dist/metrics/store.d.ts.map +1 -1
- package/dist/metrics/store.js +26 -50
- package/dist/metrics/store.js.map +1 -1
- package/dist/metrics/store.test.d.ts +2 -0
- package/dist/metrics/store.test.d.ts.map +1 -0
- package/dist/metrics/store.test.js +148 -0
- package/dist/metrics/store.test.js.map +1 -0
- package/dist/paths.js +5 -5
- package/dist/paths.js.map +1 -1
- package/dist/providers/auth/auth-manager.d.ts +1 -0
- package/dist/providers/auth/auth-manager.d.ts.map +1 -1
- package/dist/providers/auth/auth-manager.js +8 -1
- package/dist/providers/auth/auth-manager.js.map +1 -1
- package/dist/providers/auth/auth-manager.test.d.ts +2 -0
- package/dist/providers/auth/auth-manager.test.d.ts.map +1 -0
- package/dist/providers/auth/auth-manager.test.js +364 -0
- package/dist/providers/auth/auth-manager.test.js.map +1 -0
- package/dist/providers/auth/token-store.js +2 -2
- package/dist/providers/auth/token-store.js.map +1 -1
- package/dist/providers/claude/history.test.d.ts +2 -0
- package/dist/providers/claude/history.test.d.ts.map +1 -0
- package/dist/providers/claude/history.test.js +755 -0
- package/dist/providers/claude/history.test.js.map +1 -0
- package/dist/providers/claude/provider.d.ts +3 -1
- package/dist/providers/claude/provider.d.ts.map +1 -1
- package/dist/providers/claude/provider.js +33 -19
- package/dist/providers/claude/provider.js.map +1 -1
- package/dist/providers/claude/provider.test.d.ts +2 -0
- package/dist/providers/claude/provider.test.d.ts.map +1 -0
- package/dist/providers/claude/provider.test.js +1167 -0
- package/dist/providers/claude/provider.test.js.map +1 -0
- package/dist/providers/openai/history.test.d.ts +2 -0
- package/dist/providers/openai/history.test.d.ts.map +1 -0
- package/dist/providers/openai/history.test.js +655 -0
- package/dist/providers/openai/history.test.js.map +1 -0
- package/dist/providers/openai/provider.d.ts +2 -1
- package/dist/providers/openai/provider.d.ts.map +1 -1
- package/dist/providers/openai/provider.js +14 -8
- package/dist/providers/openai/provider.js.map +1 -1
- package/dist/providers/openai/provider.test.d.ts +2 -0
- package/dist/providers/openai/provider.test.d.ts.map +1 -0
- package/dist/providers/openai/provider.test.js +1089 -0
- package/dist/providers/openai/provider.test.js.map +1 -0
- package/dist/providers/retry.test.d.ts +2 -0
- package/dist/providers/retry.test.d.ts.map +1 -0
- package/dist/providers/retry.test.js +194 -0
- package/dist/providers/retry.test.js.map +1 -0
- package/dist/providers/sse.d.ts +1 -0
- package/dist/providers/sse.d.ts.map +1 -1
- package/dist/providers/sse.js +29 -20
- package/dist/providers/sse.js.map +1 -1
- package/dist/providers/sse.test.d.ts +2 -0
- package/dist/providers/sse.test.d.ts.map +1 -0
- package/dist/providers/sse.test.js +79 -0
- package/dist/providers/sse.test.js.map +1 -0
- package/dist/providers/types.test.d.ts +2 -0
- package/dist/providers/types.test.d.ts.map +1 -0
- package/dist/providers/types.test.js +112 -0
- package/dist/providers/types.test.js.map +1 -0
- package/dist/remote/connection-pool.d.ts.map +1 -1
- package/dist/remote/connection-pool.js +24 -3
- package/dist/remote/connection-pool.js.map +1 -1
- package/dist/remote/file-sync.d.ts.map +1 -1
- package/dist/remote/file-sync.js +4 -3
- package/dist/remote/file-sync.js.map +1 -1
- package/dist/scheduler/sleep-manager.d.ts.map +1 -1
- package/dist/scheduler/sleep-manager.js +8 -1
- package/dist/scheduler/sleep-manager.js.map +1 -1
- package/dist/scheduler/sleep-manager.test.d.ts +2 -0
- package/dist/scheduler/sleep-manager.test.d.ts.map +1 -0
- package/dist/scheduler/sleep-manager.test.js +491 -0
- package/dist/scheduler/sleep-manager.test.js.map +1 -0
- package/dist/scheduler/ssh-batcher.d.ts.map +1 -1
- package/dist/scheduler/ssh-batcher.js +6 -4
- package/dist/scheduler/ssh-batcher.js.map +1 -1
- package/dist/scheduler/ssh-batcher.test.d.ts +2 -0
- package/dist/scheduler/ssh-batcher.test.d.ts.map +1 -0
- package/dist/scheduler/ssh-batcher.test.js +76 -0
- package/dist/scheduler/ssh-batcher.test.js.map +1 -0
- package/dist/scheduler/state-store.d.ts.map +1 -1
- package/dist/scheduler/state-store.js +1 -2
- package/dist/scheduler/state-store.js.map +1 -1
- package/dist/scheduler/trigger-scheduler.d.ts.map +1 -1
- package/dist/scheduler/trigger-scheduler.js +59 -36
- package/dist/scheduler/trigger-scheduler.js.map +1 -1
- package/dist/scheduler/trigger-scheduler.test.d.ts +2 -0
- package/dist/scheduler/trigger-scheduler.test.d.ts.map +1 -0
- package/dist/scheduler/trigger-scheduler.test.js +483 -0
- package/dist/scheduler/trigger-scheduler.test.js.map +1 -0
- package/dist/scheduler/triggers/file.d.ts.map +1 -1
- package/dist/scheduler/triggers/file.js +12 -3
- package/dist/scheduler/triggers/file.js.map +1 -1
- package/dist/scheduler/triggers/file.test.d.ts +2 -0
- package/dist/scheduler/triggers/file.test.d.ts.map +1 -0
- package/dist/scheduler/triggers/file.test.js +294 -0
- package/dist/scheduler/triggers/file.test.js.map +1 -0
- package/dist/scheduler/triggers/metric.d.ts +3 -1
- package/dist/scheduler/triggers/metric.d.ts.map +1 -1
- package/dist/scheduler/triggers/metric.js +12 -8
- package/dist/scheduler/triggers/metric.js.map +1 -1
- package/dist/scheduler/triggers/metric.test.d.ts +2 -0
- package/dist/scheduler/triggers/metric.test.d.ts.map +1 -0
- package/dist/scheduler/triggers/metric.test.js +533 -0
- package/dist/scheduler/triggers/metric.test.js.map +1 -0
- package/dist/scheduler/triggers/process-exit.d.ts.map +1 -1
- package/dist/scheduler/triggers/process-exit.js +2 -1
- package/dist/scheduler/triggers/process-exit.js.map +1 -1
- package/dist/scheduler/triggers/process-exit.test.d.ts +2 -0
- package/dist/scheduler/triggers/process-exit.test.d.ts.map +1 -0
- package/dist/scheduler/triggers/process-exit.test.js +118 -0
- package/dist/scheduler/triggers/process-exit.test.js.map +1 -0
- package/dist/scheduler/triggers/resource.d.ts.map +1 -1
- package/dist/scheduler/triggers/resource.js +2 -10
- package/dist/scheduler/triggers/resource.js.map +1 -1
- package/dist/scheduler/triggers/resource.test.d.ts +2 -0
- package/dist/scheduler/triggers/resource.test.d.ts.map +1 -0
- package/dist/scheduler/triggers/resource.test.js +225 -0
- package/dist/scheduler/triggers/resource.test.js.map +1 -0
- package/dist/scheduler/triggers/timer.test.d.ts +2 -0
- package/dist/scheduler/triggers/timer.test.d.ts.map +1 -0
- package/dist/scheduler/triggers/timer.test.js +56 -0
- package/dist/scheduler/triggers/timer.test.js.map +1 -0
- package/dist/scheduler/triggers/types.d.ts +4 -2
- package/dist/scheduler/triggers/types.d.ts.map +1 -1
- package/dist/scheduler/triggers/types.js +0 -1
- package/dist/scheduler/triggers/types.js.map +1 -1
- package/dist/skills/executor.d.ts.map +1 -1
- package/dist/skills/executor.js +13 -15
- package/dist/skills/executor.js.map +1 -1
- package/dist/store/database.d.ts +5 -0
- package/dist/store/database.d.ts.map +1 -1
- package/dist/store/database.js +17 -1
- package/dist/store/database.js.map +1 -1
- package/dist/store/migrations.test.d.ts +2 -0
- package/dist/store/migrations.test.d.ts.map +1 -0
- package/dist/store/migrations.test.js +278 -0
- package/dist/store/migrations.test.js.map +1 -0
- package/dist/store/session-store-edge.test.d.ts +2 -0
- package/dist/store/session-store-edge.test.d.ts.map +1 -0
- package/dist/store/session-store-edge.test.js +522 -0
- package/dist/store/session-store-edge.test.js.map +1 -0
- package/dist/store/session-store.d.ts +6 -0
- package/dist/store/session-store.d.ts.map +1 -1
- package/dist/store/session-store.js +25 -24
- package/dist/store/session-store.js.map +1 -1
- package/dist/store/session-store.test.d.ts +2 -0
- package/dist/store/session-store.test.d.ts.map +1 -0
- package/dist/store/session-store.test.js +125 -0
- package/dist/store/session-store.test.js.map +1 -0
- package/dist/subagent/executor.d.ts +21 -0
- package/dist/subagent/executor.d.ts.map +1 -0
- package/dist/subagent/executor.js +136 -0
- package/dist/subagent/executor.js.map +1 -0
- package/dist/subagent/manager.d.ts +20 -0
- package/dist/subagent/manager.d.ts.map +1 -0
- package/dist/subagent/manager.js +98 -0
- package/dist/subagent/manager.js.map +1 -0
- package/dist/subagent/scoped-memory.d.ts +28 -0
- package/dist/subagent/scoped-memory.d.ts.map +1 -0
- package/dist/subagent/scoped-memory.js +122 -0
- package/dist/subagent/scoped-memory.js.map +1 -0
- package/dist/subagent/types.d.ts +27 -0
- package/dist/subagent/types.d.ts.map +1 -0
- package/dist/subagent/types.js +2 -0
- package/dist/subagent/types.js.map +1 -0
- package/dist/tools/file-ops.d.ts.map +1 -1
- package/dist/tools/file-ops.js +23 -13
- package/dist/tools/file-ops.js.map +1 -1
- package/dist/tools/file-ops.test.d.ts +2 -0
- package/dist/tools/file-ops.test.d.ts.map +1 -0
- package/dist/tools/file-ops.test.js +656 -0
- package/dist/tools/file-ops.test.js.map +1 -0
- package/dist/tools/memory-tools.test.d.ts +2 -0
- package/dist/tools/memory-tools.test.d.ts.map +1 -0
- package/dist/tools/memory-tools.test.js +273 -0
- package/dist/tools/memory-tools.test.js.map +1 -0
- package/dist/tools/subagent.d.ts +10 -0
- package/dist/tools/subagent.d.ts.map +1 -0
- package/dist/tools/subagent.js +164 -0
- package/dist/tools/subagent.js.map +1 -0
- package/dist/tools/task-output.d.ts.map +1 -1
- package/dist/tools/task-output.js +13 -2
- package/dist/tools/task-output.js.map +1 -1
- package/dist/ui/components/input-bar.d.ts +1 -1
- package/dist/ui/components/input-bar.d.ts.map +1 -1
- package/dist/ui/components/input-bar.js +3 -3
- package/dist/ui/components/input-bar.js.map +1 -1
- package/dist/ui/components/input-bar.test.d.ts +2 -0
- package/dist/ui/components/input-bar.test.d.ts.map +1 -0
- package/dist/ui/components/input-bar.test.js +380 -0
- package/dist/ui/components/input-bar.test.js.map +1 -0
- package/dist/ui/components/key-hint-rule.d.ts +2 -1
- package/dist/ui/components/key-hint-rule.d.ts.map +1 -1
- package/dist/ui/components/key-hint-rule.js +3 -3
- package/dist/ui/components/key-hint-rule.js.map +1 -1
- package/dist/ui/components/status-bar.d.ts +1 -1
- package/dist/ui/components/status-bar.d.ts.map +1 -1
- package/dist/ui/components/status-bar.js +11 -10
- package/dist/ui/components/status-bar.js.map +1 -1
- package/dist/ui/components/status-bar.test.d.ts +2 -0
- package/dist/ui/components/status-bar.test.d.ts.map +1 -0
- package/dist/ui/components/status-bar.test.js +206 -0
- package/dist/ui/components/status-bar.test.js.map +1 -0
- package/dist/ui/format.d.ts +9 -0
- package/dist/ui/format.d.ts.map +1 -1
- package/dist/ui/format.js +21 -0
- package/dist/ui/format.js.map +1 -1
- package/dist/ui/format.test.d.ts +2 -0
- package/dist/ui/format.test.d.ts.map +1 -0
- package/dist/ui/format.test.js +122 -0
- package/dist/ui/format.test.js.map +1 -0
- package/dist/ui/layout.d.ts.map +1 -1
- package/dist/ui/layout.js +63 -11
- package/dist/ui/layout.js.map +1 -1
- package/dist/ui/markdown.test.d.ts +2 -0
- package/dist/ui/markdown.test.d.ts.map +1 -0
- package/dist/ui/markdown.test.js +133 -0
- package/dist/ui/markdown.test.js.map +1 -0
- package/dist/ui/mouse-filter.test.d.ts +2 -0
- package/dist/ui/mouse-filter.test.d.ts.map +1 -0
- package/dist/ui/mouse-filter.test.js +231 -0
- package/dist/ui/mouse-filter.test.js.map +1 -0
- package/dist/ui/overlays/metrics-overlay.test.d.ts +2 -0
- package/dist/ui/overlays/metrics-overlay.test.d.ts.map +1 -0
- package/dist/ui/overlays/metrics-overlay.test.js +248 -0
- package/dist/ui/overlays/metrics-overlay.test.js.map +1 -0
- package/dist/ui/overlays/task-overlay.test.d.ts +2 -0
- package/dist/ui/overlays/task-overlay.test.d.ts.map +1 -0
- package/dist/ui/overlays/task-overlay.test.js +238 -0
- package/dist/ui/overlays/task-overlay.test.js.map +1 -0
- package/dist/ui/panels/conversation.d.ts.map +1 -1
- package/dist/ui/panels/conversation.js +60 -5
- package/dist/ui/panels/conversation.js.map +1 -1
- package/dist/ui/panels/conversation.test.d.ts +2 -0
- package/dist/ui/panels/conversation.test.d.ts.map +1 -0
- package/dist/ui/panels/conversation.test.js +381 -0
- package/dist/ui/panels/conversation.test.js.map +1 -0
- package/dist/ui/panels/metrics-dashboard.test.d.ts +2 -0
- package/dist/ui/panels/metrics-dashboard.test.d.ts.map +1 -0
- package/dist/ui/panels/metrics-dashboard.test.js +191 -0
- package/dist/ui/panels/metrics-dashboard.test.js.map +1 -0
- package/dist/ui/panels/sleep-panel.test.d.ts +2 -0
- package/dist/ui/panels/sleep-panel.test.d.ts.map +1 -0
- package/dist/ui/panels/sleep-panel.test.js +376 -0
- package/dist/ui/panels/sleep-panel.test.js.map +1 -0
- package/dist/ui/panels/task-list.d.ts.map +1 -1
- package/dist/ui/panels/task-list.js +3 -2
- package/dist/ui/panels/task-list.js.map +1 -1
- package/dist/ui/panels/task-list.test.d.ts +2 -0
- package/dist/ui/panels/task-list.test.d.ts.map +1 -0
- package/dist/ui/panels/task-list.test.js +210 -0
- package/dist/ui/panels/task-list.test.js.map +1 -0
- package/dist/ui/theme.test.d.ts +2 -0
- package/dist/ui/theme.test.d.ts.map +1 -0
- package/dist/ui/theme.test.js +159 -0
- package/dist/ui/theme.test.js.map +1 -0
- package/dist/ui/types.d.ts +3 -0
- package/dist/ui/types.d.ts.map +1 -1
- package/package.json +6 -2
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
|
2
|
+
import { createTestDb } from "../../__tests__/db-helper.js";
|
|
3
|
+
// ─── Mocks ───────────────────────────────────────────
|
|
4
|
+
const mockDb = { current: createTestDb() };
|
|
5
|
+
vi.mock("../../store/database.js", () => {
|
|
6
|
+
const getDb = () => mockDb.current;
|
|
7
|
+
class StmtCache {
|
|
8
|
+
cache = new Map();
|
|
9
|
+
stmt(sql) {
|
|
10
|
+
let s = this.cache.get(sql);
|
|
11
|
+
if (!s) {
|
|
12
|
+
s = getDb().prepare(sql);
|
|
13
|
+
this.cache.set(sql, s);
|
|
14
|
+
}
|
|
15
|
+
return s;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return { getDb, StmtCache, getHeliosDir: () => "/tmp/helios-test" };
|
|
19
|
+
});
|
|
20
|
+
vi.mock("node:child_process", () => ({
|
|
21
|
+
execSync: vi.fn(() => {
|
|
22
|
+
throw new Error("not found");
|
|
23
|
+
}),
|
|
24
|
+
}));
|
|
25
|
+
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
26
|
+
query: vi.fn(),
|
|
27
|
+
createSdkMcpServer: vi.fn(),
|
|
28
|
+
tool: vi.fn(),
|
|
29
|
+
}));
|
|
30
|
+
vi.mock("../../paths.js", () => ({
|
|
31
|
+
WEB_SEARCH_TOOL: "web_search",
|
|
32
|
+
debugLog: vi.fn(),
|
|
33
|
+
}));
|
|
34
|
+
// ─── Imports (dynamic because of mocks) ──────────────
|
|
35
|
+
const { SessionStore } = await import("../../store/session-store.js");
|
|
36
|
+
const { ClaudeProvider } = await import("./provider.js");
|
|
37
|
+
const { CHECKPOINT_ACK } = await import("../types.js");
|
|
38
|
+
// ─── Helpers ─────────────────────────────────────────
|
|
39
|
+
function mockAuthManager(creds = { method: "api_key", provider: "claude", apiKey: "sk-test" }) {
|
|
40
|
+
return {
|
|
41
|
+
getCredentials: vi.fn().mockResolvedValue(creds),
|
|
42
|
+
setApiKey: vi.fn(),
|
|
43
|
+
setOAuthTokens: vi.fn(),
|
|
44
|
+
isAuthenticated: vi.fn().mockReturnValue(true),
|
|
45
|
+
tokenStore: {
|
|
46
|
+
isExpired: vi.fn().mockReturnValue(false),
|
|
47
|
+
needsRefresh: vi.fn().mockReturnValue(false),
|
|
48
|
+
},
|
|
49
|
+
registerRefreshHandler: vi.fn(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function mockSSEResponse(events, status = 200) {
|
|
53
|
+
const encoder = new TextEncoder();
|
|
54
|
+
const body = events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("");
|
|
55
|
+
const stream = new ReadableStream({
|
|
56
|
+
start(controller) {
|
|
57
|
+
controller.enqueue(encoder.encode(body));
|
|
58
|
+
controller.close();
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
return new Response(stream, {
|
|
62
|
+
status,
|
|
63
|
+
headers: { "content-type": "text/event-stream" },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function textSSEResponse(text, usage = { input_tokens: 10, output_tokens: 5 }) {
|
|
67
|
+
return mockSSEResponse([
|
|
68
|
+
{ type: "message_start", message: { usage: { input_tokens: usage.input_tokens } } },
|
|
69
|
+
{ type: "content_block_start", index: 0, content_block: { type: "text" } },
|
|
70
|
+
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text } },
|
|
71
|
+
{ type: "content_block_stop", index: 0 },
|
|
72
|
+
{ type: "message_delta", usage: { output_tokens: usage.output_tokens } },
|
|
73
|
+
{ type: "message_stop" },
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
76
|
+
function toolCallSSEResponse(toolCalls, text = "") {
|
|
77
|
+
const events = [
|
|
78
|
+
{ type: "message_start", message: { usage: { input_tokens: 10 } } },
|
|
79
|
+
];
|
|
80
|
+
let idx = 0;
|
|
81
|
+
if (text) {
|
|
82
|
+
events.push({ type: "content_block_start", index: idx, content_block: { type: "text" } }, { type: "content_block_delta", index: idx, delta: { type: "text_delta", text } }, { type: "content_block_stop", index: idx });
|
|
83
|
+
idx++;
|
|
84
|
+
}
|
|
85
|
+
for (const tc of toolCalls) {
|
|
86
|
+
events.push({ type: "content_block_start", index: idx, content_block: { type: "tool_use", id: tc.id, name: tc.name } }, { type: "content_block_delta", index: idx, delta: { type: "input_json_delta", partial_json: JSON.stringify(tc.args) } }, { type: "content_block_stop", index: idx });
|
|
87
|
+
idx++;
|
|
88
|
+
}
|
|
89
|
+
events.push({ type: "message_delta", usage: { output_tokens: 20 } }, { type: "message_stop" });
|
|
90
|
+
return mockSSEResponse(events);
|
|
91
|
+
}
|
|
92
|
+
function serverToolSSEResponse(serverId, serverName, text = "Result") {
|
|
93
|
+
return mockSSEResponse([
|
|
94
|
+
{ type: "message_start", message: { usage: { input_tokens: 10 } } },
|
|
95
|
+
{ type: "content_block_start", index: 0, content_block: { type: "server_tool_use", id: serverId, name: serverName } },
|
|
96
|
+
{ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: "{}" } },
|
|
97
|
+
{ type: "content_block_stop", index: 0 },
|
|
98
|
+
{ type: "content_block_start", index: 1, content_block: { type: "web_search_tool_result" } },
|
|
99
|
+
{ type: "content_block_stop", index: 1 },
|
|
100
|
+
{ type: "content_block_start", index: 2, content_block: { type: "text" } },
|
|
101
|
+
{ type: "content_block_delta", index: 2, delta: { type: "text_delta", text } },
|
|
102
|
+
{ type: "content_block_stop", index: 2 },
|
|
103
|
+
{ type: "message_delta", usage: { output_tokens: 15 } },
|
|
104
|
+
{ type: "message_stop" },
|
|
105
|
+
]);
|
|
106
|
+
}
|
|
107
|
+
function emptyTextSSEResponse() {
|
|
108
|
+
return mockSSEResponse([
|
|
109
|
+
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
|
110
|
+
{ type: "message_delta", usage: { output_tokens: 0 } },
|
|
111
|
+
{ type: "message_stop" },
|
|
112
|
+
]);
|
|
113
|
+
}
|
|
114
|
+
async function collect(gen) {
|
|
115
|
+
const events = [];
|
|
116
|
+
for await (const e of gen)
|
|
117
|
+
events.push(e);
|
|
118
|
+
return events;
|
|
119
|
+
}
|
|
120
|
+
function makeTool(name, exec) {
|
|
121
|
+
return {
|
|
122
|
+
name,
|
|
123
|
+
description: `Test tool ${name}`,
|
|
124
|
+
parameters: { type: "object", properties: { input: { type: "string" } }, required: ["input"] },
|
|
125
|
+
execute: exec ?? vi.fn().mockResolvedValue("tool-result"),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
// ─── Tests ───────────────────────────────────────────
|
|
129
|
+
describe("ClaudeProvider — History Deep Edge Cases", () => {
|
|
130
|
+
let store;
|
|
131
|
+
let auth;
|
|
132
|
+
let provider;
|
|
133
|
+
let mockFetch;
|
|
134
|
+
beforeEach(() => {
|
|
135
|
+
mockDb.current = createTestDb();
|
|
136
|
+
store = new SessionStore();
|
|
137
|
+
auth = mockAuthManager();
|
|
138
|
+
provider = new ClaudeProvider(auth, "api", store);
|
|
139
|
+
provider.authMode = "api_key";
|
|
140
|
+
provider._cliAvailable = null;
|
|
141
|
+
mockFetch = vi.fn().mockResolvedValue(textSSEResponse("Hello"));
|
|
142
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
143
|
+
});
|
|
144
|
+
afterEach(() => {
|
|
145
|
+
vi.unstubAllGlobals();
|
|
146
|
+
});
|
|
147
|
+
// ========== Basic History Lifecycle ==========
|
|
148
|
+
describe("Basic History Lifecycle", () => {
|
|
149
|
+
it("history starts empty on createSession", async () => {
|
|
150
|
+
const session = await provider.createSession({});
|
|
151
|
+
const history = provider.conversationHistory.get(session.id);
|
|
152
|
+
expect(history).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
it("first send adds user message to history", async () => {
|
|
155
|
+
const session = await provider.createSession({});
|
|
156
|
+
await collect(provider.send(session, "Hello world", []));
|
|
157
|
+
const history = provider.conversationHistory.get(session.id);
|
|
158
|
+
expect(history[0]).toEqual({ role: "user", content: "Hello world" });
|
|
159
|
+
});
|
|
160
|
+
it("text response adds assistant message to history", async () => {
|
|
161
|
+
const session = await provider.createSession({});
|
|
162
|
+
await collect(provider.send(session, "Hi", []));
|
|
163
|
+
const history = provider.conversationHistory.get(session.id);
|
|
164
|
+
const assistant = history.find((m) => m.role === "assistant" && m.content === "Hello");
|
|
165
|
+
expect(assistant).toBeDefined();
|
|
166
|
+
});
|
|
167
|
+
it("empty text response does not add assistant message", async () => {
|
|
168
|
+
mockFetch.mockResolvedValue(emptyTextSSEResponse());
|
|
169
|
+
const session = await provider.createSession({});
|
|
170
|
+
await collect(provider.send(session, "Hi", []));
|
|
171
|
+
const history = provider.conversationHistory.get(session.id);
|
|
172
|
+
const assistantMsgs = history.filter((m) => m.role === "assistant");
|
|
173
|
+
expect(assistantMsgs).toHaveLength(0);
|
|
174
|
+
});
|
|
175
|
+
it("history preserves order across multiple sends", async () => {
|
|
176
|
+
const session = await provider.createSession({});
|
|
177
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("Reply1"));
|
|
178
|
+
await collect(provider.send(session, "Msg1", []));
|
|
179
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("Reply2"));
|
|
180
|
+
await collect(provider.send(session, "Msg2", []));
|
|
181
|
+
const history = provider.conversationHistory.get(session.id);
|
|
182
|
+
expect(history).toHaveLength(4);
|
|
183
|
+
expect(history[0]).toEqual({ role: "user", content: "Msg1" });
|
|
184
|
+
expect(history[1]).toEqual({ role: "assistant", content: "Reply1" });
|
|
185
|
+
expect(history[2]).toEqual({ role: "user", content: "Msg2" });
|
|
186
|
+
expect(history[3]).toEqual({ role: "assistant", content: "Reply2" });
|
|
187
|
+
});
|
|
188
|
+
it("history grows correctly across 10+ sends", async () => {
|
|
189
|
+
const session = await provider.createSession({});
|
|
190
|
+
for (let i = 0; i < 12; i++) {
|
|
191
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse(`Reply-${i}`));
|
|
192
|
+
await collect(provider.send(session, `Msg-${i}`, []));
|
|
193
|
+
}
|
|
194
|
+
const history = provider.conversationHistory.get(session.id);
|
|
195
|
+
// 12 user + 12 assistant = 24
|
|
196
|
+
expect(history).toHaveLength(24);
|
|
197
|
+
});
|
|
198
|
+
it("large history (50+ messages) works correctly", async () => {
|
|
199
|
+
const session = await provider.createSession({});
|
|
200
|
+
for (let i = 0; i < 26; i++) {
|
|
201
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse(`R${i}`));
|
|
202
|
+
await collect(provider.send(session, `Q${i}`, []));
|
|
203
|
+
}
|
|
204
|
+
const history = provider.conversationHistory.get(session.id);
|
|
205
|
+
expect(history).toHaveLength(52);
|
|
206
|
+
// First user message preserved
|
|
207
|
+
expect(history[0].content).toBe("Q0");
|
|
208
|
+
// Last assistant preserved
|
|
209
|
+
expect(history[51].content).toBe("R25");
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
// ========== Tool Call History ==========
|
|
213
|
+
describe("Tool Call History", () => {
|
|
214
|
+
it("tool call adds assistant content array with tool_use blocks", async () => {
|
|
215
|
+
const tool = makeTool("remote_exec");
|
|
216
|
+
mockFetch
|
|
217
|
+
.mockResolvedValueOnce(toolCallSSEResponse([{ id: "tc-1", name: "remote_exec", args: { input: "ls" } }]))
|
|
218
|
+
.mockResolvedValueOnce(textSSEResponse("Done"));
|
|
219
|
+
const session = await provider.createSession({});
|
|
220
|
+
await collect(provider.send(session, "Run ls", [tool]));
|
|
221
|
+
const history = provider.conversationHistory.get(session.id);
|
|
222
|
+
const assistantToolUse = history.find((m) => m.role === "assistant" && Array.isArray(m.content) && m.content.some((b) => b.type === "tool_use"));
|
|
223
|
+
expect(assistantToolUse).toBeDefined();
|
|
224
|
+
expect(assistantToolUse.content.find((b) => b.type === "tool_use").id).toBe("tc-1");
|
|
225
|
+
});
|
|
226
|
+
it("tool result adds user content array with tool_result blocks", async () => {
|
|
227
|
+
const tool = makeTool("remote_exec");
|
|
228
|
+
mockFetch
|
|
229
|
+
.mockResolvedValueOnce(toolCallSSEResponse([{ id: "tc-1", name: "remote_exec", args: { input: "ls" } }]))
|
|
230
|
+
.mockResolvedValueOnce(textSSEResponse("Done"));
|
|
231
|
+
const session = await provider.createSession({});
|
|
232
|
+
await collect(provider.send(session, "Run ls", [tool]));
|
|
233
|
+
const history = provider.conversationHistory.get(session.id);
|
|
234
|
+
const userToolResult = history.find((m) => m.role === "user" && Array.isArray(m.content) && m.content.some((b) => b.type === "tool_result"));
|
|
235
|
+
expect(userToolResult).toBeDefined();
|
|
236
|
+
expect(userToolResult.content[0].tool_use_id).toBe("tc-1");
|
|
237
|
+
});
|
|
238
|
+
it("multiple tool calls in one turn create multiple tool_use entries", async () => {
|
|
239
|
+
const tool1 = makeTool("tool_a");
|
|
240
|
+
const tool2 = makeTool("tool_b");
|
|
241
|
+
mockFetch
|
|
242
|
+
.mockResolvedValueOnce(toolCallSSEResponse([
|
|
243
|
+
{ id: "tc-1", name: "tool_a", args: { input: "x" } },
|
|
244
|
+
{ id: "tc-2", name: "tool_b", args: { input: "y" } },
|
|
245
|
+
]))
|
|
246
|
+
.mockResolvedValueOnce(textSSEResponse("Both done"));
|
|
247
|
+
const session = await provider.createSession({});
|
|
248
|
+
await collect(provider.send(session, "Run both", [tool1, tool2]));
|
|
249
|
+
const history = provider.conversationHistory.get(session.id);
|
|
250
|
+
const assistantToolUse = history.find((m) => m.role === "assistant" && Array.isArray(m.content) && m.content.filter((b) => b.type === "tool_use").length === 2);
|
|
251
|
+
expect(assistantToolUse).toBeDefined();
|
|
252
|
+
});
|
|
253
|
+
it("tool_use IDs in history match between assistant and user messages", async () => {
|
|
254
|
+
const tool = makeTool("remote_exec");
|
|
255
|
+
mockFetch
|
|
256
|
+
.mockResolvedValueOnce(toolCallSSEResponse([{ id: "tc-42", name: "remote_exec", args: { input: "pwd" } }]))
|
|
257
|
+
.mockResolvedValueOnce(textSSEResponse("Done"));
|
|
258
|
+
const session = await provider.createSession({});
|
|
259
|
+
await collect(provider.send(session, "Where am I?", [tool]));
|
|
260
|
+
const history = provider.conversationHistory.get(session.id);
|
|
261
|
+
const assistantBlock = history
|
|
262
|
+
.filter((m) => m.role === "assistant" && Array.isArray(m.content))
|
|
263
|
+
.flatMap((m) => m.content)
|
|
264
|
+
.find((b) => b.type === "tool_use");
|
|
265
|
+
const userResultBlock = history
|
|
266
|
+
.filter((m) => m.role === "user" && Array.isArray(m.content))
|
|
267
|
+
.flatMap((m) => m.content)
|
|
268
|
+
.find((b) => b.type === "tool_result");
|
|
269
|
+
expect(assistantBlock.id).toBe("tc-42");
|
|
270
|
+
expect(userResultBlock.tool_use_id).toBe("tc-42");
|
|
271
|
+
});
|
|
272
|
+
it("tool loop: tool_call -> result -> continue -> tool_call -> result -> text -> done", async () => {
|
|
273
|
+
const tool = makeTool("remote_exec");
|
|
274
|
+
// First API call: tool call
|
|
275
|
+
mockFetch
|
|
276
|
+
.mockResolvedValueOnce(toolCallSSEResponse([{ id: "tc-1", name: "remote_exec", args: { input: "ls" } }]))
|
|
277
|
+
// Second API call: another tool call (continue loop)
|
|
278
|
+
.mockResolvedValueOnce(toolCallSSEResponse([{ id: "tc-2", name: "remote_exec", args: { input: "cat file.txt" } }]))
|
|
279
|
+
// Third API call: text response (end loop)
|
|
280
|
+
.mockResolvedValueOnce(textSSEResponse("Here's the content"));
|
|
281
|
+
const session = await provider.createSession({});
|
|
282
|
+
const events = await collect(provider.send(session, "Read the file", [tool]));
|
|
283
|
+
// Should have tool_call, tool_result, tool_call, tool_result, text, done
|
|
284
|
+
const toolCallEvents = events.filter((e) => e.type === "tool_call");
|
|
285
|
+
expect(toolCallEvents).toHaveLength(2);
|
|
286
|
+
const history = provider.conversationHistory.get(session.id);
|
|
287
|
+
// user, assistant[tool_use], user[tool_result], assistant[tool_use], user[tool_result], assistant text
|
|
288
|
+
expect(history.length).toBeGreaterThanOrEqual(6);
|
|
289
|
+
});
|
|
290
|
+
it("tool error adds error result to history", async () => {
|
|
291
|
+
const tool = makeTool("failing_tool", vi.fn().mockRejectedValue(new Error("Command failed")));
|
|
292
|
+
mockFetch
|
|
293
|
+
.mockResolvedValueOnce(toolCallSSEResponse([{ id: "tc-err", name: "failing_tool", args: { input: "x" } }]))
|
|
294
|
+
.mockResolvedValueOnce(textSSEResponse("I see the error"));
|
|
295
|
+
const session = await provider.createSession({});
|
|
296
|
+
await collect(provider.send(session, "Run it", [tool]));
|
|
297
|
+
const history = provider.conversationHistory.get(session.id);
|
|
298
|
+
const toolResult = history
|
|
299
|
+
.filter((m) => m.role === "user" && Array.isArray(m.content))
|
|
300
|
+
.flatMap((m) => m.content)
|
|
301
|
+
.find((b) => b.type === "tool_result" && b.is_error === true);
|
|
302
|
+
expect(toolResult).toBeDefined();
|
|
303
|
+
expect(toolResult.content).toContain("Error:");
|
|
304
|
+
});
|
|
305
|
+
it("unknown tool adds error result to history", async () => {
|
|
306
|
+
// No tools registered, but model calls one
|
|
307
|
+
mockFetch
|
|
308
|
+
.mockResolvedValueOnce(toolCallSSEResponse([{ id: "tc-unk", name: "unknown_tool", args: {} }]))
|
|
309
|
+
.mockResolvedValueOnce(textSSEResponse("Oh well"));
|
|
310
|
+
const session = await provider.createSession({});
|
|
311
|
+
await collect(provider.send(session, "Do something", []));
|
|
312
|
+
const history = provider.conversationHistory.get(session.id);
|
|
313
|
+
const toolResult = history
|
|
314
|
+
.filter((m) => m.role === "user" && Array.isArray(m.content))
|
|
315
|
+
.flatMap((m) => m.content)
|
|
316
|
+
.find((b) => b.type === "tool_result" && b.is_error === true);
|
|
317
|
+
expect(toolResult).toBeDefined();
|
|
318
|
+
expect(toolResult.content).toContain("Unknown tool");
|
|
319
|
+
});
|
|
320
|
+
it("server tool calls do not add tool_use to conversation history (handled by API)", async () => {
|
|
321
|
+
mockFetch.mockResolvedValueOnce(serverToolSSEResponse("stc-1", "web_search", "Search results here"));
|
|
322
|
+
const session = await provider.createSession({});
|
|
323
|
+
await collect(provider.send(session, "Search for ML papers", []));
|
|
324
|
+
const history = provider.conversationHistory.get(session.id);
|
|
325
|
+
// Server tool calls are NOT added as tool_use blocks to history
|
|
326
|
+
// Only text response goes into history
|
|
327
|
+
const toolUseBlocks = history
|
|
328
|
+
.filter((m) => Array.isArray(m.content))
|
|
329
|
+
.flatMap((m) => m.content)
|
|
330
|
+
.filter((b) => b.type === "tool_use");
|
|
331
|
+
expect(toolUseBlocks).toHaveLength(0);
|
|
332
|
+
});
|
|
333
|
+
it("tool call with text prefix adds both text and tool_use to assistant content", async () => {
|
|
334
|
+
const tool = makeTool("remote_exec");
|
|
335
|
+
mockFetch
|
|
336
|
+
.mockResolvedValueOnce(toolCallSSEResponse([{ id: "tc-1", name: "remote_exec", args: { input: "ls" } }], "Let me check..."))
|
|
337
|
+
.mockResolvedValueOnce(textSSEResponse("Done"));
|
|
338
|
+
const session = await provider.createSession({});
|
|
339
|
+
await collect(provider.send(session, "List files", [tool]));
|
|
340
|
+
const history = provider.conversationHistory.get(session.id);
|
|
341
|
+
const assistantMsg = history.find((m) => m.role === "assistant" && Array.isArray(m.content));
|
|
342
|
+
expect(assistantMsg).toBeDefined();
|
|
343
|
+
const textBlock = assistantMsg.content.find((b) => b.type === "text");
|
|
344
|
+
const toolBlock = assistantMsg.content.find((b) => b.type === "tool_use");
|
|
345
|
+
expect(textBlock).toBeDefined();
|
|
346
|
+
expect(textBlock.text).toBe("Let me check...");
|
|
347
|
+
expect(toolBlock).toBeDefined();
|
|
348
|
+
});
|
|
349
|
+
it("empty tools list works (no tool definitions in API call)", async () => {
|
|
350
|
+
const session = await provider.createSession({});
|
|
351
|
+
await collect(provider.send(session, "Just chat", []));
|
|
352
|
+
const history = provider.conversationHistory.get(session.id);
|
|
353
|
+
expect(history).toHaveLength(2); // user + assistant
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
// ========== Session Isolation ==========
|
|
357
|
+
describe("Session Isolation", () => {
|
|
358
|
+
it("history for different sessions is isolated", async () => {
|
|
359
|
+
const session1 = await provider.createSession({});
|
|
360
|
+
const session2 = await provider.createSession({});
|
|
361
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("Reply to S1"));
|
|
362
|
+
await collect(provider.send(session1, "S1 message", []));
|
|
363
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("Reply to S2"));
|
|
364
|
+
await collect(provider.send(session2, "S2 message", []));
|
|
365
|
+
const h1 = provider.conversationHistory.get(session1.id);
|
|
366
|
+
const h2 = provider.conversationHistory.get(session2.id);
|
|
367
|
+
expect(h1[0].content).toBe("S1 message");
|
|
368
|
+
expect(h2[0].content).toBe("S2 message");
|
|
369
|
+
expect(h1).toHaveLength(2);
|
|
370
|
+
expect(h2).toHaveLength(2);
|
|
371
|
+
});
|
|
372
|
+
it("concurrent sends to different sessions do not interfere", async () => {
|
|
373
|
+
const session1 = await provider.createSession({});
|
|
374
|
+
const session2 = await provider.createSession({});
|
|
375
|
+
mockFetch
|
|
376
|
+
.mockResolvedValueOnce(textSSEResponse("Reply1"))
|
|
377
|
+
.mockResolvedValueOnce(textSSEResponse("Reply2"));
|
|
378
|
+
const [events1, events2] = await Promise.all([
|
|
379
|
+
collect(provider.send(session1, "Msg1", [])),
|
|
380
|
+
collect(provider.send(session2, "Msg2", [])),
|
|
381
|
+
]);
|
|
382
|
+
expect(events1.some((e) => e.type === "done")).toBe(true);
|
|
383
|
+
expect(events2.some((e) => e.type === "done")).toBe(true);
|
|
384
|
+
const h1 = provider.conversationHistory.get(session1.id);
|
|
385
|
+
const h2 = provider.conversationHistory.get(session2.id);
|
|
386
|
+
expect(h1[0].content).toBe("Msg1");
|
|
387
|
+
expect(h2[0].content).toBe("Msg2");
|
|
388
|
+
});
|
|
389
|
+
it("closeSession removes history for that session", async () => {
|
|
390
|
+
const session = await provider.createSession({ systemPrompt: "test" });
|
|
391
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("Hi"));
|
|
392
|
+
await collect(provider.send(session, "Hello", []));
|
|
393
|
+
await provider.closeSession(session);
|
|
394
|
+
expect(provider.conversationHistory.has(session.id)).toBe(false);
|
|
395
|
+
});
|
|
396
|
+
it("closeSession does not affect other sessions", async () => {
|
|
397
|
+
const session1 = await provider.createSession({});
|
|
398
|
+
const session2 = await provider.createSession({});
|
|
399
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("R1"));
|
|
400
|
+
await collect(provider.send(session1, "M1", []));
|
|
401
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("R2"));
|
|
402
|
+
await collect(provider.send(session2, "M2", []));
|
|
403
|
+
await provider.closeSession(session1);
|
|
404
|
+
expect(provider.conversationHistory.has(session1.id)).toBe(false);
|
|
405
|
+
expect(provider.conversationHistory.has(session2.id)).toBe(true);
|
|
406
|
+
expect(provider.conversationHistory.get(session2.id)).toHaveLength(2);
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
// ========== Resume ==========
|
|
410
|
+
describe("Resume", () => {
|
|
411
|
+
it("resume populates history from DB messages", async () => {
|
|
412
|
+
const session = await provider.createSession({});
|
|
413
|
+
store.addMessage(session.id, "user", "Stored Q");
|
|
414
|
+
store.addMessage(session.id, "assistant", "Stored A");
|
|
415
|
+
provider.conversationHistory.delete(session.id);
|
|
416
|
+
await provider.resumeSession(session.id);
|
|
417
|
+
const history = provider.conversationHistory.get(session.id);
|
|
418
|
+
expect(history).toHaveLength(2);
|
|
419
|
+
expect(history[0]).toEqual({ role: "user", content: "Stored Q" });
|
|
420
|
+
expect(history[1]).toEqual({ role: "assistant", content: "Stored A" });
|
|
421
|
+
});
|
|
422
|
+
it("resume with no stored messages initializes empty history", async () => {
|
|
423
|
+
const session = await provider.createSession({});
|
|
424
|
+
provider.conversationHistory.delete(session.id);
|
|
425
|
+
await provider.resumeSession(session.id);
|
|
426
|
+
const history = provider.conversationHistory.get(session.id);
|
|
427
|
+
expect(history).toEqual([]);
|
|
428
|
+
});
|
|
429
|
+
it("resume skips non-user/assistant messages (system, tool)", async () => {
|
|
430
|
+
const session = await provider.createSession({});
|
|
431
|
+
store.addMessage(session.id, "system", "System msg");
|
|
432
|
+
store.addMessage(session.id, "user", "User msg");
|
|
433
|
+
store.addMessage(session.id, "tool", "Tool msg");
|
|
434
|
+
store.addMessage(session.id, "assistant", "Assistant msg");
|
|
435
|
+
provider.conversationHistory.delete(session.id);
|
|
436
|
+
await provider.resumeSession(session.id);
|
|
437
|
+
const history = provider.conversationHistory.get(session.id);
|
|
438
|
+
expect(history).toHaveLength(2);
|
|
439
|
+
expect(history[0].role).toBe("user");
|
|
440
|
+
expect(history[1].role).toBe("assistant");
|
|
441
|
+
});
|
|
442
|
+
it("resume does not re-load if history already exists in memory", async () => {
|
|
443
|
+
const session = await provider.createSession({});
|
|
444
|
+
const existing = [{ role: "user", content: "cached" }];
|
|
445
|
+
provider.conversationHistory.set(session.id, existing);
|
|
446
|
+
store.addMessage(session.id, "user", "from DB");
|
|
447
|
+
await provider.resumeSession(session.id);
|
|
448
|
+
const history = provider.conversationHistory.get(session.id);
|
|
449
|
+
expect(history).toHaveLength(1);
|
|
450
|
+
expect(history[0].content).toBe("cached");
|
|
451
|
+
});
|
|
452
|
+
it("resume then multiple sends accumulates correctly", async () => {
|
|
453
|
+
const session = await provider.createSession({});
|
|
454
|
+
store.addMessage(session.id, "user", "Original");
|
|
455
|
+
store.addMessage(session.id, "assistant", "Original reply");
|
|
456
|
+
provider.conversationHistory.delete(session.id);
|
|
457
|
+
await provider.resumeSession(session.id);
|
|
458
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("R1"));
|
|
459
|
+
await collect(provider.send(session, "Q1", []));
|
|
460
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("R2"));
|
|
461
|
+
await collect(provider.send(session, "Q2", []));
|
|
462
|
+
const history = provider.conversationHistory.get(session.id);
|
|
463
|
+
// 2 restored + 2 new pairs = 6
|
|
464
|
+
expect(history).toHaveLength(6);
|
|
465
|
+
expect(history[0].content).toBe("Original");
|
|
466
|
+
expect(history[5].content).toBe("R2");
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
// ========== resetHistory / Checkpoint ==========
|
|
470
|
+
describe("resetHistory / Checkpoint", () => {
|
|
471
|
+
it("resetHistory replaces everything with [briefing, ack]", async () => {
|
|
472
|
+
const session = await provider.createSession({});
|
|
473
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("R1"));
|
|
474
|
+
await collect(provider.send(session, "Build something", []));
|
|
475
|
+
provider.resetHistory(session, "=== CHECKPOINT ===\nYou are resuming...");
|
|
476
|
+
const history = provider.conversationHistory.get(session.id);
|
|
477
|
+
expect(history).toHaveLength(2);
|
|
478
|
+
expect(history[0].role).toBe("user");
|
|
479
|
+
expect(history[0].content).toContain("CHECKPOINT");
|
|
480
|
+
expect(history[1].role).toBe("assistant");
|
|
481
|
+
});
|
|
482
|
+
it("resetHistory ack content is CHECKPOINT_ACK constant", async () => {
|
|
483
|
+
const session = await provider.createSession({});
|
|
484
|
+
provider.resetHistory(session, "Briefing");
|
|
485
|
+
const history = provider.conversationHistory.get(session.id);
|
|
486
|
+
expect(history[1].content).toBe(CHECKPOINT_ACK);
|
|
487
|
+
});
|
|
488
|
+
it("after resetHistory, next send only includes briefing + new message", async () => {
|
|
489
|
+
const session = await provider.createSession({});
|
|
490
|
+
// Accumulate some history
|
|
491
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("R1"));
|
|
492
|
+
await collect(provider.send(session, "Q1", []));
|
|
493
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("R2"));
|
|
494
|
+
await collect(provider.send(session, "Q2", []));
|
|
495
|
+
provider.resetHistory(session, "Checkpoint briefing");
|
|
496
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("R3"));
|
|
497
|
+
await collect(provider.send(session, "Q3", []));
|
|
498
|
+
const lastCall = mockFetch.mock.calls[mockFetch.mock.calls.length - 1];
|
|
499
|
+
const requestBody = JSON.parse(lastCall[1].body);
|
|
500
|
+
// briefing (user), ack (assistant), new message (user)
|
|
501
|
+
expect(requestBody.messages).toHaveLength(3);
|
|
502
|
+
expect(requestBody.messages[0].content).toBe("Checkpoint briefing");
|
|
503
|
+
expect(requestBody.messages[1].content).toBe(CHECKPOINT_ACK);
|
|
504
|
+
expect(requestBody.messages[2].content).toBe("Q3");
|
|
505
|
+
});
|
|
506
|
+
it("history after checkpoint: briefing -> ack -> new user -> new assistant", async () => {
|
|
507
|
+
const session = await provider.createSession({});
|
|
508
|
+
provider.resetHistory(session, "My briefing");
|
|
509
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("New reply"));
|
|
510
|
+
await collect(provider.send(session, "New question", []));
|
|
511
|
+
const history = provider.conversationHistory.get(session.id);
|
|
512
|
+
expect(history).toHaveLength(4);
|
|
513
|
+
expect(history[0]).toEqual({ role: "user", content: "My briefing" });
|
|
514
|
+
expect(history[1]).toEqual({ role: "assistant", content: CHECKPOINT_ACK });
|
|
515
|
+
expect(history[2]).toEqual({ role: "user", content: "New question" });
|
|
516
|
+
expect(history[3]).toEqual({ role: "assistant", content: "New reply" });
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
// ========== Attachments ==========
|
|
520
|
+
describe("Attachments", () => {
|
|
521
|
+
it("image attachment creates multimodal content block", async () => {
|
|
522
|
+
const session = await provider.createSession({});
|
|
523
|
+
const attachments = [{ filename: "photo.png", mediaType: "image/png", data: "iVBOR..." }];
|
|
524
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("Nice image"));
|
|
525
|
+
await collect(provider.send(session, "Describe this", [], attachments));
|
|
526
|
+
const history = provider.conversationHistory.get(session.id);
|
|
527
|
+
const userMsg = history[0];
|
|
528
|
+
expect(Array.isArray(userMsg.content)).toBe(true);
|
|
529
|
+
const imageBlock = userMsg.content.find((b) => b.type === "image");
|
|
530
|
+
expect(imageBlock).toBeDefined();
|
|
531
|
+
expect(imageBlock.source.media_type).toBe("image/png");
|
|
532
|
+
});
|
|
533
|
+
it("PDF attachment creates document content block", async () => {
|
|
534
|
+
const session = await provider.createSession({});
|
|
535
|
+
const attachments = [{ filename: "paper.pdf", mediaType: "application/pdf", data: "JVBERi..." }];
|
|
536
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("Read the PDF"));
|
|
537
|
+
await collect(provider.send(session, "Summarize", [], attachments));
|
|
538
|
+
const history = provider.conversationHistory.get(session.id);
|
|
539
|
+
const userMsg = history[0];
|
|
540
|
+
expect(Array.isArray(userMsg.content)).toBe(true);
|
|
541
|
+
const docBlock = userMsg.content.find((b) => b.type === "document");
|
|
542
|
+
expect(docBlock).toBeDefined();
|
|
543
|
+
expect(docBlock.source.media_type).toBe("application/pdf");
|
|
544
|
+
});
|
|
545
|
+
it("attachment with text: text block comes after attachments", async () => {
|
|
546
|
+
const session = await provider.createSession({});
|
|
547
|
+
const attachments = [{ filename: "img.png", mediaType: "image/png", data: "abc" }];
|
|
548
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("OK"));
|
|
549
|
+
await collect(provider.send(session, "What is this?", [], attachments));
|
|
550
|
+
const history = provider.conversationHistory.get(session.id);
|
|
551
|
+
const userContent = history[0].content;
|
|
552
|
+
// Image first, text last
|
|
553
|
+
expect(userContent[0].type).toBe("image");
|
|
554
|
+
expect(userContent[userContent.length - 1].type).toBe("text");
|
|
555
|
+
expect(userContent[userContent.length - 1].text).toBe("What is this?");
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
// ========== stripAttachmentData ==========
|
|
559
|
+
describe("stripAttachmentData", () => {
|
|
560
|
+
it("replaces large image blocks with stripped text", async () => {
|
|
561
|
+
const session = await provider.createSession({});
|
|
562
|
+
const bigData = "x".repeat(200);
|
|
563
|
+
const attachments = [{ filename: "big.png", mediaType: "image/png", data: bigData }];
|
|
564
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("Got it"));
|
|
565
|
+
await collect(provider.send(session, "Analyze", [], attachments));
|
|
566
|
+
const history = provider.conversationHistory.get(session.id);
|
|
567
|
+
const userMsg = history[0];
|
|
568
|
+
const stripped = userMsg.content.find((b) => b.type === "text" && b.text.includes("stripped"));
|
|
569
|
+
expect(stripped).toBeDefined();
|
|
570
|
+
expect(stripped.text).toBe("[image attachment stripped]");
|
|
571
|
+
});
|
|
572
|
+
it("replaces large document blocks", async () => {
|
|
573
|
+
const session = await provider.createSession({});
|
|
574
|
+
const bigData = "x".repeat(200);
|
|
575
|
+
const attachments = [{ filename: "big.pdf", mediaType: "application/pdf", data: bigData }];
|
|
576
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("Got it"));
|
|
577
|
+
await collect(provider.send(session, "Read", [], attachments));
|
|
578
|
+
const history = provider.conversationHistory.get(session.id);
|
|
579
|
+
const userMsg = history[0];
|
|
580
|
+
const stripped = userMsg.content.find((b) => b.type === "text" && b.text.includes("stripped"));
|
|
581
|
+
expect(stripped).toBeDefined();
|
|
582
|
+
expect(stripped.text).toBe("[document attachment stripped]");
|
|
583
|
+
});
|
|
584
|
+
it("preserves small attachments (<=100 chars)", async () => {
|
|
585
|
+
const session = await provider.createSession({});
|
|
586
|
+
const smallData = "x".repeat(50);
|
|
587
|
+
const attachments = [{ filename: "tiny.png", mediaType: "image/png", data: smallData }];
|
|
588
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("Tiny"));
|
|
589
|
+
await collect(provider.send(session, "Show", [], attachments));
|
|
590
|
+
const history = provider.conversationHistory.get(session.id);
|
|
591
|
+
const userMsg = history[0];
|
|
592
|
+
const imageBlock = userMsg.content.find((b) => b.type === "image");
|
|
593
|
+
expect(imageBlock).toBeDefined();
|
|
594
|
+
expect(imageBlock.source.data).toBe(smallData);
|
|
595
|
+
});
|
|
596
|
+
it("handles tool_result with multimodal content", async () => {
|
|
597
|
+
const multimodalResult = JSON.stringify({
|
|
598
|
+
__multimodal: true,
|
|
599
|
+
text: "Chart data",
|
|
600
|
+
attachments: [{ mediaType: "image/png", data: "x".repeat(200) }],
|
|
601
|
+
});
|
|
602
|
+
const tool = makeTool("plot_tool", vi.fn().mockResolvedValue(multimodalResult));
|
|
603
|
+
mockFetch
|
|
604
|
+
.mockResolvedValueOnce(toolCallSSEResponse([{ id: "tc-m", name: "plot_tool", args: { input: "plot" } }]))
|
|
605
|
+
.mockResolvedValueOnce(textSSEResponse("Here's the chart"));
|
|
606
|
+
const session = await provider.createSession({});
|
|
607
|
+
await collect(provider.send(session, "Make a chart", [tool]));
|
|
608
|
+
const history = provider.conversationHistory.get(session.id);
|
|
609
|
+
// Find the tool_result block inside a user message
|
|
610
|
+
const toolResultMsg = history.find((m) => m.role === "user" && Array.isArray(m.content) && m.content.some((b) => b.type === "tool_result"));
|
|
611
|
+
expect(toolResultMsg).toBeDefined();
|
|
612
|
+
// The image inside the tool result should be stripped
|
|
613
|
+
const toolResultBlock = toolResultMsg.content.find((b) => b.type === "tool_result");
|
|
614
|
+
if (Array.isArray(toolResultBlock.content)) {
|
|
615
|
+
const strippedInner = toolResultBlock.content.find((b) => b.type === "text" && b.text.includes("stripped"));
|
|
616
|
+
expect(strippedInner).toBeDefined();
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
it("handles plain text content (string, not array)", async () => {
|
|
620
|
+
const session = await provider.createSession({});
|
|
621
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("Simple reply"));
|
|
622
|
+
await collect(provider.send(session, "Simple question", []));
|
|
623
|
+
const history = provider.conversationHistory.get(session.id);
|
|
624
|
+
// stripAttachmentData should not crash on string content
|
|
625
|
+
const assistantMsg = history.find((m) => m.role === "assistant" && typeof m.content === "string");
|
|
626
|
+
expect(assistantMsg).toBeDefined();
|
|
627
|
+
expect(assistantMsg.content).toBe("Simple reply");
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
// ========== API Request Verification ==========
|
|
631
|
+
describe("API Request Verification", () => {
|
|
632
|
+
it("system prompt is included in API request when set", async () => {
|
|
633
|
+
const session = await provider.createSession({ systemPrompt: "You are a scientist" });
|
|
634
|
+
await collect(provider.send(session, "Question", []));
|
|
635
|
+
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
636
|
+
expect(requestBody.system).toBe("You are a scientist");
|
|
637
|
+
});
|
|
638
|
+
it("system prompt is absent when not set", async () => {
|
|
639
|
+
const session = await provider.createSession({});
|
|
640
|
+
await collect(provider.send(session, "Question", []));
|
|
641
|
+
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
642
|
+
expect(requestBody.system).toBeUndefined();
|
|
643
|
+
});
|
|
644
|
+
it("messages in API request match history at time of call", async () => {
|
|
645
|
+
const session = await provider.createSession({});
|
|
646
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("R1"));
|
|
647
|
+
await collect(provider.send(session, "Q1", []));
|
|
648
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("R2"));
|
|
649
|
+
await collect(provider.send(session, "Q2", []));
|
|
650
|
+
const secondCallBody = JSON.parse(mockFetch.mock.calls[1][1].body);
|
|
651
|
+
// Q1, R1, Q2
|
|
652
|
+
expect(secondCallBody.messages).toHaveLength(3);
|
|
653
|
+
expect(secondCallBody.messages[0].content).toBe("Q1");
|
|
654
|
+
expect(secondCallBody.messages[1].content).toBe("R1");
|
|
655
|
+
expect(secondCallBody.messages[2].content).toBe("Q2");
|
|
656
|
+
});
|
|
657
|
+
it("web_search tool is excluded from function tools but included as server tool", async () => {
|
|
658
|
+
const wsTool = makeTool("web_search");
|
|
659
|
+
const regTool = makeTool("remote_exec");
|
|
660
|
+
const session = await provider.createSession({});
|
|
661
|
+
await collect(provider.send(session, "Search", [wsTool, regTool]));
|
|
662
|
+
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
663
|
+
// Function tools (with input_schema) should only have remote_exec
|
|
664
|
+
const functionTools = requestBody.tools.filter((t) => t.input_schema);
|
|
665
|
+
expect(functionTools).toHaveLength(1);
|
|
666
|
+
expect(functionTools[0].name).toBe("remote_exec");
|
|
667
|
+
// Server tool type should also be present
|
|
668
|
+
const serverTools = requestBody.tools.filter((t) => t.type === "web_search_20250305");
|
|
669
|
+
expect(serverTools).toHaveLength(1);
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
// ========== Thinking Blocks ==========
|
|
673
|
+
describe("Thinking Blocks", () => {
|
|
674
|
+
it("thinking blocks are streamed but not stored in history as separate messages", async () => {
|
|
675
|
+
const events = [
|
|
676
|
+
{ type: "message_start", message: { usage: { input_tokens: 10 } } },
|
|
677
|
+
{ type: "content_block_start", index: 0, content_block: { type: "thinking" } },
|
|
678
|
+
{ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "Let me think..." } },
|
|
679
|
+
{ type: "content_block_stop", index: 0 },
|
|
680
|
+
{ type: "content_block_start", index: 1, content_block: { type: "text" } },
|
|
681
|
+
{ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Here's my answer" } },
|
|
682
|
+
{ type: "content_block_stop", index: 1 },
|
|
683
|
+
{ type: "message_delta", usage: { output_tokens: 20 } },
|
|
684
|
+
{ type: "message_stop" },
|
|
685
|
+
];
|
|
686
|
+
mockFetch.mockResolvedValueOnce(mockSSEResponse(events));
|
|
687
|
+
const session = await provider.createSession({});
|
|
688
|
+
await collect(provider.send(session, "Think about this", []));
|
|
689
|
+
const history = provider.conversationHistory.get(session.id);
|
|
690
|
+
expect(history).toHaveLength(2);
|
|
691
|
+
expect(history[1].content).toBe("Here's my answer");
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
// ========== Additional Edge Cases ==========
|
|
695
|
+
describe("Additional Edge Cases", () => {
|
|
696
|
+
it("reasoning effort affects thinking budget in request", async () => {
|
|
697
|
+
const session = await provider.createSession({});
|
|
698
|
+
provider.reasoningEffort = "high";
|
|
699
|
+
await collect(provider.send(session, "Question", []));
|
|
700
|
+
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
701
|
+
expect(requestBody.thinking).toEqual({ type: "enabled", budget_tokens: 50000 });
|
|
702
|
+
});
|
|
703
|
+
it("reasoning effort 'max' sets highest budget", async () => {
|
|
704
|
+
const session = await provider.createSession({});
|
|
705
|
+
provider.reasoningEffort = "max";
|
|
706
|
+
await collect(provider.send(session, "Question", []));
|
|
707
|
+
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
708
|
+
expect(requestBody.thinking.budget_tokens).toBe(100000);
|
|
709
|
+
});
|
|
710
|
+
it("model name is sent in API request body", async () => {
|
|
711
|
+
provider.currentModel = "claude-sonnet-4-6";
|
|
712
|
+
const session = await provider.createSession({});
|
|
713
|
+
await collect(provider.send(session, "Hi", []));
|
|
714
|
+
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
715
|
+
expect(requestBody.model).toBe("claude-sonnet-4-6");
|
|
716
|
+
});
|
|
717
|
+
it("interrupt sets abort controller to null", async () => {
|
|
718
|
+
const session = await provider.createSession({});
|
|
719
|
+
// Start a send
|
|
720
|
+
const gen = provider.send(session, "Hello", []);
|
|
721
|
+
const iter = gen[Symbol.asyncIterator]();
|
|
722
|
+
await iter.next(); // begin streaming
|
|
723
|
+
// Interrupt
|
|
724
|
+
provider.interrupt(session);
|
|
725
|
+
expect(provider.abortController).toBeNull();
|
|
726
|
+
});
|
|
727
|
+
it("multiple attachments create multiple content blocks", async () => {
|
|
728
|
+
const session = await provider.createSession({});
|
|
729
|
+
const attachments = [
|
|
730
|
+
{ filename: "img1.png", mediaType: "image/png", data: "abc" },
|
|
731
|
+
{ filename: "doc.pdf", mediaType: "application/pdf", data: "def" },
|
|
732
|
+
{ filename: "img2.jpg", mediaType: "image/jpeg", data: "ghi" },
|
|
733
|
+
];
|
|
734
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("Got all three"));
|
|
735
|
+
await collect(provider.send(session, "Analyze these", [], attachments));
|
|
736
|
+
const history = provider.conversationHistory.get(session.id);
|
|
737
|
+
const userContent = history[0].content;
|
|
738
|
+
// 2 images + 1 document + 1 text = 4 blocks
|
|
739
|
+
expect(userContent).toHaveLength(4);
|
|
740
|
+
expect(userContent.filter((b) => b.type === "image")).toHaveLength(2);
|
|
741
|
+
expect(userContent.filter((b) => b.type === "document")).toHaveLength(1);
|
|
742
|
+
expect(userContent.filter((b) => b.type === "text")).toHaveLength(1);
|
|
743
|
+
});
|
|
744
|
+
it("fetchModels returns two Claude models", async () => {
|
|
745
|
+
const models = await provider.fetchModels();
|
|
746
|
+
expect(models).toHaveLength(2);
|
|
747
|
+
expect(models.map((m) => m.id)).toContain("claude-opus-4-6");
|
|
748
|
+
expect(models.map((m) => m.id)).toContain("claude-sonnet-4-6");
|
|
749
|
+
});
|
|
750
|
+
it("default model is claude-opus-4-6", () => {
|
|
751
|
+
expect(provider.currentModel).toBe("claude-opus-4-6");
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
//# sourceMappingURL=history.test.js.map
|