@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,1089 @@
|
|
|
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("../../paths.js", () => ({
|
|
21
|
+
WEB_SEARCH_TOOL: "web_search",
|
|
22
|
+
debugLog: vi.fn(),
|
|
23
|
+
}));
|
|
24
|
+
const mockOAuthInstance = {
|
|
25
|
+
login: vi.fn(),
|
|
26
|
+
refresh: vi.fn(),
|
|
27
|
+
};
|
|
28
|
+
vi.mock("./oauth.js", () => {
|
|
29
|
+
return {
|
|
30
|
+
OpenAIOAuth: class MockOpenAIOAuth {
|
|
31
|
+
login = mockOAuthInstance.login;
|
|
32
|
+
refresh = mockOAuthInstance.refresh;
|
|
33
|
+
onAuthUrl = null;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
// ─── Imports (dynamic because of mocks) ──────────────
|
|
38
|
+
const { SessionStore } = await import("../../store/session-store.js");
|
|
39
|
+
const { OpenAIProvider } = await import("./provider.js");
|
|
40
|
+
const { TransientError } = await import("../retry.js");
|
|
41
|
+
const { CHECKPOINT_ACK } = await import("../types.js");
|
|
42
|
+
// ─── Helpers ─────────────────────────────────────────
|
|
43
|
+
function mockAuthManager(creds = {
|
|
44
|
+
method: "oauth",
|
|
45
|
+
provider: "openai",
|
|
46
|
+
accessToken: "at-test",
|
|
47
|
+
refreshToken: "rt-test",
|
|
48
|
+
}) {
|
|
49
|
+
return {
|
|
50
|
+
getCredentials: vi.fn().mockResolvedValue(creds),
|
|
51
|
+
setApiKey: vi.fn(),
|
|
52
|
+
setOAuthTokens: vi.fn(),
|
|
53
|
+
isAuthenticated: vi.fn().mockReturnValue(true),
|
|
54
|
+
tokenStore: {
|
|
55
|
+
isExpired: vi.fn().mockReturnValue(false),
|
|
56
|
+
needsRefresh: vi.fn().mockReturnValue(false),
|
|
57
|
+
},
|
|
58
|
+
registerRefreshHandler: vi.fn(),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function mockSSEResponse(events, status = 200) {
|
|
62
|
+
const encoder = new TextEncoder();
|
|
63
|
+
const body = events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("");
|
|
64
|
+
const stream = new ReadableStream({
|
|
65
|
+
start(controller) {
|
|
66
|
+
controller.enqueue(encoder.encode(body));
|
|
67
|
+
controller.close();
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
return new Response(stream, {
|
|
71
|
+
status,
|
|
72
|
+
headers: { "content-type": "text/event-stream" },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** Build a minimal SSE response that produces a text reply via OpenAI's Responses API format. */
|
|
76
|
+
function textSSEResponse(text, usage = { input_tokens: 10, output_tokens: 5 }) {
|
|
77
|
+
return mockSSEResponse([
|
|
78
|
+
{ type: "response.output_text.delta", delta: text },
|
|
79
|
+
{
|
|
80
|
+
type: "response.output_item.done",
|
|
81
|
+
item: {
|
|
82
|
+
type: "message",
|
|
83
|
+
role: "assistant",
|
|
84
|
+
content: [{ type: "output_text", text }],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
type: "response.completed",
|
|
89
|
+
response: { usage },
|
|
90
|
+
},
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
/** Build a SSE response that includes function_call items. */
|
|
94
|
+
function toolCallSSEResponse(toolCalls, text = "") {
|
|
95
|
+
const events = [];
|
|
96
|
+
if (text) {
|
|
97
|
+
events.push({ type: "response.output_text.delta", delta: text });
|
|
98
|
+
events.push({
|
|
99
|
+
type: "response.output_item.done",
|
|
100
|
+
item: {
|
|
101
|
+
type: "message",
|
|
102
|
+
role: "assistant",
|
|
103
|
+
content: [{ type: "output_text", text }],
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
for (const tc of toolCalls) {
|
|
108
|
+
events.push({
|
|
109
|
+
type: "response.output_item.done",
|
|
110
|
+
item: {
|
|
111
|
+
type: "function_call",
|
|
112
|
+
call_id: tc.call_id,
|
|
113
|
+
name: tc.name,
|
|
114
|
+
arguments: JSON.stringify(tc.args),
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
events.push({
|
|
119
|
+
type: "response.completed",
|
|
120
|
+
response: { usage: { input_tokens: 10, output_tokens: 20 } },
|
|
121
|
+
});
|
|
122
|
+
return mockSSEResponse(events);
|
|
123
|
+
}
|
|
124
|
+
async function collect(gen) {
|
|
125
|
+
const events = [];
|
|
126
|
+
for await (const e of gen)
|
|
127
|
+
events.push(e);
|
|
128
|
+
return events;
|
|
129
|
+
}
|
|
130
|
+
function makeTool(name, exec) {
|
|
131
|
+
return {
|
|
132
|
+
name,
|
|
133
|
+
description: `Test tool ${name}`,
|
|
134
|
+
parameters: {
|
|
135
|
+
type: "object",
|
|
136
|
+
properties: { input: { type: "string" } },
|
|
137
|
+
required: ["input"],
|
|
138
|
+
},
|
|
139
|
+
execute: exec ?? vi.fn().mockResolvedValue("tool-result"),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function makeSession(id = "sess-1") {
|
|
143
|
+
return {
|
|
144
|
+
id,
|
|
145
|
+
providerId: "openai",
|
|
146
|
+
createdAt: Date.now(),
|
|
147
|
+
lastActiveAt: Date.now(),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
// ─── Tests ───────────────────────────────────────────
|
|
151
|
+
describe("OpenAIProvider", () => {
|
|
152
|
+
let store;
|
|
153
|
+
let auth;
|
|
154
|
+
let provider;
|
|
155
|
+
let mockFetch;
|
|
156
|
+
beforeEach(() => {
|
|
157
|
+
mockDb.current = createTestDb();
|
|
158
|
+
store = new SessionStore();
|
|
159
|
+
auth = mockAuthManager();
|
|
160
|
+
provider = new OpenAIProvider(auth, store);
|
|
161
|
+
mockFetch = vi.fn().mockResolvedValue(textSSEResponse("Hello"));
|
|
162
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
163
|
+
mockOAuthInstance.login.mockReset();
|
|
164
|
+
mockOAuthInstance.refresh.mockReset();
|
|
165
|
+
});
|
|
166
|
+
afterEach(() => {
|
|
167
|
+
vi.unstubAllGlobals();
|
|
168
|
+
});
|
|
169
|
+
// ========== Session Management ==========
|
|
170
|
+
describe("Session Management", () => {
|
|
171
|
+
it("createSession initializes empty history", async () => {
|
|
172
|
+
const session = await provider.createSession({});
|
|
173
|
+
expect(session.id).toBeTruthy();
|
|
174
|
+
expect(provider.conversationHistory.get(session.id)).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
it("createSession stores instructions from system prompt", async () => {
|
|
177
|
+
const session = await provider.createSession({
|
|
178
|
+
systemPrompt: "You are a researcher",
|
|
179
|
+
});
|
|
180
|
+
expect(provider.instructions.get(session.id)).toBe("You are a researcher");
|
|
181
|
+
});
|
|
182
|
+
it("createSession with ephemeral flag creates eph- prefixed session", async () => {
|
|
183
|
+
const session = await provider.createSession({ ephemeral: true });
|
|
184
|
+
expect(session.id).toMatch(/^eph-/);
|
|
185
|
+
});
|
|
186
|
+
it("createSession without ephemeral persists to DB", async () => {
|
|
187
|
+
const session = await provider.createSession({});
|
|
188
|
+
const retrieved = store.getSession(session.id);
|
|
189
|
+
expect(retrieved).not.toBeNull();
|
|
190
|
+
expect(retrieved.providerId).toBe("openai");
|
|
191
|
+
});
|
|
192
|
+
it("resumeSession loads session from DB", async () => {
|
|
193
|
+
const created = await provider.createSession({});
|
|
194
|
+
provider.conversationHistory.delete(created.id);
|
|
195
|
+
const resumed = await provider.resumeSession(created.id);
|
|
196
|
+
expect(resumed.id).toBe(created.id);
|
|
197
|
+
});
|
|
198
|
+
it("resumeSession restores conversation history with correct content types", async () => {
|
|
199
|
+
const created = await provider.createSession({});
|
|
200
|
+
store.addMessage(created.id, "user", "Hello");
|
|
201
|
+
store.addMessage(created.id, "assistant", "Hi there");
|
|
202
|
+
provider.conversationHistory.delete(created.id);
|
|
203
|
+
await provider.resumeSession(created.id);
|
|
204
|
+
const history = provider.conversationHistory.get(created.id);
|
|
205
|
+
expect(history).toHaveLength(2);
|
|
206
|
+
expect(history[0]).toEqual({
|
|
207
|
+
type: "message",
|
|
208
|
+
role: "user",
|
|
209
|
+
content: [{ type: "input_text", text: "Hello" }],
|
|
210
|
+
});
|
|
211
|
+
expect(history[1]).toEqual({
|
|
212
|
+
type: "message",
|
|
213
|
+
role: "assistant",
|
|
214
|
+
content: [{ type: "output_text", text: "Hi there" }],
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
it("resumeSession with empty history initializes empty array", async () => {
|
|
218
|
+
const created = await provider.createSession({});
|
|
219
|
+
provider.conversationHistory.delete(created.id);
|
|
220
|
+
await provider.resumeSession(created.id);
|
|
221
|
+
const history = provider.conversationHistory.get(created.id);
|
|
222
|
+
expect(history).toEqual([]);
|
|
223
|
+
});
|
|
224
|
+
it("resumeSession throws for unknown session", async () => {
|
|
225
|
+
await expect(provider.resumeSession("nonexistent")).rejects.toThrow("Session nonexistent not found");
|
|
226
|
+
});
|
|
227
|
+
it("resumeSession restores system prompt", async () => {
|
|
228
|
+
const created = await provider.createSession({});
|
|
229
|
+
provider.conversationHistory.delete(created.id);
|
|
230
|
+
await provider.resumeSession(created.id, "System instructions");
|
|
231
|
+
expect(provider.instructions.get(created.id)).toBe("System instructions");
|
|
232
|
+
});
|
|
233
|
+
it("resumeSession does not overwrite existing in-memory history", async () => {
|
|
234
|
+
const created = await provider.createSession({});
|
|
235
|
+
const existing = [{ type: "message", role: "user", content: [{ type: "input_text", text: "cached" }] }];
|
|
236
|
+
provider.conversationHistory.set(created.id, existing);
|
|
237
|
+
store.addMessage(created.id, "user", "from DB");
|
|
238
|
+
await provider.resumeSession(created.id);
|
|
239
|
+
const history = provider.conversationHistory.get(created.id);
|
|
240
|
+
expect(history).toHaveLength(1);
|
|
241
|
+
expect(history[0].content[0].text).toBe("cached");
|
|
242
|
+
});
|
|
243
|
+
it("closeSession clears state", async () => {
|
|
244
|
+
const session = await provider.createSession({ systemPrompt: "test" });
|
|
245
|
+
await provider.closeSession(session);
|
|
246
|
+
expect(provider.conversationHistory.has(session.id)).toBe(false);
|
|
247
|
+
expect(provider.instructions.has(session.id)).toBe(false);
|
|
248
|
+
});
|
|
249
|
+
it("fetchModels with valid token returns API models", async () => {
|
|
250
|
+
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({
|
|
251
|
+
models: [
|
|
252
|
+
{ slug: "gpt-5.4", title: "GPT-5.4", description: "flagship" },
|
|
253
|
+
{ slug: "gpt-5.3", title: "GPT-5.3" },
|
|
254
|
+
],
|
|
255
|
+
}), { status: 200 }));
|
|
256
|
+
const models = await provider.fetchModels();
|
|
257
|
+
expect(models).toHaveLength(2);
|
|
258
|
+
expect(models[0].id).toBe("gpt-5.4");
|
|
259
|
+
expect(models[0].name).toBe("GPT-5.4");
|
|
260
|
+
});
|
|
261
|
+
it("fetchModels falls back to defaults on error", async () => {
|
|
262
|
+
mockFetch.mockResolvedValueOnce(new Response("error", { status: 500 }));
|
|
263
|
+
const models = await provider.fetchModels();
|
|
264
|
+
expect(models.length).toBeGreaterThan(0);
|
|
265
|
+
expect(models[0].id).toBe("gpt-5.4");
|
|
266
|
+
});
|
|
267
|
+
it("fetchModels falls back on empty models array", async () => {
|
|
268
|
+
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ models: [] }), { status: 200 }));
|
|
269
|
+
const models = await provider.fetchModels();
|
|
270
|
+
expect(models[0].id).toBe("gpt-5.4");
|
|
271
|
+
});
|
|
272
|
+
it("getDefaultModels returns expected list", () => {
|
|
273
|
+
const defaults = provider.getDefaultModels();
|
|
274
|
+
expect(defaults.length).toBeGreaterThanOrEqual(5);
|
|
275
|
+
expect(defaults[0].id).toBe("gpt-5.4");
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
// ========== History Management (CRITICAL BUG AREA) ==========
|
|
279
|
+
describe("History Management", () => {
|
|
280
|
+
it("send() appends user message to history as input_text content", async () => {
|
|
281
|
+
const session = await provider.createSession({});
|
|
282
|
+
await collect(provider.send(session, "What is ML?", []));
|
|
283
|
+
const history = provider.conversationHistory.get(session.id);
|
|
284
|
+
const userMsg = history.find((m) => m.type === "message" &&
|
|
285
|
+
m.role === "user" &&
|
|
286
|
+
m.content.some((c) => c.type === "input_text" && c.text === "What is ML?"));
|
|
287
|
+
expect(userMsg).toBeDefined();
|
|
288
|
+
});
|
|
289
|
+
it("send() with response appends assistant message", async () => {
|
|
290
|
+
const session = await provider.createSession({});
|
|
291
|
+
await collect(provider.send(session, "Hi", []));
|
|
292
|
+
const history = provider.conversationHistory.get(session.id);
|
|
293
|
+
const assistantMsg = history.find((m) => m.type === "message" &&
|
|
294
|
+
m.role === "assistant");
|
|
295
|
+
expect(assistantMsg).toBeDefined();
|
|
296
|
+
});
|
|
297
|
+
it("send() with tool calls appends function_call + function_call_output items", async () => {
|
|
298
|
+
const tool = makeTool("run_cmd");
|
|
299
|
+
mockFetch
|
|
300
|
+
.mockResolvedValueOnce(toolCallSSEResponse([{ call_id: "fc-1", name: "run_cmd", args: { input: "ls" } }]))
|
|
301
|
+
.mockResolvedValueOnce(textSSEResponse("Done"));
|
|
302
|
+
const session = await provider.createSession({});
|
|
303
|
+
await collect(provider.send(session, "Run ls", [tool]));
|
|
304
|
+
const history = provider.conversationHistory.get(session.id);
|
|
305
|
+
const funcCall = history.find((m) => m.type === "function_call");
|
|
306
|
+
expect(funcCall).toBeDefined();
|
|
307
|
+
expect(funcCall.name).toBe("run_cmd");
|
|
308
|
+
const funcOutput = history.find((m) => m.type === "function_call_output");
|
|
309
|
+
expect(funcOutput).toBeDefined();
|
|
310
|
+
expect(funcOutput.call_id).toBe("fc-1");
|
|
311
|
+
});
|
|
312
|
+
it("resume then send: API request includes previous messages (THE BUG TEST)", async () => {
|
|
313
|
+
const created = await provider.createSession({});
|
|
314
|
+
store.addMessage(created.id, "user", "First question");
|
|
315
|
+
store.addMessage(created.id, "assistant", "First answer");
|
|
316
|
+
// Fresh process resume
|
|
317
|
+
provider.conversationHistory.delete(created.id);
|
|
318
|
+
await provider.resumeSession(created.id);
|
|
319
|
+
await collect(provider.send(created, "Second question", []));
|
|
320
|
+
expect(mockFetch).toHaveBeenCalled();
|
|
321
|
+
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
322
|
+
// input should contain: restored user, restored assistant, new user = 3 items
|
|
323
|
+
expect(requestBody.input).toHaveLength(3);
|
|
324
|
+
expect(requestBody.input[0]).toEqual({
|
|
325
|
+
type: "message",
|
|
326
|
+
role: "user",
|
|
327
|
+
content: [{ type: "input_text", text: "First question" }],
|
|
328
|
+
});
|
|
329
|
+
expect(requestBody.input[1]).toEqual({
|
|
330
|
+
type: "message",
|
|
331
|
+
role: "assistant",
|
|
332
|
+
content: [{ type: "output_text", text: "First answer" }],
|
|
333
|
+
});
|
|
334
|
+
expect(requestBody.input[2]).toEqual({
|
|
335
|
+
type: "message",
|
|
336
|
+
role: "user",
|
|
337
|
+
content: [{ type: "input_text", text: "Second question" }],
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
it("resume then send: fetch body includes ALL restored messages in order", async () => {
|
|
341
|
+
const created = await provider.createSession({});
|
|
342
|
+
for (let i = 1; i <= 5; i++) {
|
|
343
|
+
store.addMessage(created.id, "user", `msg-${i}`);
|
|
344
|
+
store.addMessage(created.id, "assistant", `reply-${i}`);
|
|
345
|
+
}
|
|
346
|
+
provider.conversationHistory.delete(created.id);
|
|
347
|
+
await provider.resumeSession(created.id);
|
|
348
|
+
await collect(provider.send(created, "msg-6", []));
|
|
349
|
+
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
350
|
+
expect(requestBody.input).toHaveLength(11); // 10 restored + 1 new
|
|
351
|
+
expect(requestBody.input[0].content[0].text).toBe("msg-1");
|
|
352
|
+
expect(requestBody.input[9].content[0].text).toBe("reply-5");
|
|
353
|
+
expect(requestBody.input[10].content[0].text).toBe("msg-6");
|
|
354
|
+
});
|
|
355
|
+
it("multiple resume/send cycles maintain correct history", async () => {
|
|
356
|
+
const created = await provider.createSession({});
|
|
357
|
+
store.addMessage(created.id, "user", "Turn 1");
|
|
358
|
+
store.addMessage(created.id, "assistant", "Reply 1");
|
|
359
|
+
// First resume + send
|
|
360
|
+
provider.conversationHistory.delete(created.id);
|
|
361
|
+
await provider.resumeSession(created.id);
|
|
362
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("Reply 2"));
|
|
363
|
+
await collect(provider.send(created, "Turn 2", []));
|
|
364
|
+
let requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
365
|
+
expect(requestBody.input).toHaveLength(3);
|
|
366
|
+
// Second send (without resume — history accumulated in-memory)
|
|
367
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("Reply 3"));
|
|
368
|
+
await collect(provider.send(created, "Turn 3", []));
|
|
369
|
+
requestBody = JSON.parse(mockFetch.mock.calls[1][1].body);
|
|
370
|
+
// Should contain: Turn1, Reply1, Turn2, Reply2(assistant), Turn3
|
|
371
|
+
expect(requestBody.input.length).toBeGreaterThanOrEqual(5);
|
|
372
|
+
const lastItem = requestBody.input[requestBody.input.length - 1];
|
|
373
|
+
expect(lastItem.content[0].text).toBe("Turn 3");
|
|
374
|
+
});
|
|
375
|
+
it("resume with tool/system messages filters to user/assistant only", async () => {
|
|
376
|
+
const created = await provider.createSession({});
|
|
377
|
+
store.addMessage(created.id, "user", "Run command");
|
|
378
|
+
store.addMessage(created.id, "assistant", "Running...");
|
|
379
|
+
store.addMessage(created.id, "tool", "tool output");
|
|
380
|
+
store.addMessage(created.id, "system", "system msg");
|
|
381
|
+
provider.conversationHistory.delete(created.id);
|
|
382
|
+
await provider.resumeSession(created.id);
|
|
383
|
+
const history = provider.conversationHistory.get(created.id);
|
|
384
|
+
expect(history).toHaveLength(2);
|
|
385
|
+
expect(history[0].role).toBe("user");
|
|
386
|
+
expect(history[1].role).toBe("assistant");
|
|
387
|
+
});
|
|
388
|
+
it("multiple sends accumulate history correctly", async () => {
|
|
389
|
+
const session = await provider.createSession({});
|
|
390
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("Reply 1"));
|
|
391
|
+
await collect(provider.send(session, "msg 1", []));
|
|
392
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("Reply 2"));
|
|
393
|
+
await collect(provider.send(session, "msg 2", []));
|
|
394
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("Reply 3"));
|
|
395
|
+
await collect(provider.send(session, "msg 3", []));
|
|
396
|
+
const history = provider.conversationHistory.get(session.id);
|
|
397
|
+
// 3 user + 3 assistant = 6 message items
|
|
398
|
+
const messages = history.filter((h) => h.type === "message");
|
|
399
|
+
expect(messages).toHaveLength(6);
|
|
400
|
+
});
|
|
401
|
+
it("resetHistory replaces history with briefing exchange", async () => {
|
|
402
|
+
const session = await provider.createSession({});
|
|
403
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("reply"));
|
|
404
|
+
await collect(provider.send(session, "old message", []));
|
|
405
|
+
provider.resetHistory(session, "Here is your memory checkpoint");
|
|
406
|
+
const history = provider.conversationHistory.get(session.id);
|
|
407
|
+
expect(history).toHaveLength(2);
|
|
408
|
+
expect(history[0]).toEqual({
|
|
409
|
+
type: "message",
|
|
410
|
+
role: "user",
|
|
411
|
+
content: [{ type: "input_text", text: "Here is your memory checkpoint" }],
|
|
412
|
+
});
|
|
413
|
+
expect(history[1]).toEqual({
|
|
414
|
+
type: "message",
|
|
415
|
+
role: "assistant",
|
|
416
|
+
content: [{ type: "output_text", text: CHECKPOINT_ACK }],
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
it("after resetHistory + send, only briefing + new message in request", async () => {
|
|
420
|
+
const session = await provider.createSession({});
|
|
421
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("old"));
|
|
422
|
+
await collect(provider.send(session, "old msg", []));
|
|
423
|
+
provider.resetHistory(session, "Briefing");
|
|
424
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("new reply"));
|
|
425
|
+
await collect(provider.send(session, "new msg", []));
|
|
426
|
+
const requestBody = JSON.parse(mockFetch.mock.calls[1][1].body);
|
|
427
|
+
expect(requestBody.input).toHaveLength(3); // briefing, ACK, new msg
|
|
428
|
+
expect(requestBody.input[0].content[0].text).toBe("Briefing");
|
|
429
|
+
expect(requestBody.input[1].content[0].text).toBe(CHECKPOINT_ACK);
|
|
430
|
+
expect(requestBody.input[2].content[0].text).toBe("new msg");
|
|
431
|
+
});
|
|
432
|
+
it("attachment data stripped from history after send (input_image)", async () => {
|
|
433
|
+
const session = await provider.createSession({});
|
|
434
|
+
const longData = "A".repeat(300);
|
|
435
|
+
const attachments = [
|
|
436
|
+
{ filename: "img.png", mediaType: "image/png", data: longData },
|
|
437
|
+
];
|
|
438
|
+
await collect(provider.send(session, "Look at this", [], attachments));
|
|
439
|
+
const history = provider.conversationHistory.get(session.id);
|
|
440
|
+
const firstUser = history[0];
|
|
441
|
+
// Image should be stripped (replaced with text placeholder)
|
|
442
|
+
const imageBlock = firstUser.content.find((b) => b.type === "input_image");
|
|
443
|
+
expect(imageBlock).toBeUndefined();
|
|
444
|
+
const textPlaceholder = firstUser.content.find((b) => typeof b.text === "string" && b.text.includes("stripped"));
|
|
445
|
+
expect(textPlaceholder).toBeDefined();
|
|
446
|
+
});
|
|
447
|
+
it("attachment data stripped from history after send (input_file)", async () => {
|
|
448
|
+
const session = await provider.createSession({});
|
|
449
|
+
const longData = "B".repeat(300);
|
|
450
|
+
const attachments = [
|
|
451
|
+
{ filename: "doc.pdf", mediaType: "application/pdf", data: longData },
|
|
452
|
+
];
|
|
453
|
+
await collect(provider.send(session, "Read this", [], attachments));
|
|
454
|
+
const history = provider.conversationHistory.get(session.id);
|
|
455
|
+
const firstUser = history[0];
|
|
456
|
+
const fileBlock = firstUser.content.find((b) => b.type === "input_file");
|
|
457
|
+
expect(fileBlock).toBeUndefined();
|
|
458
|
+
});
|
|
459
|
+
it("multimodal content extracted from tool results injects user message", async () => {
|
|
460
|
+
const multimodalResult = JSON.stringify({
|
|
461
|
+
__multimodal: true,
|
|
462
|
+
text: "chart",
|
|
463
|
+
attachments: [{ mediaType: "image/png", data: "chartdata" }],
|
|
464
|
+
});
|
|
465
|
+
const tool = makeTool("plot", vi.fn().mockResolvedValue(multimodalResult));
|
|
466
|
+
mockFetch
|
|
467
|
+
.mockResolvedValueOnce(toolCallSSEResponse([{ call_id: "fc-1", name: "plot", args: {} }]))
|
|
468
|
+
.mockResolvedValueOnce(textSSEResponse("Done"));
|
|
469
|
+
const session = await provider.createSession({});
|
|
470
|
+
await collect(provider.send(session, "Plot data", [tool]));
|
|
471
|
+
const history = provider.conversationHistory.get(session.id);
|
|
472
|
+
// Should contain a user message with input_image + input_text for multimodal content
|
|
473
|
+
const multimodalMsg = history.find((m) => m.type === "message" &&
|
|
474
|
+
m.role === "user" &&
|
|
475
|
+
Array.isArray(m.content) &&
|
|
476
|
+
m.content.some((c) => c.type === "input_image"));
|
|
477
|
+
expect(multimodalMsg).toBeDefined();
|
|
478
|
+
});
|
|
479
|
+
it("resumed session history is correctly sent on first API call after resume", async () => {
|
|
480
|
+
// Regression test: verifying the fix for the bug where resumed sessions
|
|
481
|
+
// did not include past messages in the API request
|
|
482
|
+
const created = await provider.createSession({});
|
|
483
|
+
for (let i = 1; i <= 4; i++) {
|
|
484
|
+
store.addMessage(created.id, "user", `User turn ${i}`);
|
|
485
|
+
store.addMessage(created.id, "assistant", `Assistant turn ${i}`);
|
|
486
|
+
}
|
|
487
|
+
provider.conversationHistory.delete(created.id);
|
|
488
|
+
await provider.resumeSession(created.id, "System prompt");
|
|
489
|
+
await collect(provider.send(created, "New question", []));
|
|
490
|
+
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
491
|
+
// 8 restored + 1 new = 9 items
|
|
492
|
+
expect(requestBody.input).toHaveLength(9);
|
|
493
|
+
expect(requestBody.input[0].content[0].text).toBe("User turn 1");
|
|
494
|
+
expect(requestBody.input[7].content[0].text).toBe("Assistant turn 4");
|
|
495
|
+
expect(requestBody.input[8].content[0].text).toBe("New question");
|
|
496
|
+
expect(requestBody.instructions).toBe("System prompt");
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
// ========== Send / Streaming ==========
|
|
500
|
+
describe("Send / Streaming", () => {
|
|
501
|
+
it("send() yields text deltas", async () => {
|
|
502
|
+
const session = await provider.createSession({});
|
|
503
|
+
const events = await collect(provider.send(session, "hi", []));
|
|
504
|
+
const textEvents = events.filter((e) => e.type === "text");
|
|
505
|
+
expect(textEvents.length).toBeGreaterThanOrEqual(1);
|
|
506
|
+
expect(textEvents[0].delta).toBe("Hello");
|
|
507
|
+
});
|
|
508
|
+
it("send() yields done with usage", async () => {
|
|
509
|
+
const session = await provider.createSession({});
|
|
510
|
+
const events = await collect(provider.send(session, "hi", []));
|
|
511
|
+
const doneEvent = events.find((e) => e.type === "done");
|
|
512
|
+
expect(doneEvent).toBeDefined();
|
|
513
|
+
expect(doneEvent.usage).toEqual({ inputTokens: 10, outputTokens: 5 });
|
|
514
|
+
});
|
|
515
|
+
it("send() yields tool_call events", async () => {
|
|
516
|
+
const tool = makeTool("my_tool");
|
|
517
|
+
mockFetch
|
|
518
|
+
.mockResolvedValueOnce(toolCallSSEResponse([
|
|
519
|
+
{ call_id: "fc-1", name: "my_tool", args: { input: "x" } },
|
|
520
|
+
]))
|
|
521
|
+
.mockResolvedValueOnce(textSSEResponse("Final"));
|
|
522
|
+
const session = await provider.createSession({});
|
|
523
|
+
const events = await collect(provider.send(session, "use tool", [tool]));
|
|
524
|
+
const toolCallEvents = events.filter((e) => e.type === "tool_call");
|
|
525
|
+
expect(toolCallEvents.length).toBeGreaterThanOrEqual(1);
|
|
526
|
+
const tc = toolCallEvents.find((e) => e.name === "my_tool");
|
|
527
|
+
expect(tc).toBeDefined();
|
|
528
|
+
expect(tc.args).toEqual({ input: "x" });
|
|
529
|
+
});
|
|
530
|
+
it("tool execution with results", async () => {
|
|
531
|
+
const tool = makeTool("my_tool", vi.fn().mockResolvedValue("executed!"));
|
|
532
|
+
mockFetch
|
|
533
|
+
.mockResolvedValueOnce(toolCallSSEResponse([{ call_id: "fc-1", name: "my_tool", args: {} }]))
|
|
534
|
+
.mockResolvedValueOnce(textSSEResponse("Ok"));
|
|
535
|
+
const session = await provider.createSession({});
|
|
536
|
+
const events = await collect(provider.send(session, "run", [tool]));
|
|
537
|
+
const resultEvents = events.filter((e) => e.type === "tool_result" && e.callId === "fc-1");
|
|
538
|
+
expect(resultEvents).toHaveLength(1);
|
|
539
|
+
expect(resultEvents[0].result).toBe("executed!");
|
|
540
|
+
});
|
|
541
|
+
it("unknown tool error", async () => {
|
|
542
|
+
mockFetch
|
|
543
|
+
.mockResolvedValueOnce(toolCallSSEResponse([{ call_id: "fc-1", name: "nonexistent", args: {} }]))
|
|
544
|
+
.mockResolvedValueOnce(textSSEResponse("Ok"));
|
|
545
|
+
const session = await provider.createSession({});
|
|
546
|
+
const events = await collect(provider.send(session, "run", []));
|
|
547
|
+
const resultEvents = events.filter((e) => e.type === "tool_result" && e.callId === "fc-1");
|
|
548
|
+
expect(resultEvents[0].result).toBe("Unknown tool: nonexistent");
|
|
549
|
+
expect(resultEvents[0].isError).toBe(true);
|
|
550
|
+
});
|
|
551
|
+
it("tool timeout", async () => {
|
|
552
|
+
vi.useFakeTimers();
|
|
553
|
+
const neverResolve = new Promise(() => { });
|
|
554
|
+
const tool = makeTool("slow", vi.fn().mockReturnValue(neverResolve));
|
|
555
|
+
mockFetch
|
|
556
|
+
.mockResolvedValueOnce(toolCallSSEResponse([{ call_id: "fc-1", name: "slow", args: {} }]))
|
|
557
|
+
.mockResolvedValueOnce(textSSEResponse("Ok"));
|
|
558
|
+
const session = await provider.createSession({});
|
|
559
|
+
const genPromise = collect(provider.send(session, "run", [tool]));
|
|
560
|
+
await vi.advanceTimersByTimeAsync(300_001);
|
|
561
|
+
const events = await genPromise;
|
|
562
|
+
vi.useRealTimers();
|
|
563
|
+
const resultEvents = events.filter((e) => e.type === "tool_result");
|
|
564
|
+
expect(resultEvents[0].result).toContain("timed out");
|
|
565
|
+
expect(resultEvents[0].isError).toBe(true);
|
|
566
|
+
});
|
|
567
|
+
it("server web_search calls yield events", async () => {
|
|
568
|
+
const events = [
|
|
569
|
+
{ type: "response.output_text.delta", delta: "Search result" },
|
|
570
|
+
{
|
|
571
|
+
type: "response.output_item.done",
|
|
572
|
+
item: { type: "web_search_call", id: "ws-1" },
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
type: "response.output_item.done",
|
|
576
|
+
item: {
|
|
577
|
+
type: "message",
|
|
578
|
+
role: "assistant",
|
|
579
|
+
content: [{ type: "output_text", text: "Search result" }],
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
{
|
|
583
|
+
type: "response.completed",
|
|
584
|
+
response: { usage: { input_tokens: 10, output_tokens: 5 } },
|
|
585
|
+
},
|
|
586
|
+
];
|
|
587
|
+
mockFetch.mockResolvedValueOnce(mockSSEResponse(events));
|
|
588
|
+
const webTool = {
|
|
589
|
+
name: "web_search",
|
|
590
|
+
description: "search",
|
|
591
|
+
parameters: { type: "object", properties: {}, required: [] },
|
|
592
|
+
execute: vi.fn(),
|
|
593
|
+
};
|
|
594
|
+
const session = await provider.createSession({});
|
|
595
|
+
const result = await collect(provider.send(session, "search X", [webTool]));
|
|
596
|
+
const toolCalls = result.filter((e) => e.type === "tool_call" && e.name === "web_search");
|
|
597
|
+
expect(toolCalls).toHaveLength(1);
|
|
598
|
+
const toolResults = result.filter((e) => e.type === "tool_result" && e.callId === "ws-1");
|
|
599
|
+
expect(toolResults[0].result).toBe("(server-executed)");
|
|
600
|
+
});
|
|
601
|
+
it("retry on transient error (429)", async () => {
|
|
602
|
+
mockFetch
|
|
603
|
+
.mockResolvedValueOnce(new Response("rate limited", { status: 429 }))
|
|
604
|
+
.mockResolvedValueOnce(textSSEResponse("OK after retry"));
|
|
605
|
+
vi.useFakeTimers();
|
|
606
|
+
const session = await provider.createSession({});
|
|
607
|
+
const genPromise = collect(provider.send(session, "hi", []));
|
|
608
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
609
|
+
const events = await genPromise;
|
|
610
|
+
vi.useRealTimers();
|
|
611
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
612
|
+
expect(events.some((e) => e.type === "done")).toBe(true);
|
|
613
|
+
});
|
|
614
|
+
it("retry on transient error (500)", async () => {
|
|
615
|
+
mockFetch
|
|
616
|
+
.mockResolvedValueOnce(new Response("server error", { status: 500 }))
|
|
617
|
+
.mockResolvedValueOnce(textSSEResponse("OK"));
|
|
618
|
+
vi.useFakeTimers();
|
|
619
|
+
const session = await provider.createSession({});
|
|
620
|
+
const genPromise = collect(provider.send(session, "hi", []));
|
|
621
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
622
|
+
const events = await genPromise;
|
|
623
|
+
vi.useRealTimers();
|
|
624
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
625
|
+
});
|
|
626
|
+
it("response.failed with server_error is transient", async () => {
|
|
627
|
+
const failedResp = mockSSEResponse([
|
|
628
|
+
{
|
|
629
|
+
type: "response.failed",
|
|
630
|
+
response: { error: { code: "server_error", message: "Server error" } },
|
|
631
|
+
},
|
|
632
|
+
]);
|
|
633
|
+
mockFetch
|
|
634
|
+
.mockResolvedValueOnce(failedResp)
|
|
635
|
+
.mockResolvedValueOnce(textSSEResponse("OK"));
|
|
636
|
+
vi.useFakeTimers();
|
|
637
|
+
const session = await provider.createSession({});
|
|
638
|
+
const genPromise = collect(provider.send(session, "hi", []));
|
|
639
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
640
|
+
const events = await genPromise;
|
|
641
|
+
vi.useRealTimers();
|
|
642
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
643
|
+
expect(events.some((e) => e.type === "done")).toBe(true);
|
|
644
|
+
});
|
|
645
|
+
it("response.failed with rate_limit_exceeded is transient", async () => {
|
|
646
|
+
const failedResp = mockSSEResponse([
|
|
647
|
+
{
|
|
648
|
+
type: "response.failed",
|
|
649
|
+
response: {
|
|
650
|
+
error: { code: "rate_limit_exceeded", message: "Too many requests" },
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
]);
|
|
654
|
+
mockFetch
|
|
655
|
+
.mockResolvedValueOnce(failedResp)
|
|
656
|
+
.mockResolvedValueOnce(textSSEResponse("OK"));
|
|
657
|
+
vi.useFakeTimers();
|
|
658
|
+
const session = await provider.createSession({});
|
|
659
|
+
const genPromise = collect(provider.send(session, "hi", []));
|
|
660
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
661
|
+
const events = await genPromise;
|
|
662
|
+
vi.useRealTimers();
|
|
663
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
664
|
+
});
|
|
665
|
+
it("response.failed with other code throws", async () => {
|
|
666
|
+
const failedResp = mockSSEResponse([
|
|
667
|
+
{
|
|
668
|
+
type: "response.failed",
|
|
669
|
+
response: {
|
|
670
|
+
error: { code: "invalid_request", message: "Bad request" },
|
|
671
|
+
},
|
|
672
|
+
},
|
|
673
|
+
]);
|
|
674
|
+
mockFetch.mockResolvedValueOnce(failedResp);
|
|
675
|
+
const session = await provider.createSession({});
|
|
676
|
+
await expect(collect(provider.send(session, "hi", []))).rejects.toThrow("Bad request");
|
|
677
|
+
});
|
|
678
|
+
it("non-transient HTTP error (400) throws immediately", async () => {
|
|
679
|
+
mockFetch.mockResolvedValueOnce(new Response("bad request", { status: 400 }));
|
|
680
|
+
const session = await provider.createSession({});
|
|
681
|
+
await expect(collect(provider.send(session, "hi", []))).rejects.toThrow("400");
|
|
682
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
683
|
+
});
|
|
684
|
+
it("empty response yields done with no usage", async () => {
|
|
685
|
+
// An empty SSE stream produces a complete event but with no usage
|
|
686
|
+
const emptyStream = new ReadableStream({
|
|
687
|
+
start(controller) {
|
|
688
|
+
controller.close();
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
mockFetch.mockResolvedValueOnce(new Response(emptyStream, {
|
|
692
|
+
status: 200,
|
|
693
|
+
headers: { "content-type": "text/event-stream" },
|
|
694
|
+
}));
|
|
695
|
+
const session = await provider.createSession({});
|
|
696
|
+
const events = await collect(provider.send(session, "hi", []));
|
|
697
|
+
const doneEvent = events.find((e) => e.type === "done");
|
|
698
|
+
expect(doneEvent).toBeDefined();
|
|
699
|
+
expect(doneEvent.usage).toBeUndefined();
|
|
700
|
+
});
|
|
701
|
+
it("null body response throws error", async () => {
|
|
702
|
+
mockFetch.mockResolvedValueOnce(new Response(null, {
|
|
703
|
+
status: 200,
|
|
704
|
+
headers: { "content-type": "text/event-stream" },
|
|
705
|
+
}));
|
|
706
|
+
const session = await provider.createSession({});
|
|
707
|
+
await expect(collect(provider.send(session, "hi", []))).rejects.toThrow("No response body");
|
|
708
|
+
});
|
|
709
|
+
it("tool loop works correctly", async () => {
|
|
710
|
+
const tool = makeTool("read_file", vi.fn().mockResolvedValue("file content"));
|
|
711
|
+
mockFetch.mockResolvedValueOnce(toolCallSSEResponse([
|
|
712
|
+
{ call_id: "fc-1", name: "read_file", args: { input: "main.py" } },
|
|
713
|
+
]));
|
|
714
|
+
mockFetch.mockResolvedValueOnce(textSSEResponse("Here is what I found"));
|
|
715
|
+
const session = await provider.createSession({});
|
|
716
|
+
const events = await collect(provider.send(session, "read main.py", [tool]));
|
|
717
|
+
const types = events.map((e) => e.type);
|
|
718
|
+
expect(types).toContain("tool_call");
|
|
719
|
+
expect(types).toContain("tool_result");
|
|
720
|
+
expect(types).toContain("text");
|
|
721
|
+
expect(types).toContain("done");
|
|
722
|
+
expect(types[types.length - 1]).toBe("done");
|
|
723
|
+
});
|
|
724
|
+
it("tool execution error is caught", async () => {
|
|
725
|
+
const tool = makeTool("broken", vi.fn().mockRejectedValue(new Error("tool broke")));
|
|
726
|
+
mockFetch
|
|
727
|
+
.mockResolvedValueOnce(toolCallSSEResponse([{ call_id: "fc-1", name: "broken", args: {} }]))
|
|
728
|
+
.mockResolvedValueOnce(textSSEResponse("Ok"));
|
|
729
|
+
const session = await provider.createSession({});
|
|
730
|
+
const events = await collect(provider.send(session, "run", [tool]));
|
|
731
|
+
const resultEvents = events.filter((e) => e.type === "tool_result");
|
|
732
|
+
expect(resultEvents[0].result).toContain("Error:");
|
|
733
|
+
expect(resultEvents[0].isError).toBe(true);
|
|
734
|
+
});
|
|
735
|
+
it("send includes instructions in request body", async () => {
|
|
736
|
+
const session = await provider.createSession({ systemPrompt: "Be helpful" });
|
|
737
|
+
await collect(provider.send(session, "hi", []));
|
|
738
|
+
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
739
|
+
expect(requestBody.instructions).toBe("Be helpful");
|
|
740
|
+
});
|
|
741
|
+
it("send uses default instructions when none set", async () => {
|
|
742
|
+
const session = await provider.createSession({});
|
|
743
|
+
await collect(provider.send(session, "hi", []));
|
|
744
|
+
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
745
|
+
expect(requestBody.instructions).toBe("You are a helpful assistant.");
|
|
746
|
+
});
|
|
747
|
+
it("send sets correct headers", async () => {
|
|
748
|
+
const session = await provider.createSession({});
|
|
749
|
+
await collect(provider.send(session, "hi", []));
|
|
750
|
+
const headers = mockFetch.mock.calls[0][1].headers;
|
|
751
|
+
expect(headers.Authorization).toBe("Bearer at-test");
|
|
752
|
+
expect(headers["Content-Type"]).toBe("application/json");
|
|
753
|
+
expect(headers.originator).toBe("codex_cli_rs");
|
|
754
|
+
});
|
|
755
|
+
it("multiple tool calls in single response", async () => {
|
|
756
|
+
const tool1 = makeTool("tool_a", vi.fn().mockResolvedValue("result-a"));
|
|
757
|
+
const tool2 = makeTool("tool_b", vi.fn().mockResolvedValue("result-b"));
|
|
758
|
+
mockFetch
|
|
759
|
+
.mockResolvedValueOnce(toolCallSSEResponse([
|
|
760
|
+
{ call_id: "fc-1", name: "tool_a", args: { input: "1" } },
|
|
761
|
+
{ call_id: "fc-2", name: "tool_b", args: { input: "2" } },
|
|
762
|
+
]))
|
|
763
|
+
.mockResolvedValueOnce(textSSEResponse("Both done"));
|
|
764
|
+
const session = await provider.createSession({});
|
|
765
|
+
const events = await collect(provider.send(session, "run both", [tool1, tool2]));
|
|
766
|
+
const toolCalls = events.filter((e) => e.type === "tool_call");
|
|
767
|
+
expect(toolCalls.length).toBeGreaterThanOrEqual(2);
|
|
768
|
+
});
|
|
769
|
+
});
|
|
770
|
+
// ========== Auth ==========
|
|
771
|
+
describe("Auth", () => {
|
|
772
|
+
it("isAuthenticated delegates to authManager", async () => {
|
|
773
|
+
const result = await provider.isAuthenticated();
|
|
774
|
+
expect(result).toBe(true);
|
|
775
|
+
expect(auth.isAuthenticated).toHaveBeenCalledWith("openai");
|
|
776
|
+
});
|
|
777
|
+
it("isAuthenticated returns false when no credentials", async () => {
|
|
778
|
+
auth.isAuthenticated.mockReturnValue(false);
|
|
779
|
+
const result = await provider.isAuthenticated();
|
|
780
|
+
expect(result).toBe(false);
|
|
781
|
+
});
|
|
782
|
+
it("authenticate with valid non-expired token returns immediately", async () => {
|
|
783
|
+
auth.getCredentials.mockResolvedValue({
|
|
784
|
+
method: "oauth",
|
|
785
|
+
provider: "openai",
|
|
786
|
+
accessToken: "at-valid",
|
|
787
|
+
});
|
|
788
|
+
auth.tokenStore.isExpired.mockReturnValue(false);
|
|
789
|
+
await provider.authenticate();
|
|
790
|
+
expect(mockOAuthInstance.login).not.toHaveBeenCalled();
|
|
791
|
+
expect(mockOAuthInstance.refresh).not.toHaveBeenCalled();
|
|
792
|
+
});
|
|
793
|
+
it("authenticate with expired token tries refresh", async () => {
|
|
794
|
+
auth.getCredentials.mockResolvedValue({
|
|
795
|
+
method: "oauth",
|
|
796
|
+
provider: "openai",
|
|
797
|
+
accessToken: "at-old",
|
|
798
|
+
refreshToken: "rt-old",
|
|
799
|
+
});
|
|
800
|
+
auth.tokenStore.isExpired.mockReturnValue(true);
|
|
801
|
+
mockOAuthInstance.refresh.mockResolvedValue({
|
|
802
|
+
accessToken: "at-new",
|
|
803
|
+
refreshToken: "rt-new",
|
|
804
|
+
expiresAt: Date.now() + 3600_000,
|
|
805
|
+
});
|
|
806
|
+
await provider.authenticate();
|
|
807
|
+
expect(mockOAuthInstance.refresh).toHaveBeenCalledWith("rt-old");
|
|
808
|
+
expect(auth.setOAuthTokens).toHaveBeenCalled();
|
|
809
|
+
});
|
|
810
|
+
it("authenticate with failed refresh does full login", async () => {
|
|
811
|
+
auth.getCredentials.mockResolvedValue({
|
|
812
|
+
method: "oauth",
|
|
813
|
+
provider: "openai",
|
|
814
|
+
accessToken: "at-old",
|
|
815
|
+
refreshToken: "rt-old",
|
|
816
|
+
});
|
|
817
|
+
auth.tokenStore.isExpired.mockReturnValue(true);
|
|
818
|
+
mockOAuthInstance.refresh.mockRejectedValue(new Error("refresh failed"));
|
|
819
|
+
await provider.authenticate();
|
|
820
|
+
expect(mockOAuthInstance.login).toHaveBeenCalled();
|
|
821
|
+
});
|
|
822
|
+
it("authenticate with no credentials does full login", async () => {
|
|
823
|
+
auth.getCredentials.mockResolvedValue(null);
|
|
824
|
+
await provider.authenticate();
|
|
825
|
+
expect(mockOAuthInstance.login).toHaveBeenCalled();
|
|
826
|
+
});
|
|
827
|
+
it("authenticate with non-expired creds and no refresh token returns immediately", async () => {
|
|
828
|
+
auth.getCredentials.mockResolvedValue({
|
|
829
|
+
method: "oauth",
|
|
830
|
+
provider: "openai",
|
|
831
|
+
accessToken: "at-valid",
|
|
832
|
+
});
|
|
833
|
+
auth.tokenStore.isExpired.mockReturnValue(false);
|
|
834
|
+
await provider.authenticate();
|
|
835
|
+
expect(mockOAuthInstance.login).not.toHaveBeenCalled();
|
|
836
|
+
});
|
|
837
|
+
it("send throws without access token", async () => {
|
|
838
|
+
const noTokenAuth = mockAuthManager({
|
|
839
|
+
method: "oauth",
|
|
840
|
+
provider: "openai",
|
|
841
|
+
accessToken: null,
|
|
842
|
+
});
|
|
843
|
+
const p = new OpenAIProvider(noTokenAuth, store);
|
|
844
|
+
const session = await p.createSession({});
|
|
845
|
+
await expect(collect(p.send(session, "hi", []))).rejects.toThrow("Not authenticated");
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
// ========== Configuration ==========
|
|
849
|
+
describe("Configuration", () => {
|
|
850
|
+
it("currentModel defaults to gpt-5.4", () => {
|
|
851
|
+
expect(provider.currentModel).toBe("gpt-5.4");
|
|
852
|
+
});
|
|
853
|
+
it("reasoningEffort defaults to medium", () => {
|
|
854
|
+
expect(provider.reasoningEffort).toBe("medium");
|
|
855
|
+
});
|
|
856
|
+
it("tool definitions use function type format", async () => {
|
|
857
|
+
const tool = makeTool("my_tool");
|
|
858
|
+
const session = await provider.createSession({});
|
|
859
|
+
await collect(provider.send(session, "hi", [tool]));
|
|
860
|
+
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
861
|
+
const funcTool = requestBody.tools.find((t) => t.name === "my_tool");
|
|
862
|
+
expect(funcTool).toBeDefined();
|
|
863
|
+
expect(funcTool.type).toBe("function");
|
|
864
|
+
expect(funcTool.parameters).toBeDefined();
|
|
865
|
+
});
|
|
866
|
+
it("reasoning effort is sent in request body", async () => {
|
|
867
|
+
provider.reasoningEffort = "high";
|
|
868
|
+
const session = await provider.createSession({});
|
|
869
|
+
await collect(provider.send(session, "hi", []));
|
|
870
|
+
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
871
|
+
expect(requestBody.reasoning).toEqual({ effort: "high" });
|
|
872
|
+
});
|
|
873
|
+
it("currentModel can be changed and is sent in request", async () => {
|
|
874
|
+
provider.currentModel = "gpt-5.3-codex";
|
|
875
|
+
const session = await provider.createSession({});
|
|
876
|
+
await collect(provider.send(session, "hi", []));
|
|
877
|
+
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
878
|
+
expect(requestBody.model).toBe("gpt-5.3-codex");
|
|
879
|
+
});
|
|
880
|
+
});
|
|
881
|
+
// ========== SSE Parsing ==========
|
|
882
|
+
describe("SSE Parsing (parseSSEStream)", () => {
|
|
883
|
+
it("handles response.output_text.delta", async () => {
|
|
884
|
+
const resp = mockSSEResponse([
|
|
885
|
+
{ type: "response.output_text.delta", delta: "Hello" },
|
|
886
|
+
{
|
|
887
|
+
type: "response.completed",
|
|
888
|
+
response: { usage: { input_tokens: 5, output_tokens: 3 } },
|
|
889
|
+
},
|
|
890
|
+
]);
|
|
891
|
+
const events = [];
|
|
892
|
+
for await (const e of provider.parseSSEStream(resp)) {
|
|
893
|
+
events.push(e);
|
|
894
|
+
}
|
|
895
|
+
expect(events.some((e) => e.kind === "delta" && e.text === "Hello")).toBe(true);
|
|
896
|
+
});
|
|
897
|
+
it("handles response.output_item.done for messages", async () => {
|
|
898
|
+
const resp = mockSSEResponse([
|
|
899
|
+
{
|
|
900
|
+
type: "response.output_item.done",
|
|
901
|
+
item: {
|
|
902
|
+
type: "message",
|
|
903
|
+
role: "assistant",
|
|
904
|
+
content: [{ type: "output_text", text: "Hi" }],
|
|
905
|
+
},
|
|
906
|
+
},
|
|
907
|
+
{
|
|
908
|
+
type: "response.completed",
|
|
909
|
+
response: { usage: { input_tokens: 5, output_tokens: 3 } },
|
|
910
|
+
},
|
|
911
|
+
]);
|
|
912
|
+
const events = [];
|
|
913
|
+
for await (const e of provider.parseSSEStream(resp)) {
|
|
914
|
+
events.push(e);
|
|
915
|
+
}
|
|
916
|
+
const complete = events.find((e) => e.kind === "complete");
|
|
917
|
+
expect(complete).toBeDefined();
|
|
918
|
+
expect(complete.responseItems.some((i) => i.type === "message")).toBe(true);
|
|
919
|
+
});
|
|
920
|
+
it("handles response.output_item.done for function_call", async () => {
|
|
921
|
+
const resp = mockSSEResponse([
|
|
922
|
+
{
|
|
923
|
+
type: "response.output_item.done",
|
|
924
|
+
item: {
|
|
925
|
+
type: "function_call",
|
|
926
|
+
call_id: "fc-1",
|
|
927
|
+
name: "test_tool",
|
|
928
|
+
arguments: '{"input":"x"}',
|
|
929
|
+
},
|
|
930
|
+
},
|
|
931
|
+
{
|
|
932
|
+
type: "response.completed",
|
|
933
|
+
response: { usage: { input_tokens: 5, output_tokens: 3 } },
|
|
934
|
+
},
|
|
935
|
+
]);
|
|
936
|
+
const events = [];
|
|
937
|
+
for await (const e of provider.parseSSEStream(resp)) {
|
|
938
|
+
events.push(e);
|
|
939
|
+
}
|
|
940
|
+
const complete = events.find((e) => e.kind === "complete");
|
|
941
|
+
expect(complete.toolCalls).toHaveLength(1);
|
|
942
|
+
expect(complete.toolCalls[0].name).toBe("test_tool");
|
|
943
|
+
expect(complete.toolCalls[0].args).toEqual({ input: "x" });
|
|
944
|
+
});
|
|
945
|
+
it("handles response.output_item.done for web_search_call", async () => {
|
|
946
|
+
const resp = mockSSEResponse([
|
|
947
|
+
{
|
|
948
|
+
type: "response.output_item.done",
|
|
949
|
+
item: { type: "web_search_call", id: "ws-1" },
|
|
950
|
+
},
|
|
951
|
+
{
|
|
952
|
+
type: "response.completed",
|
|
953
|
+
response: { usage: { input_tokens: 5, output_tokens: 3 } },
|
|
954
|
+
},
|
|
955
|
+
]);
|
|
956
|
+
const events = [];
|
|
957
|
+
for await (const e of provider.parseSSEStream(resp)) {
|
|
958
|
+
events.push(e);
|
|
959
|
+
}
|
|
960
|
+
const complete = events.find((e) => e.kind === "complete");
|
|
961
|
+
expect(complete.serverToolIds).toContain("ws-1");
|
|
962
|
+
});
|
|
963
|
+
it("handles response.completed with usage", async () => {
|
|
964
|
+
const resp = mockSSEResponse([
|
|
965
|
+
{
|
|
966
|
+
type: "response.completed",
|
|
967
|
+
response: { usage: { input_tokens: 100, output_tokens: 50 } },
|
|
968
|
+
},
|
|
969
|
+
]);
|
|
970
|
+
const events = [];
|
|
971
|
+
for await (const e of provider.parseSSEStream(resp)) {
|
|
972
|
+
events.push(e);
|
|
973
|
+
}
|
|
974
|
+
const complete = events.find((e) => e.kind === "complete");
|
|
975
|
+
expect(complete.usage).toEqual({ input_tokens: 100, output_tokens: 50 });
|
|
976
|
+
});
|
|
977
|
+
it("handles response.failed", async () => {
|
|
978
|
+
const resp = mockSSEResponse([
|
|
979
|
+
{
|
|
980
|
+
type: "response.failed",
|
|
981
|
+
response: {
|
|
982
|
+
error: { code: "invalid_request", message: "Invalid" },
|
|
983
|
+
},
|
|
984
|
+
},
|
|
985
|
+
]);
|
|
986
|
+
const gen = provider.parseSSEStream(resp);
|
|
987
|
+
await expect(collect(gen)).rejects.toThrow("Invalid");
|
|
988
|
+
});
|
|
989
|
+
it("creates assistant message if none emitted from items", async () => {
|
|
990
|
+
// Only deltas, no output_item.done with assistant message
|
|
991
|
+
const resp = mockSSEResponse([
|
|
992
|
+
{ type: "response.output_text.delta", delta: "Synthesized" },
|
|
993
|
+
{
|
|
994
|
+
type: "response.completed",
|
|
995
|
+
response: { usage: { input_tokens: 5, output_tokens: 3 } },
|
|
996
|
+
},
|
|
997
|
+
]);
|
|
998
|
+
const events = [];
|
|
999
|
+
for await (const e of provider.parseSSEStream(resp)) {
|
|
1000
|
+
events.push(e);
|
|
1001
|
+
}
|
|
1002
|
+
const complete = events.find((e) => e.kind === "complete");
|
|
1003
|
+
expect(complete.responseItems.some((i) => i.type === "message" && i.role === "assistant")).toBe(true);
|
|
1004
|
+
});
|
|
1005
|
+
it("function_call with malformed JSON arguments uses empty object", async () => {
|
|
1006
|
+
const resp = mockSSEResponse([
|
|
1007
|
+
{
|
|
1008
|
+
type: "response.output_item.done",
|
|
1009
|
+
item: {
|
|
1010
|
+
type: "function_call",
|
|
1011
|
+
call_id: "fc-1",
|
|
1012
|
+
name: "test_tool",
|
|
1013
|
+
arguments: "not-valid-json",
|
|
1014
|
+
},
|
|
1015
|
+
},
|
|
1016
|
+
{
|
|
1017
|
+
type: "response.completed",
|
|
1018
|
+
response: { usage: { input_tokens: 5, output_tokens: 3 } },
|
|
1019
|
+
},
|
|
1020
|
+
]);
|
|
1021
|
+
const events = [];
|
|
1022
|
+
for await (const e of provider.parseSSEStream(resp)) {
|
|
1023
|
+
events.push(e);
|
|
1024
|
+
}
|
|
1025
|
+
const complete = events.find((e) => e.kind === "complete");
|
|
1026
|
+
expect(complete.toolCalls[0].args).toEqual({});
|
|
1027
|
+
});
|
|
1028
|
+
});
|
|
1029
|
+
// ========== Interrupt ==========
|
|
1030
|
+
describe("Interrupt", () => {
|
|
1031
|
+
it("interrupt aborts active request", () => {
|
|
1032
|
+
const ac = new AbortController();
|
|
1033
|
+
provider.abortController = ac;
|
|
1034
|
+
const session = makeSession();
|
|
1035
|
+
provider.interrupt(session);
|
|
1036
|
+
expect(ac.signal.aborted).toBe(true);
|
|
1037
|
+
expect(provider.abortController).toBeNull();
|
|
1038
|
+
});
|
|
1039
|
+
it("interrupt is safe when no active request", () => {
|
|
1040
|
+
const session = makeSession();
|
|
1041
|
+
provider.interrupt(session);
|
|
1042
|
+
expect(provider.abortController).toBeNull();
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
1045
|
+
// ========== Provider Metadata ==========
|
|
1046
|
+
describe("Provider Metadata", () => {
|
|
1047
|
+
it("name is openai", () => {
|
|
1048
|
+
expect(provider.name).toBe("openai");
|
|
1049
|
+
});
|
|
1050
|
+
it("displayName is OpenAI", () => {
|
|
1051
|
+
expect(provider.displayName).toBe("OpenAI");
|
|
1052
|
+
});
|
|
1053
|
+
});
|
|
1054
|
+
// ========== Web Search Tool Filtering ==========
|
|
1055
|
+
describe("Web Search Tool Filtering", () => {
|
|
1056
|
+
it("web_search tool is sent as special type, not function", async () => {
|
|
1057
|
+
const webTool = {
|
|
1058
|
+
name: "web_search",
|
|
1059
|
+
description: "Search",
|
|
1060
|
+
parameters: { type: "object", properties: {}, required: [] },
|
|
1061
|
+
execute: vi.fn(),
|
|
1062
|
+
};
|
|
1063
|
+
const regularTool = makeTool("regular");
|
|
1064
|
+
const session = await provider.createSession({});
|
|
1065
|
+
await collect(provider.send(session, "hi", [webTool, regularTool]));
|
|
1066
|
+
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
1067
|
+
const funcTools = requestBody.tools.filter((t) => t.type === "function");
|
|
1068
|
+
expect(funcTools.map((t) => t.name)).toContain("regular");
|
|
1069
|
+
expect(funcTools.map((t) => t.name)).not.toContain("web_search");
|
|
1070
|
+
expect(requestBody.tools.some((t) => t.type === "web_search")).toBe(true);
|
|
1071
|
+
});
|
|
1072
|
+
});
|
|
1073
|
+
// ========== Request Body ==========
|
|
1074
|
+
describe("Request Body Format", () => {
|
|
1075
|
+
it("includes all expected fields", async () => {
|
|
1076
|
+
const session = await provider.createSession({ systemPrompt: "test" });
|
|
1077
|
+
await collect(provider.send(session, "hi", []));
|
|
1078
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
1079
|
+
expect(body.model).toBe("gpt-5.4");
|
|
1080
|
+
expect(body.instructions).toBe("test");
|
|
1081
|
+
expect(body.stream).toBe(true);
|
|
1082
|
+
expect(body.store).toBe(false);
|
|
1083
|
+
expect(body.tool_choice).toBe("auto");
|
|
1084
|
+
expect(body.parallel_tool_calls).toBe(true);
|
|
1085
|
+
expect(body.reasoning).toEqual({ effort: "medium" });
|
|
1086
|
+
});
|
|
1087
|
+
});
|
|
1088
|
+
});
|
|
1089
|
+
//# sourceMappingURL=provider.test.js.map
|