@openchambery/web 1.19.20 → 2.0.0-beta.1
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/README.md +13 -11
- package/bin/cli-opencode.test.js +208 -0
- package/bin/cli.js +62 -9
- package/bin/lib/cli-startup.js +105 -2
- package/bin/lib/cli-startup.test.js +165 -0
- package/dist/assets/{AgentAvatar-NT957MuI.js → AgentAvatar-CLWWNu_Q.js} +1 -1
- package/dist/assets/AssistantView-BTCkm8yk.js +3 -0
- package/dist/assets/{DiagramView-C_JfbxNl.js → DiagramView-xuSJTnv7.js} +1 -1
- package/dist/assets/{FadeInOnReveal-CilRl4gc.js → FadeInOnReveal-Ov0D96d0.js} +1 -1
- package/dist/assets/{Icon-i5GiQRLq.js → Icon-DD-BuMfZ.js} +1 -1
- package/dist/assets/MarkdownRenderer-bc_p1H7l.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-B1iUwzOz.js → MarkdownRendererImpl-SKi4oG0c.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-BPtMj3-X.js → MarkstreamRendererImpl-CnkodGJW.js} +1 -1
- package/dist/assets/MobileDetailNavigation-Bnx59U_J.js +1 -0
- package/dist/assets/{MobileShareBridge-CKClHsWN.js → MobileShareBridge-BQlP4J-F.js} +1 -1
- package/dist/assets/MobileSurface-BXLeiZ4d.js +2 -0
- package/dist/assets/{MultiRunWindow-DiA24bnN.js → MultiRunWindow-ChZGob0v.js} +1 -1
- package/dist/assets/{OnboardingScreen-BGRLk4Wi.js → OnboardingScreen-DtQJxZG-.js} +1 -1
- package/dist/assets/{OverlayScrollbar-NuQI58XD.js → OverlayScrollbar-pMwxaCca.js} +1 -1
- package/dist/assets/SettingsView-B5I0KDTZ.js +25 -0
- package/dist/assets/{SettingsWindow-XlzRSS7E.js → SettingsWindow-DfnsFa8y.js} +1 -1
- package/dist/assets/{TerminalView-DdIaaLba.js → TerminalView-Dh9MWCpS.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BbwuLdXU.js → ToolOutputDialog-BejmjrRk.js} +1 -1
- package/dist/assets/{appThemeRegistry-ChzfD7mc.js → appThemeRegistry-COGfxTFV.js} +1 -1
- package/dist/assets/{button-DtzFDvij.js → button-D-rATN7w.js} +1 -1
- package/dist/assets/{collapsible-D-nzhcyF.js → collapsible-BXRd5_Hr.js} +1 -1
- package/dist/assets/{context-menu-BzVSzHLG.js → context-menu-jGtH6fR9.js} +1 -1
- package/dist/assets/{diagram-2YWalSb5.js → diagram-DQf7Y_uR.js} +1 -1
- package/dist/assets/{dialog-DGV7DsZC.js → dialog-bfbvR4VH.js} +1 -1
- package/dist/assets/es-c52LTHHe.js +15 -0
- package/dist/assets/{fileMentionTouchSelection-DyjmxUEN.js → fileMentionTouchSelection-BvrNCroG.js} +1 -1
- package/dist/assets/{fr-DsBZeN8Y.js → fr-PisFbUE8.js} +3 -3
- package/dist/assets/gitApi-ButwzQ0N.js +9 -0
- package/dist/assets/{input-CbB2HdLg.js → input-8r6F_Fza.js} +1 -1
- package/dist/assets/insertionBoundaries-DGs7S5zv.js +1 -0
- package/dist/assets/ja-C8Ikun3Z.js +15 -0
- package/dist/assets/ko-ClFys5o2.js +15 -0
- package/dist/assets/main-CCE_4y5F.js +2 -0
- package/dist/assets/main-Dn9rpsgl.js +169 -0
- package/dist/assets/{markdownHeightCache-C9ym9Tsb.js → markdownHeightCache-1Qo6y6IJ.js} +1 -1
- package/dist/assets/miniChat-BpjxVqgg.js +2 -0
- package/dist/assets/mobile-nMJCaNq1.js +2 -0
- package/dist/assets/{multirun-8fkF0Ji_.js → multirun-BvSG94mW.js} +1 -1
- package/dist/assets/pl-CnaKWN5F.js +15 -0
- package/dist/assets/platform-CWTIp02q.js +4 -0
- package/dist/assets/{projectMeta--y6tyjrv.js → projectMeta-DOe49dM_.js} +2 -2
- package/dist/assets/pt-BR-s8OpfRI6.js +15 -0
- package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +2 -0
- package/dist/assets/renderMobileApp-y-S8y8v_.js +28 -0
- package/dist/assets/{runtimeConfig-DYWtBrkW.js → runtimeConfig-BMvqa5we.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-BSWg99jc.js → runtimeEndpointReset-B6BxhvR0.js} +5 -5
- package/dist/assets/{sessionChangeSummary-BwupJF-Q.js → sessionChangeSummary-VVBlEXuh.js} +1 -1
- package/dist/assets/{sessionLookup-DP2X1qRR.js → sessionLookup-gh-hVRIc.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-CfgEJfB0.js → sortable-tabs-strip-CudoSgV4.js} +1 -1
- package/dist/assets/{src-DZ60kHBP.css → src-BzX5UI-c.css} +1 -1
- package/dist/assets/{store-B6dImdlY.js → store-DJczmczv.js} +4 -4
- package/dist/assets/{terminalApi-xjqssKke.js → terminalApi-DiF6JmBP.js} +1 -1
- package/dist/assets/{textarea-BnD799LW.js → textarea-3XR8sUfy.js} +1 -1
- package/dist/assets/{tooltip-Djn7MSOX.js → tooltip-CQvpK-UV.js} +1 -1
- package/dist/assets/uk-CaX0iqkB.js +15 -0
- package/dist/assets/{useAppFontEffects-DWLxpWlS.js → useAppFontEffects-Cpmpo7Wb.js} +45 -45
- package/dist/assets/{useDesktopWindowControlsLayout-BGPR_rq6.js → useDesktopWindowControlsLayout-B1GX59-L.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-CbV9_r1s.js → useEffectiveDirectory-CN6JoCFw.js} +1 -1
- package/dist/assets/{useFontPreferences-B1JYyzpK.js → useFontPreferences-DcbXQOkc.js} +1 -1
- package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +414 -0
- package/dist/assets/{useSessionAutoCleanup-Dnors7hB.js → useSessionAutoCleanup-BLjymaFt.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-DQqqrOBU.js → useWorkerHighlightedLines-Bqn4AiBL.js} +1 -1
- package/dist/assets/vendor-opencode-client-BOe2qpQb.js +12 -0
- package/dist/assets/{vendor-yaml-DIcAK3EC.js → vendor-yaml-C9KlJAlj.js} +37 -37
- package/dist/assets/zh-CN-20MYEjha.js +15 -0
- package/dist/assets/{zh-TW-BsOQbrwT.js → zh-TW-DiN04qTw.js} +3 -3
- package/dist/index.html +6 -6
- package/dist/mini-chat.html +6 -6
- package/dist/mobile.html +6 -6
- package/package.json +2 -2
- package/server/index.js +107 -0
- package/server/lib/assistants/DOCUMENTATION.md +18 -18
- package/server/lib/assistants/assign.js +2 -1
- package/server/lib/assistants/contact-store.js +37 -19
- package/server/lib/assistants/projection-history.test.js +207 -0
- package/server/lib/assistants/routes.js +5 -5
- package/server/lib/assistants/service.js +493 -123
- package/server/lib/assistants/service.test.js +203 -133
- package/server/lib/conversations/DOCUMENTATION.md +4 -4
- package/server/lib/conversations/service.js +86 -57
- package/server/lib/conversations/service.test.js +116 -100
- package/server/lib/event-stream/global-hub.js +2 -1
- package/server/lib/llm/DOCUMENTATION.md +71 -84
- package/server/lib/llm/catalog.js +111 -45
- package/server/lib/llm/catalog.test.js +121 -59
- package/server/lib/llm/completions.js +37 -12
- package/server/lib/llm/completions.test.js +45 -4
- package/server/lib/llm/generate.js +421 -450
- package/server/lib/llm/generate.test.js +553 -487
- package/server/lib/llm/routes.js +6 -4
- package/server/lib/message-queue/DOCUMENTATION.md +5 -5
- package/server/lib/message-queue/opencode-adapter.js +191 -104
- package/server/lib/message-queue/opencode-adapter.test.js +328 -58
- package/server/lib/message-queue/runtime.test.js +6 -6
- package/server/lib/message-queue/worker.js +7 -1
- package/server/lib/opencode/DOCUMENTATION.md +28 -5
- package/server/lib/opencode/config-entity-routes.js +84 -13
- package/server/lib/opencode/config-entity-routes.test.js +41 -10
- package/server/lib/opencode/ensure-cli.js +239 -0
- package/server/lib/opencode/ensure-cli.test.js +119 -0
- package/server/lib/opencode/env-runtime.js +193 -110
- package/server/lib/opencode/env-runtime.test.js +221 -17
- package/server/lib/opencode/feature-routes-runtime.js +37 -1
- package/server/lib/opencode/lifecycle.js +204 -70
- package/server/lib/opencode/lifecycle.test.js +376 -6
- package/server/lib/opencode/network-runtime.js +28 -16
- package/server/lib/opencode/network-runtime.test.js +68 -4
- package/server/lib/opencode/opencode2-pin.js +139 -0
- package/server/lib/opencode/opencode2-pin.test.js +87 -0
- package/server/lib/opencode/opencode2-sidecar.e2e.test.js +203 -0
- package/server/lib/opencode/plugin-compatibility.js +15 -0
- package/server/lib/opencode/plugin-compatibility.test.js +21 -0
- package/server/lib/opencode/plugins.js +4 -3
- package/server/lib/opencode/plugins.test.js +12 -0
- package/server/lib/opencode/proxy.js +153 -16
- package/server/lib/opencode/proxy.test.js +120 -0
- package/server/lib/opencode/routes.js +25 -32
- package/server/lib/opencode/routes.test.js +51 -52
- package/server/lib/opencode/server-utils-runtime.js +2 -0
- package/server/lib/opencode/skill-routes.js +6 -7
- package/server/lib/opencode/skill-routes.test.js +36 -5
- package/server/lib/opencode/v1-migration-gate.js +157 -0
- package/server/lib/opencode/v1-migration-gate.test.js +111 -0
- package/server/lib/opencode/v2-client.js +49 -0
- package/server/lib/opencode/watcher.js +2 -1
- package/server/lib/opencode/watcher.test.js +1 -1
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +71 -39
- package/server/lib/scheduled-tasks/managed-tool-route.js +31 -1
- package/server/lib/scheduled-tasks/managed-tool-route.test.js +23 -0
- package/server/lib/scheduled-tasks/routes.js +27 -0
- package/server/lib/scheduled-tasks/routes.test.js +94 -0
- package/server/lib/scheduled-tasks/runtime.js +331 -308
- package/server/lib/scheduled-tasks/runtime.test.js +530 -318
- package/server/lib/session-goal/DOCUMENTATION.md +65 -14
- package/server/lib/session-goal/capability.js +57 -0
- package/server/lib/session-goal/capability.test.js +33 -0
- package/server/lib/session-goal/routes.js +231 -2
- package/server/lib/session-goal/routes.test.js +211 -0
- package/server/lib/session-goal/runtime.js +46 -4
- package/server/lib/session-goal/runtime.test.js +62 -1
- package/server/lib/session-index/sync-runtime.js +20 -17
- package/server/lib/session-index/sync-runtime.test.js +9 -8
- package/server/lib/session-metadata/DOCUMENTATION.md +41 -0
- package/server/lib/session-metadata/routes.js +94 -0
- package/server/lib/session-metadata/routes.test.js +111 -0
- package/server/lib/session-metadata/session-metadata-store.js +235 -0
- package/server/lib/session-metadata/session-metadata-store.test.js +184 -0
- package/server/lib/session-turn-pages/DOCUMENTATION.md +3 -3
- package/server/lib/session-turn-pages/reconcile.service.js +1 -1
- package/server/lib/session-turn-pages/reconcile.service.test.js +16 -0
- package/server/lib/session-turn-pages/routes.js +204 -71
- package/server/lib/session-turn-pages/routes.test.js +285 -0
- package/server/lib/session-turn-pages/service.js +6 -2
- package/server/lib/session-turn-pages/service.test.js +34 -0
- package/server/lib/small-model/DOCUMENTATION.md +5 -4
- package/server/lib/small-model/catalog.js +56 -6
- package/server/lib/small-model/catalog.test.js +47 -0
- package/server/lib/small-model/index.js +3 -3
- package/server/lib/small-model/opencode-session.js +50 -206
- package/server/lib/small-model/opencode-session.test.js +58 -88
- package/server/opencode-proxy.test.js +61 -12
- package/dist/assets/AssistantView-BjvfkSRS.js +0 -3
- package/dist/assets/MarkdownRenderer-Cg82L3fN.js +0 -2
- package/dist/assets/MobileDetailNavigation-s_vvUD_F.js +0 -1
- package/dist/assets/MobileSurface-DHpXHbJp.js +0 -2
- package/dist/assets/SettingsView-l0J7pfYo.js +0 -25
- package/dist/assets/es-rqMiRP7-.js +0 -15
- package/dist/assets/gitApi-CHpibtno.js +0 -7
- package/dist/assets/insertionBoundaries-Yd1Lorjw.js +0 -1
- package/dist/assets/ja-Bgr8-dOg.js +0 -15
- package/dist/assets/ko-B_Gxh-0l.js +0 -15
- package/dist/assets/main-BoRXxmB3.js +0 -2
- package/dist/assets/main-DCDcdV5o.js +0 -169
- package/dist/assets/miniChat-12fxk5KR.js +0 -2
- package/dist/assets/mobile-CkZem9Ad.js +0 -2
- package/dist/assets/pl-DW2XJGC4.js +0 -15
- package/dist/assets/platform-0w_Q9_ji.js +0 -2
- package/dist/assets/pt-BR-CIsanf4T.js +0 -15
- package/dist/assets/renderElectronMiniChatApp-COFc0ZDJ.js +0 -2
- package/dist/assets/renderMobileApp-BKzx7JL3.js +0 -28
- package/dist/assets/uk-BPmPEUYJ.js +0 -15
- package/dist/assets/useMobileNavigationStore-DrB_awgk.js +0 -413
- package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
- package/dist/assets/zh-CN-R8-mDcIp.js +0 -15
|
@@ -1,24 +1,52 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
|
|
3
|
-
vi.mock('
|
|
4
|
-
const {
|
|
3
|
+
vi.mock('../opencode/v2-client.js', () => ({ makeOpenCodeV2Client: vi.fn() }));
|
|
4
|
+
const { makeOpenCodeV2Client } = await import('../opencode/v2-client.js');
|
|
5
5
|
const { createOpenCodeMessageQueueAdapter } = await import('./opencode-adapter.js');
|
|
6
6
|
const { createSessionTurnGate } = await import('./session-turn-gate.js');
|
|
7
7
|
|
|
8
|
+
const notFound = (status = 404) => Object.assign(new Error('not_found'), { status, code: 'not_found' });
|
|
9
|
+
const v2Client = ({ active = {}, list = { data: [] }, inbox = [], message } = {}) => ({
|
|
10
|
+
session: {
|
|
11
|
+
active: vi.fn(async () => active),
|
|
12
|
+
inbox: { list: vi.fn(async () => inbox) },
|
|
13
|
+
message: vi.fn(async ({ messageID }) => {
|
|
14
|
+
if (typeof message === 'function') return message(messageID);
|
|
15
|
+
if (message && typeof message === 'object' && messageID in message) return message[messageID];
|
|
16
|
+
throw notFound();
|
|
17
|
+
}),
|
|
18
|
+
},
|
|
19
|
+
message: { list: vi.fn(async () => list) },
|
|
20
|
+
});
|
|
21
|
+
|
|
8
22
|
describe('OpenCode message queue adapter', () => {
|
|
9
23
|
it('waits for readiness without forwarding worker options', async () => {
|
|
10
24
|
const waitForReady = vi.fn(); const adapter = createOpenCodeMessageQueueAdapter({ waitForReady });
|
|
11
25
|
await adapter.waitForReady({ signal: new AbortController().signal });
|
|
12
26
|
expect(waitForReady).toHaveBeenCalledWith();
|
|
13
27
|
});
|
|
14
|
-
it('captures runtime, materializes text and files, and
|
|
15
|
-
const
|
|
28
|
+
it('captures runtime, materializes text and files, and applies sendConfig before prompt', async () => {
|
|
29
|
+
const fetchMock = vi.fn(async () => ({ ok: true, status: 204, json: async () => ({}) }));
|
|
30
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
31
|
+
makeOpenCodeV2Client.mockReturnValue(v2Client());
|
|
16
32
|
let generation = 1;
|
|
17
33
|
const adapter = createOpenCodeMessageQueueAdapter({ waitForReady: vi.fn(), buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({ Authorization: 'secret' }), getRuntimeConfig: () => ({ apiBaseUrl: 'http://open.code' }), getRuntimeGeneration: () => generation, getSessionEligibility: () => ({ idle: true, settled: true }), getLatestMessageID: () => 'old', readAttachment: () => ({ type: 'file', url: 'file:///attachment' }) });
|
|
18
34
|
const runtime = adapter.captureRuntime(); const scope = { sessionID: 'session', directory: '/repo' }; expect(await adapter.checkEligibility(scope)).toMatchObject({ available: true, idle: true, settled: true, latestMessageID: 'old' });
|
|
19
35
|
const parts = await adapter.materializeAttachments({ content: 'text', attachments: [{}] }); expect(parts).toEqual([{ type: 'text', text: 'text' }, { type: 'file', url: 'file:///attachment' }]);
|
|
20
|
-
await adapter.send({ scope, messageID: 'message', runtime, sendConfig: { providerID: 'p', modelID: 'm', agent: 'a', variant: 'v' }, parts });
|
|
36
|
+
await adapter.send({ scope, messageID: 'message', runtime, sendConfig: { providerID: 'p', modelID: 'm', agent: 'a', variant: 'v' }, parts });
|
|
37
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
38
|
+
expect(String(fetchMock.mock.calls[0][0])).toContain('/api/session/session/agent');
|
|
39
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ agent: 'a' });
|
|
40
|
+
expect(String(fetchMock.mock.calls[1][0])).toContain('/api/session/session/model');
|
|
41
|
+
expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({ model: { id: 'm', providerID: 'p', variant: 'v' } });
|
|
42
|
+
expect(String(fetchMock.mock.calls[2][0])).toContain('/api/session/session/prompt');
|
|
43
|
+
const promptBody = JSON.parse(fetchMock.mock.calls[2][1].body);
|
|
44
|
+
expect(promptBody).toEqual(expect.objectContaining({ id: 'message', delivery: 'steer', text: 'text' }));
|
|
45
|
+
expect(promptBody.model).toBeUndefined();
|
|
46
|
+
expect(promptBody.agent).toBeUndefined();
|
|
47
|
+
expect(promptBody.variant).toBeUndefined();
|
|
21
48
|
generation = 2; expect(await adapter.send({ scope, runtime, sendConfig: { providerID: 'p', modelID: 'm' } })).toMatchObject({ code: 'runtime_stale' });
|
|
49
|
+
vi.unstubAllGlobals();
|
|
22
50
|
});
|
|
23
51
|
it('materializes Assistant attachment IDs in delivery order', async () => {
|
|
24
52
|
const readAttachment = vi.fn((attachment) => ({ type: 'file', url: `file:///${attachment.attachmentID}`, filename: attachment.filename }));
|
|
@@ -30,110 +58,352 @@ describe('OpenCode message queue adapter', () => {
|
|
|
30
58
|
expect(readAttachment).toHaveBeenCalledWith({ attachmentID: 'image', filename: 'image.png' }, expect.any(Object), expect.any(Object));
|
|
31
59
|
});
|
|
32
60
|
it('uses the injected upstream runtime URL and detects its changes', async () => {
|
|
33
|
-
const
|
|
34
|
-
|
|
61
|
+
const fetchMock = vi.fn(async () => ({ ok: true, status: 204, json: async () => ({}) }));
|
|
62
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
63
|
+
makeOpenCodeV2Client.mockReturnValue(v2Client());
|
|
35
64
|
let upstreamUrl = 'http://opencode-upstream:4096/';
|
|
36
65
|
const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => upstreamUrl, getOpenCodeAuthHeaders: () => ({}), getRuntimeConfig: () => ({ apiBaseUrl: upstreamUrl }), readAttachment: () => null });
|
|
37
66
|
const runtime = adapter.captureRuntime();
|
|
38
67
|
await adapter.send({ scope: { sessionID: 's', directory: '/repo' }, messageID: 'msg_1', runtime, sendConfig: { providerID: 'p', modelID: 'm' }, parts: [] });
|
|
39
|
-
|
|
68
|
+
const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt'));
|
|
69
|
+
expect(String(promptCall[0])).toContain('http://opencode-upstream:4096/api/session/s/prompt');
|
|
40
70
|
upstreamUrl = 'http://opencode-upstream:4097/';
|
|
41
71
|
expect(adapter.isCurrent(runtime)).toBe(false);
|
|
72
|
+
vi.unstubAllGlobals();
|
|
42
73
|
});
|
|
43
74
|
it('classifies SDK results and exact reconciliation matches without exposing transport details', async () => {
|
|
44
|
-
const
|
|
75
|
+
const fetchMock = vi.fn(async () => ({ ok: false, status: 503, json: async () => ({}) }));
|
|
76
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
77
|
+
makeOpenCodeV2Client.mockReturnValue(v2Client({ inbox: [{ id: 'wanted', sessionID: 's' }] }));
|
|
45
78
|
const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({}), getSessionEligibility: () => ({ idle: true, settled: true }), getLatestMessageID: () => null, readAttachment: () => null });
|
|
46
|
-
|
|
79
|
+
// Prompt-only path (no sendConfig) classifies transport status on the POST itself.
|
|
80
|
+
expect(await adapter.send({ scope: { sessionID: 's', directory: '/d' }, messageID: 'm', parts: [] })).toMatchObject({ kind: 'ambiguous', status: 503 });
|
|
81
|
+
expect(await adapter.findMessage({ sessionID: 's', directory: '/d' }, 'wanted')).toEqual({ found: true });
|
|
82
|
+
vi.unstubAllGlobals();
|
|
47
83
|
});
|
|
48
84
|
it('treats explicit 2xx empty bodies as ok and never treats undefined/malformed results as success', async () => {
|
|
49
|
-
const
|
|
50
|
-
.
|
|
51
|
-
.
|
|
52
|
-
.
|
|
53
|
-
.
|
|
54
|
-
.
|
|
55
|
-
.
|
|
56
|
-
|
|
85
|
+
const fetchMock = vi.fn()
|
|
86
|
+
.mockResolvedValueOnce({ ok: true, status: 204, json: async () => undefined })
|
|
87
|
+
.mockResolvedValueOnce({ ok: true, status: 202, json: async () => undefined })
|
|
88
|
+
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}) })
|
|
89
|
+
.mockResolvedValueOnce(undefined)
|
|
90
|
+
.mockResolvedValueOnce(null)
|
|
91
|
+
.mockResolvedValueOnce({});
|
|
92
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
57
93
|
const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({}), getRuntimeConfig: () => ({ apiBaseUrl: 'http://open.code' }), readAttachment: () => null });
|
|
58
|
-
const base = { scope: { sessionID: 's', directory: '/d' }, messageID: 'm',
|
|
94
|
+
const base = { scope: { sessionID: 's', directory: '/d' }, messageID: 'm', parts: [] };
|
|
59
95
|
await expect(adapter.send(base)).resolves.toMatchObject({ ok: true, status: 204 });
|
|
60
96
|
await expect(adapter.send(base)).resolves.toMatchObject({ ok: true, status: 202 });
|
|
61
97
|
await expect(adapter.send(base)).resolves.toMatchObject({ ok: true, status: 200 });
|
|
62
98
|
await expect(adapter.send(base)).resolves.toMatchObject({ ok: false, kind: 'ambiguous', code: 'malformed_result' });
|
|
63
99
|
await expect(adapter.send(base)).resolves.toMatchObject({ ok: false, kind: 'ambiguous', code: 'malformed_result' });
|
|
64
100
|
await expect(adapter.send(base)).resolves.toMatchObject({ ok: false, kind: 'ambiguous', code: 'malformed_result' });
|
|
101
|
+
vi.unstubAllGlobals();
|
|
65
102
|
});
|
|
66
103
|
it('reads failure status from error.status and classifies definitive 4xx failures', async () => {
|
|
67
|
-
const
|
|
68
|
-
.
|
|
69
|
-
.
|
|
70
|
-
.
|
|
71
|
-
.
|
|
72
|
-
|
|
104
|
+
const fetchMock = vi.fn()
|
|
105
|
+
.mockResolvedValueOnce({ ok: false, status: 400, json: async () => ({}) })
|
|
106
|
+
.mockResolvedValueOnce({ ok: false, status: 422, json: async () => ({}) })
|
|
107
|
+
.mockResolvedValueOnce({ ok: false, status: 429, json: async () => ({}) })
|
|
108
|
+
.mockResolvedValueOnce({ ok: false, status: 408, json: async () => ({}) })
|
|
109
|
+
.mockResolvedValueOnce({ ok: false, status: 409, json: async () => ({ _tag: 'SessionBusyError' }) });
|
|
110
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
73
111
|
const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({}), readAttachment: () => null });
|
|
74
|
-
const base = { scope: { sessionID: 's', directory: '/d' }, messageID: 'm',
|
|
112
|
+
const base = { scope: { sessionID: 's', directory: '/d' }, messageID: 'm', parts: [] };
|
|
75
113
|
await expect(adapter.send(base)).resolves.toMatchObject({ ok: false, kind: 'failed', status: 400 });
|
|
76
114
|
await expect(adapter.send(base)).resolves.toMatchObject({ ok: false, kind: 'failed', status: 422 });
|
|
77
115
|
await expect(adapter.send(base)).resolves.toMatchObject({ ok: false, kind: 'ambiguous', status: 429 });
|
|
78
116
|
await expect(adapter.send(base)).resolves.toMatchObject({ ok: false, kind: 'ambiguous', status: 408 });
|
|
117
|
+
await expect(adapter.send(base)).resolves.toMatchObject({ ok: false, kind: 'retry', code: 'session_busy' });
|
|
118
|
+
vi.unstubAllGlobals();
|
|
79
119
|
});
|
|
80
120
|
it('prefers client.v2.session.message exact lookup and treats 404 as found:false', async () => {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
121
|
+
const api = v2Client({
|
|
122
|
+
inbox: [],
|
|
123
|
+
message: { wanted: { id: 'wanted', type: 'user' } },
|
|
124
|
+
});
|
|
125
|
+
makeOpenCodeV2Client.mockReturnValue(api);
|
|
86
126
|
const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({}), readAttachment: () => null });
|
|
87
127
|
await expect(adapter.findMessage({ sessionID: 's', directory: '/d' }, 'wanted')).resolves.toEqual({ found: true });
|
|
88
128
|
await expect(adapter.findMessage({ sessionID: 's', directory: '/d' }, 'missing')).resolves.toEqual({ found: false });
|
|
89
|
-
expect(
|
|
90
|
-
expect(
|
|
91
|
-
});
|
|
92
|
-
it('
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
.mockReturnValueOnce({ session: { message: legacyMessage, messages, promptAsync: vi.fn() } })
|
|
99
|
-
.mockReturnValueOnce({ session: { message: legacyMessage, messages, promptAsync: vi.fn() } })
|
|
100
|
-
.mockReturnValueOnce({ session: { messages, promptAsync: vi.fn() } });
|
|
129
|
+
expect(api.session.inbox.list).toHaveBeenCalled();
|
|
130
|
+
expect(api.session.message).toHaveBeenCalledWith({ sessionID: 's', messageID: 'wanted' }, expect.any(Object));
|
|
131
|
+
});
|
|
132
|
+
it('reconciles inbox then session.message without session.messages or 1.x fallback', async () => {
|
|
133
|
+
const api = v2Client({
|
|
134
|
+
inbox: [{ id: 'legacy-hit' }],
|
|
135
|
+
message: { 'bounded-hit': { id: 'bounded-hit', type: 'user' } },
|
|
136
|
+
});
|
|
137
|
+
makeOpenCodeV2Client.mockReturnValue(api);
|
|
101
138
|
const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({}), readAttachment: () => null });
|
|
102
139
|
await expect(adapter.findMessage({ sessionID: 's', directory: '/d' }, 'legacy-hit')).resolves.toEqual({ found: true });
|
|
103
140
|
await expect(adapter.findMessage({ sessionID: 's', directory: '/d' }, 'missing')).resolves.toEqual({ found: false });
|
|
104
141
|
await expect(adapter.findMessage({ sessionID: 's', directory: '/d' }, 'bounded-hit')).resolves.toEqual({ found: true });
|
|
105
|
-
expect(
|
|
142
|
+
expect(api.session.status).toBeUndefined();
|
|
143
|
+
expect(api.session.messages).toBeUndefined();
|
|
144
|
+
expect(api.session.promptAsync).toBeUndefined();
|
|
106
145
|
});
|
|
107
|
-
it('
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
146
|
+
it('marks non-404 inbox/message failures unavailable instead of falling back', async () => {
|
|
147
|
+
const api = v2Client();
|
|
148
|
+
api.session.inbox.list.mockRejectedValue(Object.assign(new Error('unsupported'), { status: 405 }));
|
|
149
|
+
makeOpenCodeV2Client.mockReturnValue(api);
|
|
111
150
|
const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({}), readAttachment: () => null });
|
|
112
|
-
await expect(adapter.findMessage({ sessionID: 's', directory: '/d' }, 'wanted')).resolves.toEqual({
|
|
113
|
-
expect(messages).toHaveBeenCalledTimes(1);
|
|
151
|
+
await expect(adapter.findMessage({ sessionID: 's', directory: '/d' }, 'wanted')).resolves.toEqual({ unavailable: true });
|
|
114
152
|
});
|
|
115
|
-
it('uses absent session
|
|
116
|
-
const
|
|
153
|
+
it('uses absent session.active membership as idle and derives settlement from type and time.completed', async () => {
|
|
154
|
+
const list = vi.fn(async () => ({ data: [] }));
|
|
155
|
+
const active = vi.fn(async () => ({}));
|
|
156
|
+
makeOpenCodeV2Client.mockReturnValue({ session: { active, inbox: { list: vi.fn() }, message: vi.fn() }, message: { list } });
|
|
117
157
|
const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({}), readAttachment: () => null });
|
|
118
158
|
expect(await adapter.checkEligibility({ sessionID: 's', directory: '/d' })).toMatchObject({ idle: true, settled: true });
|
|
119
|
-
|
|
120
|
-
|
|
159
|
+
list.mockResolvedValueOnce({ data: [{ id: 'user', type: 'user', time: { created: 1 } }] });
|
|
160
|
+
expect((await adapter.checkEligibility({ sessionID: 's', directory: '/d' })).settled).toBe(false);
|
|
161
|
+
list.mockResolvedValueOnce({ data: [{ id: 'assistant', type: 'assistant', time: { completed: 1 } }] });
|
|
162
|
+
expect((await adapter.checkEligibility({ sessionID: 's', directory: '/d' })).settled).toBe(true);
|
|
163
|
+
expect(list).toHaveBeenCalledWith({ sessionID: 's', limit: 1, order: 'desc' }, expect.any(Object));
|
|
164
|
+
});
|
|
165
|
+
it('treats a sessionID present in session.active as not idle', async () => {
|
|
166
|
+
makeOpenCodeV2Client.mockReturnValue(v2Client({
|
|
167
|
+
active: { s: { type: 'running' } },
|
|
168
|
+
list: { data: [{ id: 'assistant', type: 'assistant', time: { completed: 1 } }] },
|
|
169
|
+
}));
|
|
170
|
+
const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({}), readAttachment: () => null });
|
|
171
|
+
expect(await adapter.checkEligibility({ sessionID: 's', directory: '/d' })).toMatchObject({ available: true, idle: false });
|
|
121
172
|
});
|
|
122
173
|
it('recovers an idle incomplete assistant tail without treating fetch failure as empty', async () => {
|
|
123
174
|
let now = 0; const turnGate = createSessionTurnGate({ clock: () => now });
|
|
124
|
-
const
|
|
125
|
-
const
|
|
175
|
+
const list = vi.fn(async () => ({ data: [{ id: 'assistant', type: 'assistant', time: { created: 1 } }] }));
|
|
176
|
+
const active = vi.fn(async () => ({}));
|
|
177
|
+
makeOpenCodeV2Client.mockReturnValue({ session: { active, inbox: { list: vi.fn() }, message: vi.fn() }, message: { list } });
|
|
126
178
|
const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({}), readAttachment: () => null, turnGate });
|
|
127
179
|
expect(await adapter.checkEligibility({ sessionID: 's', directory: '/d' })).toMatchObject({ settled: false, settlementReason: 'tail_unsettled' });
|
|
128
|
-
|
|
180
|
+
active.mockRejectedValueOnce(Object.assign(new Error('upstream'), { status: 500 })); now = 3_000;
|
|
129
181
|
await expect(adapter.checkEligibility({ sessionID: 's', directory: '/d' })).resolves.toEqual({ available: false, idle: false, settled: false });
|
|
130
182
|
expect(await adapter.checkEligibility({ sessionID: 's', directory: '/d' })).toMatchObject({ settled: false, settlementReason: 'tail_unsettled' });
|
|
131
183
|
now = 6_000;
|
|
132
184
|
expect(await adapter.checkEligibility({ sessionID: 's', directory: '/d' })).toMatchObject({ settled: true, settlementReason: 'stopped_assistant' });
|
|
133
185
|
});
|
|
134
186
|
it('marks malformed or failed authoritative eligibility reads unavailable', async () => {
|
|
135
|
-
|
|
187
|
+
makeOpenCodeV2Client.mockReturnValue({
|
|
188
|
+
session: { active: vi.fn(async () => { throw Object.assign(new Error('upstream'), { status: 500 }); }), inbox: { list: vi.fn() }, message: vi.fn() },
|
|
189
|
+
message: { list: vi.fn(async () => ({ data: [] })) },
|
|
190
|
+
});
|
|
136
191
|
const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({}), readAttachment: () => null });
|
|
137
192
|
await expect(adapter.checkEligibility({ sessionID: 's', directory: '/d' })).resolves.toEqual({ available: false, idle: false, settled: false });
|
|
138
193
|
});
|
|
139
194
|
});
|
|
195
|
+
|
|
196
|
+
describe('sendConfig serial boundary (v2 prompt contract)', () => {
|
|
197
|
+
it('applies different models in agent→model→prompt order per item', async () => {
|
|
198
|
+
const calls = [];
|
|
199
|
+
const fetchMock = vi.fn(async (url, init) => {
|
|
200
|
+
calls.push({ url: String(url), body: init?.body ? JSON.parse(init.body) : null });
|
|
201
|
+
return { ok: true, status: 204, json: async () => ({}) };
|
|
202
|
+
});
|
|
203
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
204
|
+
const adapter = createOpenCodeMessageQueueAdapter({
|
|
205
|
+
buildOpenCodeUrl: () => 'http://open.code/',
|
|
206
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
207
|
+
readAttachment: () => null,
|
|
208
|
+
});
|
|
209
|
+
const scope = { sessionID: 's', directory: '/d' };
|
|
210
|
+
await adapter.send({
|
|
211
|
+
scope, messageID: 'msg_a', parts: [{ type: 'text', text: 'first' }],
|
|
212
|
+
sendConfig: { providerID: 'openai', modelID: 'gpt-a', agent: 'build', variant: 'fast' },
|
|
213
|
+
});
|
|
214
|
+
await adapter.send({
|
|
215
|
+
scope, messageID: 'msg_b', parts: [{ type: 'text', text: 'second' }],
|
|
216
|
+
sendConfig: { providerID: 'anthropic', modelID: 'claude-b', agent: 'plan' },
|
|
217
|
+
});
|
|
218
|
+
expect(calls.map((c) => c.url.replace(/^.*\/api\/session\/s\//, ''))).toEqual([
|
|
219
|
+
'agent?directory=%2Fd',
|
|
220
|
+
'model?directory=%2Fd',
|
|
221
|
+
'prompt?directory=%2Fd',
|
|
222
|
+
'agent?directory=%2Fd',
|
|
223
|
+
'model?directory=%2Fd',
|
|
224
|
+
'prompt?directory=%2Fd',
|
|
225
|
+
]);
|
|
226
|
+
expect(calls[0].body).toEqual({ agent: 'build' });
|
|
227
|
+
expect(calls[1].body).toEqual({ model: { id: 'gpt-a', providerID: 'openai', variant: 'fast' } });
|
|
228
|
+
expect(calls[2].body).toEqual(expect.objectContaining({ id: 'msg_a', text: 'first', delivery: 'steer' }));
|
|
229
|
+
expect(calls[2].body.model).toBeUndefined();
|
|
230
|
+
expect(calls[3].body).toEqual({ agent: 'plan' });
|
|
231
|
+
expect(calls[4].body).toEqual({ model: { id: 'claude-b', providerID: 'anthropic' } });
|
|
232
|
+
expect(calls[5].body).toEqual(expect.objectContaining({ id: 'msg_b', text: 'second' }));
|
|
233
|
+
vi.unstubAllGlobals();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('does not POST prompt when model switch fails', async () => {
|
|
237
|
+
const fetchMock = vi.fn(async (url) => {
|
|
238
|
+
if (String(url).includes('/model')) return { ok: false, status: 400, json: async () => ({ _tag: 'InvalidRequestError' }) };
|
|
239
|
+
return { ok: true, status: 204, json: async () => ({}) };
|
|
240
|
+
});
|
|
241
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
242
|
+
const adapter = createOpenCodeMessageQueueAdapter({
|
|
243
|
+
buildOpenCodeUrl: () => 'http://open.code/',
|
|
244
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
245
|
+
readAttachment: () => null,
|
|
246
|
+
});
|
|
247
|
+
const result = await adapter.send({
|
|
248
|
+
scope: { sessionID: 's', directory: '/d' },
|
|
249
|
+
messageID: 'msg_fail',
|
|
250
|
+
parts: [{ type: 'text', text: 'nope' }],
|
|
251
|
+
sendConfig: { providerID: 'p', modelID: 'm', agent: 'a' },
|
|
252
|
+
});
|
|
253
|
+
expect(result).toMatchObject({ ok: false, kind: 'failed', code: 'config_switch_failed', status: 400 });
|
|
254
|
+
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt'))).toBe(false);
|
|
255
|
+
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/agent'))).toBe(true);
|
|
256
|
+
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/model'))).toBe(true);
|
|
257
|
+
vi.unstubAllGlobals();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('treats session busy on switch as retry without prompt', async () => {
|
|
261
|
+
const fetchMock = vi.fn(async (url) => {
|
|
262
|
+
if (String(url).includes('/agent')) return { ok: false, status: 409, json: async () => ({ _tag: 'SessionBusyError' }) };
|
|
263
|
+
return { ok: true, status: 200, json: async () => ({}) };
|
|
264
|
+
});
|
|
265
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
266
|
+
const adapter = createOpenCodeMessageQueueAdapter({
|
|
267
|
+
buildOpenCodeUrl: () => 'http://open.code/',
|
|
268
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
269
|
+
readAttachment: () => null,
|
|
270
|
+
});
|
|
271
|
+
const result = await adapter.send({
|
|
272
|
+
scope: { sessionID: 's', directory: '/d' },
|
|
273
|
+
messageID: 'msg_busy',
|
|
274
|
+
parts: [{ type: 'text', text: 'wait' }],
|
|
275
|
+
sendConfig: { providerID: 'p', modelID: 'm', agent: 'a' },
|
|
276
|
+
});
|
|
277
|
+
expect(result).toMatchObject({ ok: false, kind: 'retry', code: 'session_busy' });
|
|
278
|
+
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt'))).toBe(false);
|
|
279
|
+
vi.unstubAllGlobals();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('maps MessageNotFoundError _tag from projection to found:false', async () => {
|
|
283
|
+
const api = v2Client({ inbox: [] });
|
|
284
|
+
api.session.message.mockRejectedValue({
|
|
285
|
+
_tag: 'MessageNotFoundError',
|
|
286
|
+
sessionID: 's',
|
|
287
|
+
messageID: 'missing',
|
|
288
|
+
message: 'not found',
|
|
289
|
+
});
|
|
290
|
+
makeOpenCodeV2Client.mockReturnValue(api);
|
|
291
|
+
const adapter = createOpenCodeMessageQueueAdapter({
|
|
292
|
+
buildOpenCodeUrl: () => 'http://open.code/',
|
|
293
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
294
|
+
readAttachment: () => null,
|
|
295
|
+
});
|
|
296
|
+
await expect(adapter.findMessage({ sessionID: 's', directory: '/d' }, 'missing')).resolves.toEqual({ found: false });
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('awaits projection rejections so non-404 stays unavailable instead of escaping', async () => {
|
|
300
|
+
const api = v2Client({ inbox: [] });
|
|
301
|
+
api.session.message.mockRejectedValue(Object.assign(new Error('boom'), { status: 500 }));
|
|
302
|
+
makeOpenCodeV2Client.mockReturnValue(api);
|
|
303
|
+
const adapter = createOpenCodeMessageQueueAdapter({
|
|
304
|
+
buildOpenCodeUrl: () => 'http://open.code/',
|
|
305
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
306
|
+
readAttachment: () => null,
|
|
307
|
+
});
|
|
308
|
+
await expect(adapter.findMessage({ sessionID: 's', directory: '/d' }, 'wanted')).resolves.toEqual({ unavailable: true });
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('intent queue prompt/inbox authority (ticket 12)', () => {
|
|
313
|
+
const jsonResponse = (status, body) => ({
|
|
314
|
+
ok: status >= 200 && status < 300,
|
|
315
|
+
status,
|
|
316
|
+
json: async () => body,
|
|
317
|
+
text: async () => JSON.stringify(body),
|
|
318
|
+
headers: { get: () => 'application/json' },
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('sends via v2 prompt/inbox and does not treat local parts as the body of record', async () => {
|
|
322
|
+
const fetchMock = vi.fn(async () => jsonResponse(200, {
|
|
323
|
+
id: 'msg_inbox',
|
|
324
|
+
sessionID: 's',
|
|
325
|
+
delivery: 'steer',
|
|
326
|
+
payload: { text: 'from-projection' },
|
|
327
|
+
}));
|
|
328
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
329
|
+
const adapter = createOpenCodeMessageQueueAdapter({
|
|
330
|
+
buildOpenCodeUrl: (pathname) => `http://open.code${pathname}`,
|
|
331
|
+
getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic secret' }),
|
|
332
|
+
readAttachment: () => ({ type: 'file', url: 'file:///staged' }),
|
|
333
|
+
});
|
|
334
|
+
const localParts = [{ type: 'text', text: 'stale-local-parts' }];
|
|
335
|
+
const result = await adapter.send({
|
|
336
|
+
scope: { sessionID: 's', directory: '/d' },
|
|
337
|
+
messageID: 'msg_inbox',
|
|
338
|
+
sendConfig: { providerID: 'p', modelID: 'm' },
|
|
339
|
+
parts: localParts,
|
|
340
|
+
content: 'stale-local-parts',
|
|
341
|
+
});
|
|
342
|
+
expect(result).toMatchObject({ ok: true, status: 200 });
|
|
343
|
+
const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt'));
|
|
344
|
+
expect(promptCall).toBeTruthy();
|
|
345
|
+
const [url, init] = promptCall;
|
|
346
|
+
expect(String(url)).toContain('/api/session/s/prompt');
|
|
347
|
+
const body = JSON.parse(init.body);
|
|
348
|
+
expect(body.delivery).toMatch(/steer|queue/);
|
|
349
|
+
expect(body.id).toBe('msg_inbox');
|
|
350
|
+
expect(body.model).toBeUndefined();
|
|
351
|
+
expect(init.method).toBe('POST');
|
|
352
|
+
vi.unstubAllGlobals();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('reconciles only against OpenCode inbox and projection, never a local parts scan', async () => {
|
|
356
|
+
const list = vi.fn(async () => ({ data: [{ id: 'wanted', type: 'user', text: 'local-scan' }] }));
|
|
357
|
+
const api = v2Client({
|
|
358
|
+
inbox: [{ id: 'wanted', sessionID: 's', delivery: 'steer', payload: { text: 'inbox' } }],
|
|
359
|
+
message: { wanted: { id: 'wanted', type: 'user' } },
|
|
360
|
+
});
|
|
361
|
+
api.message.list = list;
|
|
362
|
+
makeOpenCodeV2Client.mockReturnValue(api);
|
|
363
|
+
const adapter = createOpenCodeMessageQueueAdapter({
|
|
364
|
+
buildOpenCodeUrl: (pathname) => `http://open.code${pathname}`,
|
|
365
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
366
|
+
readAttachment: () => null,
|
|
367
|
+
});
|
|
368
|
+
await expect(adapter.findMessage({ sessionID: 's', directory: '/d' }, 'wanted')).resolves.toEqual({ found: true });
|
|
369
|
+
expect(list).not.toHaveBeenCalled();
|
|
370
|
+
expect(api.session.inbox.list).toHaveBeenCalledWith({ sessionID: 's' }, expect.any(Object));
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('keeps staged attachment files until prompt is accepted, then projection wins', async () => {
|
|
374
|
+
const fetchMock = vi.fn(async () => jsonResponse(200, {
|
|
375
|
+
id: 'msg_att',
|
|
376
|
+
sessionID: 's',
|
|
377
|
+
delivery: 'steer',
|
|
378
|
+
payload: { text: 'accepted' },
|
|
379
|
+
}));
|
|
380
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
381
|
+
const readAttachment = vi.fn(() => ({ type: 'file', url: 'file:///staged-disk', filename: 'note.txt' }));
|
|
382
|
+
const adapter = createOpenCodeMessageQueueAdapter({
|
|
383
|
+
buildOpenCodeUrl: (pathname) => `http://open.code${pathname}`,
|
|
384
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
385
|
+
readAttachment,
|
|
386
|
+
});
|
|
387
|
+
const parts = await adapter.materializeAttachments({
|
|
388
|
+
content: 'caption',
|
|
389
|
+
attachments: [{ attachmentID: 'att-1', filename: 'note.txt' }],
|
|
390
|
+
});
|
|
391
|
+
expect(parts).toEqual([
|
|
392
|
+
{ type: 'text', text: 'caption' },
|
|
393
|
+
{ type: 'file', url: 'file:///staged-disk', filename: 'note.txt' },
|
|
394
|
+
]);
|
|
395
|
+
const result = await adapter.send({
|
|
396
|
+
scope: { sessionID: 's', directory: '/d' },
|
|
397
|
+
messageID: 'msg_att',
|
|
398
|
+
sendConfig: { providerID: 'p', modelID: 'm' },
|
|
399
|
+
parts,
|
|
400
|
+
});
|
|
401
|
+
expect(result.ok).toBe(true);
|
|
402
|
+
expect(readAttachment).toHaveBeenCalled();
|
|
403
|
+
const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt'));
|
|
404
|
+
const body = JSON.parse(promptCall[1].body);
|
|
405
|
+
expect(body.files?.length || body.parts).toBeTruthy();
|
|
406
|
+
expect(body.model).toBeUndefined();
|
|
407
|
+
vi.unstubAllGlobals();
|
|
408
|
+
});
|
|
409
|
+
});
|
|
@@ -4,24 +4,24 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { describe, expect, it, vi } from 'vitest';
|
|
6
6
|
|
|
7
|
-
vi.mock('
|
|
8
|
-
const {
|
|
7
|
+
vi.mock('../opencode/v2-client.js', () => ({ makeOpenCodeV2Client: vi.fn() }));
|
|
8
|
+
const { makeOpenCodeV2Client } = await import('../opencode/v2-client.js');
|
|
9
9
|
import { createMessageQueueRuntime } from './runtime.js';
|
|
10
10
|
|
|
11
11
|
describe('message queue runtime ownership', () => {
|
|
12
12
|
it('keeps the durable queue identity separate from the upstream OpenCode adapter URL', async () => {
|
|
13
13
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'message-queue-runtime-'));
|
|
14
14
|
const uiUrl = 'http://127.0.0.1:57123'; const upstreamUrl = 'http://127.0.0.1:4096/';
|
|
15
|
-
const
|
|
16
|
-
|
|
15
|
+
const active = vi.fn(() => ({})); const list = vi.fn(() => ({ data: [] }));
|
|
16
|
+
makeOpenCodeV2Client.mockReturnValue({ session: { active, prompt: vi.fn() }, message: { list } });
|
|
17
17
|
const runtime = createMessageQueueRuntime({ dbPath: path.join(root, 'queue.sqlite'), getRuntimeConfig: () => ({ apiBaseUrl: uiUrl }), getOpenCodeRuntimeConfig: () => ({ apiBaseUrl: upstreamUrl }), buildOpenCodeUrl: () => upstreamUrl, getOpenCodeAuthHeaders: () => ({}) });
|
|
18
18
|
try {
|
|
19
19
|
const adapterRuntime = runtime.adapter.captureRuntime();
|
|
20
20
|
await runtime.adapter.checkEligibility({ directory: '/repo', sessionID: 'session' }, adapterRuntime);
|
|
21
21
|
expect(runtime.service.getRuntimeKey()).toBe(crypto.createHash('sha256').update(`url:${uiUrl}`).digest('hex'));
|
|
22
22
|
expect(adapterRuntime.config.apiBaseUrl).toBe(upstreamUrl);
|
|
23
|
-
expect(
|
|
24
|
-
expect(
|
|
23
|
+
expect(makeOpenCodeV2Client).toHaveBeenCalledWith(expect.objectContaining({ baseUrl: 'http://127.0.0.1:4096' }));
|
|
24
|
+
expect(makeOpenCodeV2Client.mock.calls.map(([options]) => options.baseUrl)).not.toContain(uiUrl);
|
|
25
25
|
} finally {
|
|
26
26
|
await runtime.stop(); fs.rmSync(root, { recursive: true, force: true });
|
|
27
27
|
}
|
|
@@ -109,7 +109,13 @@ export const createMessageQueueWorker = ({ service, adapter, workerID, concurren
|
|
|
109
109
|
};
|
|
110
110
|
const run = async () => {
|
|
111
111
|
if (paused || stopping) return status();
|
|
112
|
-
const runtimeKey = service.getRuntimeKey();
|
|
112
|
+
const runtimeKey = service.getRuntimeKey();
|
|
113
|
+
// The managed OpenCode service may still be booting when the worker starts;
|
|
114
|
+
// captureRuntime throws until its port is detected. Leave queued work
|
|
115
|
+
// pending and retry on the next tick instead of failing the flight.
|
|
116
|
+
let runtime;
|
|
117
|
+
try { runtime = adapter.captureRuntime(); } catch { return status(); }
|
|
118
|
+
const authority = service.getAuthority({ runtimeKey });
|
|
113
119
|
if (authority?.authority !== 'active') return status();
|
|
114
120
|
service.recoverExpiredSending?.({ runtimeKey });
|
|
115
121
|
// Start dispatch probes before awaiting reconciling work so a hung findMessage
|
|
@@ -11,8 +11,10 @@ This module provides OpenCode server integration utilities for the web server ru
|
|
|
11
11
|
- `packages/web/server/lib/opencode/cli-entry-runtime.js`: CLI entrypoint runtime that detects direct execution, forces `OPENCHAMBER_RUNTIME=web` unless already `ssh-remote` (so `dev:server` cannot inherit a leftover desktop env and host a relay, while SSH-managed remotes may), parses CLI options, and starts server bootstrap.
|
|
12
12
|
- `packages/web/server/lib/opencode/routes.js`: OpenCode/provider settings and auth-related route registration.
|
|
13
13
|
- `packages/web/server/lib/opencode/lifecycle.js`: OpenCode process lifecycle runtime (startup, restart, readiness, health monitoring). Managed child env inherits the user shell environment unchanged; experimental flags such as `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` follow the user's own environment and are never injected by OpenChamber.
|
|
14
|
+
- `packages/web/server/lib/opencode/v1-migration-gate.js`: pure V1→v2 migration admission gate (`GET /api/experimental/migration/v1` → whether transcript may be fetched).
|
|
14
15
|
- `packages/web/server/lib/opencode/managed-capabilities-runtime.js`: managed-child scheduled-task resources, config injection, rotating bridge identity, and bridge authorization.
|
|
15
16
|
- `packages/web/server/lib/opencode/env-runtime.js`: OpenCode CLI/binary resolution and shell environment runtime.
|
|
17
|
+
- `packages/web/server/lib/opencode/ensure-cli.js`: detect local `opencode2` version and install the pinned official V2 into the OpenChamber data dir when missing or too old. Never uses a packaged/bundled binary.
|
|
16
18
|
- `packages/web/server/lib/opencode/env-config.js`: OpenCode-related environment variable parsing and validation (host/port/hostname).
|
|
17
19
|
- `packages/web/server/lib/opencode/hmr-state-runtime.js`: HMR-persistent runtime state initialization, auth-state bootstrap, and HMR sync helpers.
|
|
18
20
|
- `packages/web/server/lib/opencode/bootstrap-runtime.js`: base app bootstrap runtime for status/auth/tts/notification/OpenChamber route wiring.
|
|
@@ -112,8 +114,27 @@ This module provides OpenCode server integration utilities for the web server ru
|
|
|
112
114
|
membership — that remains a UI-side authoritative pull fused with directory
|
|
113
115
|
`/session/status`.
|
|
114
116
|
|
|
117
|
+
## Public exports (opencode2-pin.js)
|
|
118
|
+
- `PINNED_OPENCODE2_VERSION`: exact desktop/runtime pin (never 1.x).
|
|
119
|
+
- `OPENCODE2_NPM_PACKAGE`: `@opencode/cli` (global install that ships the `opencode2` binary; not 1.x `opencode-ai`).
|
|
120
|
+
- `parseOpenCode2VersionOutput(stdout)` / `compareOpenCode2Versions(left, right)` / `isOpenCode2VersionAtLeast(version, minimum)`: CLI `--version` parse and pin comparison.
|
|
121
|
+
- `openCode2BinaryName(platform)` / `npmPackageForOpenCode2(platform, arch)`: staged binary name `opencode2` and official platform package `@opencode/cli-<os>-<arch>[-baseline]`.
|
|
122
|
+
- `isOpenCode1xVersion(value)` / `isAcceptableOpenCode2HealthVersion(value)` / `evaluateOpenCodeHealthBody(body)`: health admission rejects 1.x and missing/unknown versions even when `healthy: true`.
|
|
123
|
+
- `resolveOpenCode2UpgradeTarget(target)` / `rejectOpenCode1xUpgradeTarget(target)`: upgrade targets default to the pin and refuse 1.x.
|
|
124
|
+
|
|
125
|
+
## Public exports (ensure-cli.js)
|
|
126
|
+
- `ensurePinnedOpenCode2Cli({ discoveredPath, pin, autoInstall, install, readVersion })`: reuse any discovered acceptable 2.x CLI (official name `opencode`, alias `opencode2`). Install the pin into `~/.config/openchamber/opencode-cli/<pin>/opencode` only when nothing usable is installed. `OPENCHAMBER_OPENCODE2_AUTO_INSTALL=0` disables download (`OPENCODE_CLI_MISSING`).
|
|
127
|
+
- `installPinnedOpenCode2Cli(options)`: download `@opencode/cli-<os>-<arch>@pin`, extract, copy as `opencode`, chmod, verify `--version`.
|
|
128
|
+
- `installedOpenCode2BinaryPath(version)` / `readOpenCode2BinaryVersion(path)` / `isOpenCode2AutoInstallEnabled()`.
|
|
129
|
+
|
|
130
|
+
## Public exports (v1-migration-gate.js)
|
|
131
|
+
- `OPENCODE_V1_MIGRATION_PATH`: `/api/experimental/migration/v1`.
|
|
132
|
+
- `V1_MIGRATION_USER_NOTICE`: user-visible backfill notes (reuse message ids; in-progress tools become interrupted; V1 subtasks omitted from v2).
|
|
133
|
+
- `evaluateV1MigrationGate(input)`: pure function. Accepts migration JSON (`{ status }`), an HTTP envelope (`{ httpStatus|status, body }`), or a transport failure (`{ error }`). Returns `{ admitTranscript, phase, progress?, error?, userNotice? }`. `required` / `running` / `error` / network failure → `admitTranscript: false`. `completed` or HTTP 404 (no V1 library) → `true`. Unknown or empty bodies fail closed and are never treated as an empty-list success.
|
|
134
|
+
- `fetchV1MigrationGate({ url, headers, signal, fetchImpl })`: GET-only status poll used by lifecycle.
|
|
135
|
+
|
|
115
136
|
## Public exports (lifecycle.js)
|
|
116
|
-
- `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration.
|
|
137
|
+
- `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration. Bootstrap reuses a healthy v2 `opencode serve` when present; otherwise starts managed `opencode serve`. Before managed spawn, `ensurePinnedOpenCode2CliEnv()` detects the local CLI version and installs the pinned official V2 when missing or too old (skipped for external skip-start). Startup accepts both `server listening on http://127.0.0.1:PORT` and the legacy `opencode server listening on …` line. Readiness/health probes `GET /api/info` first, then `/global/health`, with Basic auth (username `opencode`). A body is admitted only when `evaluateOpenCodeHealthBody` accepts the version (rejects 1.x and missing/unknown). After health ok, `startOpenCode` / `waitForOpenCodeReady` poll `GET /api/experimental/migration/v1` (no POST; backfill is owned by opencode2). Each readiness attempt uses one abort timer that covers **both** health and migration so a hung migration cannot outrun the per-attempt budget. External instances are a read-only mount: restart/refresh only re-probe health + V1 migration (never kill, spawn, clear port, or flip `isExternalOpenCode`); health monitoring skips them; upgrade routes return 409. After readiness admits, lifecycle warms recent directories via optional `getWarmupDirectories` (sequential best-effort `GET /api/session?directory=&limit=1`). External skip-start / auto-detect attach leave `isOpenCodeReady` false until the gate admits. `isOpenCodeReady` means transcript may be fetched only when the gate admits (`completed`, or no V1 library such as HTTP 404). `required` / `running` / `error` keep the ready gate closed; `error` is retried. The last gate result is stored on `state.v1Migration` and published on the OpenChamber `/health` snapshot so UI can render `phase` and running `progress` (`label` / `numerator` / `denominator`) plus `userNotice` (reuse message ids; in-progress tools become interrupted; V1 subtasks do not appear in v2).
|
|
117
138
|
- Returned API:
|
|
118
139
|
- `startOpenCode()`
|
|
119
140
|
- `restartOpenCode()`
|
|
@@ -127,11 +148,13 @@ This module provides OpenCode server integration utilities for the web server ru
|
|
|
127
148
|
- `killProcessOnPort(port)`
|
|
128
149
|
|
|
129
150
|
## Public exports (env-runtime.js)
|
|
130
|
-
- `createOpenCodeEnvRuntime(dependencies)`: creates runtime that owns OpenCode CLI environment and binary discovery state.
|
|
151
|
+
- `createOpenCodeEnvRuntime(dependencies)`: creates runtime that owns OpenCode CLI environment and binary discovery state. Auto-discovery matches master: settings/env, then PATH `opencode` (official v2) with `opencode2` as alias, then known install locations, then a previously installed pin under the OpenChamber data dir. 1.x binaries are skipped by `--version`, not by basename. `resolveOpencodeCliPath()` never prefers a packaged/bundled Electron extraResource. Managed startup calls `ensurePinnedOpenCode2CliEnv()` and installs the pin only when no usable 2.x CLI exists; if pin install fails (or auto-install is disabled), it may last-resort to a 2.x binary under `OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR` or `process.resourcesPath/opencode-cli` (source `bundled`). Explicit `OPENCODE_BINARY` that reports 1.x fails closed with `OPENCODE_BINARY_INVALID`. Bootstrap reuses a healthy v2 `opencode serve` on port 4096 when present; otherwise it starts generic `opencode serve`.
|
|
152
|
+
- VS Code keeps a copied sidecar (`packages/vscode/src/opencode-sidecar.ts`) with the same invariants: discovery order may differ (no Electron bundled fallback), but reject list, listening lines, health path order (`/api/health` then `/global/health`), `{ healthy: true }`, and Basic username `opencode` must stay aligned. Changing one copy requires changing the other.
|
|
131
153
|
- Returned API:
|
|
132
154
|
- `applyLoginShellEnvSnapshot()`
|
|
133
155
|
- `getLoginShellEnvSnapshot()`
|
|
134
156
|
- `ensureOpencodeCliEnv()`
|
|
157
|
+
- `ensurePinnedOpenCode2CliEnv()`
|
|
135
158
|
- `applyOpencodeBinaryFromSettings()`
|
|
136
159
|
- `resolveOpencodeCliPath()`
|
|
137
160
|
- `resolveManagedOpenCodeLaunchSpec(opencodePath)`: resolves the effective managed OpenCode launch target, unwrapping Windows package-manager shims to a direct native binary or explicit runtime+script when possible.
|
|
@@ -175,7 +198,7 @@ This module provides OpenCode server integration utilities for the web server ru
|
|
|
175
198
|
## Public exports (network-runtime.js)
|
|
176
199
|
- `createOpenCodeNetworkRuntime(dependencies)`: creates runtime for OpenCode network and URL concerns.
|
|
177
200
|
- Returned API:
|
|
178
|
-
- `waitForReady(url, timeoutMs?)`
|
|
201
|
+
- `waitForReady(url, timeoutMs?)`: probes `GET /api/health` then `/global/health` with `getOpenCodeAuthHeaders()`; `{ healthy: true }` is ready.
|
|
179
202
|
- `normalizeApiPrefix(prefix)`
|
|
180
203
|
- `setDetectedOpenCodeApiPrefix()`
|
|
181
204
|
- `buildOpenCodeUrl(path, prefixOverride?)`
|
|
@@ -382,9 +405,9 @@ When adding or changing Host HTTP APIs that mobile/desktop clients reach over Pr
|
|
|
382
405
|
- SSE forwarders: `GET /api/global/event`, `GET /api/event` (optional `includeReasoning=false` parse-filters reasoning events on the Host send path; param never forwarded upstream; enabled path remains byte passthrough)
|
|
383
406
|
- Official session.messages list: `GET /api/session/:sessionID/message` (optional `includeReasoning=false` strips reasoning parts; exact message GET is owned by session-turn-pages)
|
|
384
407
|
- Session message forwarder: `POST /api/session/:sessionId/message`
|
|
385
|
-
- Generic `/api/*` forwarding with hop-by-hop header filtering
|
|
408
|
+
- Generic `/api/*` forwarding with hop-by-hop header filtering. v2 upstream routes live under `/api` (`@opencode-ai/protocol`); the proxy restores that prefix after Express strips the `/api` mount. Forwarding `/agent` (v1 root) hits the v2 Web UI HTML fallback.
|
|
386
409
|
- Windows `/session` merge fallback path behavior
|
|
387
|
-
- OpenCode readiness gate for proxied `/api` requests
|
|
410
|
+
- OpenCode readiness gate for proxied `/api` requests: process warmup still uses `OPEN_CODE_READY_GRACE_MS`; `state.v1Migration.admitTranscript !== true` holds with no grace until the gate admits or the client cancels, so a `running` migration past 12s cannot `next()` an empty session list through as success
|
|
388
411
|
|
|
389
412
|
## Public exports (watcher.js)
|
|
390
413
|
- `createOpenCodeWatcherRuntime(dependencies)`: creates global event watcher runtime backed by the shared upstream SSE reader.
|