@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,347 +1,398 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
import { generateOpenCodeText, _test } from './generate.js'
|
|
3
|
+
|
|
4
|
+
const denyAllAgent = {
|
|
5
|
+
location: { directory: '/tmp/openchamber-llm' },
|
|
6
|
+
data: {
|
|
7
|
+
id: 'openchamber-llm',
|
|
8
|
+
name: 'openchamber-llm',
|
|
9
|
+
mode: 'primary',
|
|
10
|
+
hidden: true,
|
|
11
|
+
permissions: [
|
|
12
|
+
{ action: '*', resource: '*', effect: 'deny' },
|
|
13
|
+
],
|
|
8
14
|
},
|
|
9
|
-
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const completedAssistant = (text, extras = {}) => ({
|
|
18
|
+
id: 'msg_asst',
|
|
19
|
+
type: 'assistant',
|
|
20
|
+
time: { created: 1, completed: Date.now() },
|
|
21
|
+
agent: 'openchamber-llm',
|
|
22
|
+
model: { id: 'gpt-5-nano', providerID: 'opencode' },
|
|
23
|
+
content: [{ type: 'text', text }],
|
|
24
|
+
finish: 'stop',
|
|
25
|
+
...extras,
|
|
10
26
|
})
|
|
11
27
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
buildOpenCodeUrl: () => 'http://localhost:1',
|
|
18
|
-
getOpenCodeAuthHeaders: () => ({}),
|
|
19
|
-
detect: async () => ({ available: false, mode: 'throwaway-session' }),
|
|
20
|
-
clientFactory: () => client,
|
|
21
|
-
ensureTempDirectory: async () => '/tmp/timeout-fixture',
|
|
22
|
-
...extra,
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
const throwawayClient = ({ status, messages, id = 'ses_tmp', remove = vi.fn(async () => ({ data: true })) }) => ({
|
|
26
|
-
tool: { ids: async () => ({ data: [] }) },
|
|
27
|
-
session: {
|
|
28
|
-
create: async () => ({ data: { id } }),
|
|
29
|
-
update: async () => ({ data: { id } }),
|
|
30
|
-
promptAsync: async () => ({ response: { status: 204 } }),
|
|
31
|
-
status,
|
|
32
|
-
messages,
|
|
33
|
-
delete: remove,
|
|
28
|
+
const jsonResponse = (status, body) => ({
|
|
29
|
+
ok: status >= 200 && status < 300,
|
|
30
|
+
status,
|
|
31
|
+
headers: {
|
|
32
|
+
get: (name) => (String(name).toLowerCase() === 'content-type' ? 'application/json' : null),
|
|
34
33
|
},
|
|
34
|
+
json: async () => body,
|
|
35
|
+
text: async () => JSON.stringify(body ?? null),
|
|
36
|
+
arrayBuffer: async () => new TextEncoder().encode(JSON.stringify(body ?? null)).buffer,
|
|
35
37
|
})
|
|
36
38
|
|
|
37
39
|
describe('generate cancellation', () => {
|
|
38
|
-
it('cancels
|
|
40
|
+
it('cancels attachment wait and still removes only its temporary session', async () => {
|
|
39
41
|
const controller = new AbortController()
|
|
40
|
-
const remove = vi.fn(async () =>
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
create: async () => ({ data: { id: 'ses_abort_fixture' } }),
|
|
45
|
-
update: async () => ({ data: { id: 'ses_abort_fixture' } }),
|
|
46
|
-
promptAsync: async () => ({ response: { status: 204 } }),
|
|
47
|
-
status: async (_args, { signal }) => { controller.abort(); signal.throwIfAborted() },
|
|
48
|
-
delete: remove,
|
|
49
|
-
},
|
|
50
|
-
}
|
|
42
|
+
const remove = vi.fn(async () => undefined)
|
|
43
|
+
const interrupt = vi.fn(async () => undefined)
|
|
44
|
+
const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
|
|
45
|
+
|
|
51
46
|
await expect(generateOpenCodeText({
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
47
|
+
buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
|
|
48
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
49
|
+
providerID: 'opencode',
|
|
50
|
+
modelID: 'gpt-4o',
|
|
51
|
+
messages: [{ role: 'user', content: 'look', parts: [image] }],
|
|
52
|
+
clientFactory: () => ({
|
|
53
|
+
agent: { get: async () => denyAllAgent },
|
|
54
|
+
session: {
|
|
55
|
+
create: async () => ({ id: 'ses_abort_fixture' }),
|
|
56
|
+
prompt: async () => ({ id: 'inbox' }),
|
|
57
|
+
wait: async (_args, { signal }) => {
|
|
58
|
+
controller.abort()
|
|
59
|
+
signal.throwIfAborted()
|
|
60
|
+
},
|
|
61
|
+
interrupt,
|
|
62
|
+
remove,
|
|
63
|
+
instructions: { entry: { put: async () => {} } },
|
|
64
|
+
},
|
|
65
|
+
message: { list: vi.fn() },
|
|
66
|
+
}),
|
|
67
|
+
ensureTempDirectory: async () => '/tmp/abort-fixture',
|
|
68
|
+
forwardImageParts: true,
|
|
56
69
|
signal: controller.signal,
|
|
57
70
|
})).rejects.toMatchObject({ name: 'AbortError' })
|
|
58
|
-
expect(remove).toHaveBeenCalledExactlyOnceWith({ sessionID: 'ses_abort_fixture'
|
|
71
|
+
expect(remove).toHaveBeenCalledExactlyOnceWith({ sessionID: 'ses_abort_fixture' })
|
|
72
|
+
expect(interrupt).toHaveBeenCalledWith({ sessionID: 'ses_abort_fixture', continue: false })
|
|
59
73
|
})
|
|
60
|
-
|
|
74
|
+
|
|
75
|
+
it('aborts an in-flight generate.text when the contact continuation is cancelled', async () => {
|
|
61
76
|
const controller = new AbortController()
|
|
62
|
-
const
|
|
77
|
+
const text = vi.fn(async (_args, { signal }) => {
|
|
63
78
|
controller.abort()
|
|
64
79
|
signal.throwIfAborted()
|
|
65
80
|
})
|
|
66
81
|
await expect(generateOpenCodeText({
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
82
|
+
buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
|
|
83
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
84
|
+
providerID: 'opencode',
|
|
85
|
+
modelID: 'gpt-5-nano',
|
|
86
|
+
messages: [{ role: 'user', content: 'work' }],
|
|
87
|
+
clientFactory: () => ({ generate: { text } }),
|
|
88
|
+
signal: controller.signal,
|
|
71
89
|
})).rejects.toMatchObject({ name: 'AbortError' })
|
|
72
|
-
expect(
|
|
73
|
-
})
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
describe('detectSessionlessGenerate', () => {
|
|
77
|
-
it('returns unavailable when the probe 404s', async () => {
|
|
78
|
-
const fetchImpl = vi.fn(async () => new Response('missing', { status: 404 }))
|
|
79
|
-
await expect(detectSessionlessGenerate({
|
|
80
|
-
fetchImpl,
|
|
81
|
-
baseUrl: 'http://127.0.0.1:4096',
|
|
82
|
-
headers: {},
|
|
83
|
-
})).resolves.toEqual({ available: false, mode: 'throwaway-session' })
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
it('returns available when the probe returns JSON', async () => {
|
|
87
|
-
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ ok: true }), {
|
|
88
|
-
status: 200,
|
|
89
|
-
headers: { 'Content-Type': 'application/json' },
|
|
90
|
-
}))
|
|
91
|
-
await expect(detectSessionlessGenerate({
|
|
92
|
-
fetchImpl,
|
|
93
|
-
baseUrl: 'http://127.0.0.1:4096',
|
|
94
|
-
headers: {},
|
|
95
|
-
})).resolves.toMatchObject({ available: true, mode: 'http' })
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
it('treats HTML 200 on /generate as unavailable', async () => {
|
|
99
|
-
const fetchImpl = vi.fn(async (url) => {
|
|
100
|
-
expect(String(url)).toMatch(/\/generate$/)
|
|
101
|
-
return new Response('<!doctype html><html><body>OpenChamber</body></html>', {
|
|
102
|
-
status: 200,
|
|
103
|
-
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
104
|
-
})
|
|
105
|
-
})
|
|
106
|
-
await expect(detectSessionlessGenerate({
|
|
107
|
-
fetchImpl,
|
|
108
|
-
baseUrl: 'http://127.0.0.1:4096',
|
|
109
|
-
headers: {},
|
|
110
|
-
})).resolves.toEqual({ available: false, mode: 'throwaway-session' })
|
|
111
|
-
expect(fetchImpl).toHaveBeenCalled()
|
|
90
|
+
expect(text).toHaveBeenCalledTimes(1)
|
|
112
91
|
})
|
|
113
92
|
})
|
|
114
93
|
|
|
115
|
-
describe('generateOpenCodeText', () => {
|
|
116
|
-
it
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
client.session.promptAsync = vi.fn(async () => ({ response: { status: 204 } }));
|
|
125
|
-
const result = await throwawayGenerate(client, { variant });
|
|
126
|
-
expect(client.session.promptAsync.mock.calls[0][0].variant).toBe(variant);
|
|
127
|
-
expect(result.text).toBe('public reply');
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it('passes variant to sessionless generation', async () => {
|
|
131
|
-
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ text: 'reply' })));
|
|
132
|
-
await throwawayGenerate({}, {
|
|
133
|
-
variant: 'high', fetchImpl,
|
|
134
|
-
detect: async () => ({ available: true, mode: 'http', url: 'http://localhost:1/generate' }),
|
|
135
|
-
});
|
|
136
|
-
expect(JSON.parse(fetchImpl.mock.calls[0][1].body).variant).toBe('high');
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it('uses promptAsync with model+parts, waits for idle messages, and never calls v2 session.prompt', async () => {
|
|
140
|
-
const create = vi.fn(async () => ({ data: { id: 'ses_tmp' } }))
|
|
141
|
-
const update = vi.fn(async () => ({ data: { id: 'ses_tmp' } }))
|
|
142
|
-
const ids = vi.fn(async () => ({ data: ['bash', 'edit'] }))
|
|
143
|
-
const prompt = vi.fn(async () => {
|
|
144
|
-
throw new Error('v2 session.prompt must not be used')
|
|
94
|
+
describe('generateOpenCodeText — text path', () => {
|
|
95
|
+
it('uses generate.text with model id/providerID and never opens a session', async () => {
|
|
96
|
+
const text = vi.fn(async () => ({ text: 'reply' }))
|
|
97
|
+
const create = vi.fn()
|
|
98
|
+
const prompt = vi.fn()
|
|
99
|
+
const clientFactory = () => ({
|
|
100
|
+
generate: { text },
|
|
101
|
+
session: { create, prompt },
|
|
102
|
+
agent: { get: vi.fn() },
|
|
145
103
|
})
|
|
146
|
-
const promptAsync = vi.fn(async () => ({ response: { status: 204 } }))
|
|
147
|
-
const status = vi.fn(async () => ({ data: { ses_tmp: { type: 'idle' } } }))
|
|
148
|
-
const messages = vi.fn(async () => ({ data: [completedAssistant('reply')] }))
|
|
149
|
-
const remove = vi.fn(async () => ({ data: true }))
|
|
150
|
-
const createOpencodeClient = vi.fn(() => ({
|
|
151
|
-
session: { create, update, prompt, promptAsync, status, messages, delete: remove },
|
|
152
|
-
tool: { ids },
|
|
153
|
-
}))
|
|
154
104
|
|
|
155
105
|
const result = await generateOpenCodeText({
|
|
156
106
|
buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
|
|
157
107
|
getOpenCodeAuthHeaders: () => ({}),
|
|
158
108
|
providerID: 'opencode',
|
|
159
109
|
modelID: 'gpt-5-nano',
|
|
160
|
-
messages: [
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
expect(agentMarkdown).toContain('permission:')
|
|
166
|
-
expect(agentMarkdown).not.toContain('effect: deny')
|
|
167
|
-
expect(agentMarkdown).toContain('Report execution and failures from supplied tool results only.')
|
|
168
|
-
expect(agentMarkdown).toContain('Native permissions are denied')
|
|
169
|
-
return '/tmp/openchamber-llm'
|
|
170
|
-
},
|
|
171
|
-
detect: async () => ({ available: false, mode: 'throwaway-session' }),
|
|
110
|
+
messages: [
|
|
111
|
+
{ role: 'system', content: 'Be brief' },
|
|
112
|
+
{ role: 'user', content: 'hi' },
|
|
113
|
+
],
|
|
114
|
+
clientFactory,
|
|
172
115
|
})
|
|
173
116
|
|
|
174
|
-
expect(
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
metadata: { openchamber: { llm: { purpose: 'chat-completions' } } },
|
|
179
|
-
}), expect.anything())
|
|
180
|
-
expect(update).toHaveBeenCalled()
|
|
181
|
-
expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({
|
|
182
|
-
sessionID: 'ses_tmp',
|
|
183
|
-
agent: 'openchamber-llm',
|
|
184
|
-
model: { providerID: 'opencode', modelID: 'gpt-5-nano' },
|
|
185
|
-
tools: { bash: false, edit: false },
|
|
186
|
-
parts: [{ type: 'text', text: 'User: hi', synthetic: false }],
|
|
117
|
+
expect(result).toEqual({ text: 'reply', source: 'generate.text' })
|
|
118
|
+
expect(text).toHaveBeenCalledWith(expect.objectContaining({
|
|
119
|
+
prompt: expect.stringContaining('Be brief'),
|
|
120
|
+
model: { id: 'gpt-5-nano', providerID: 'opencode' },
|
|
187
121
|
}), expect.anything())
|
|
122
|
+
expect(text.mock.calls[0][0].prompt).toContain('User: hi')
|
|
123
|
+
expect(create).not.toHaveBeenCalled()
|
|
188
124
|
expect(prompt).not.toHaveBeenCalled()
|
|
189
|
-
expect(status).toHaveBeenCalled()
|
|
190
|
-
expect(messages).toHaveBeenCalledWith(expect.objectContaining({ sessionID: 'ses_tmp' }), expect.anything())
|
|
191
|
-
expect(remove).toHaveBeenCalled()
|
|
192
|
-
expect(result).toEqual({ text: 'reply', source: 'throwaway-session' })
|
|
193
125
|
})
|
|
194
126
|
|
|
195
|
-
it('
|
|
196
|
-
const
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
127
|
+
it.each(['high', undefined])('passes variant %s to generate.text and returns only text', async (variant) => {
|
|
128
|
+
const text = vi.fn(async () => ({ text: 'public reply' }))
|
|
129
|
+
const result = await generateOpenCodeText({
|
|
130
|
+
buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
|
|
131
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
132
|
+
providerID: 'opencode',
|
|
133
|
+
modelID: 'gpt-5-nano',
|
|
134
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
135
|
+
variant,
|
|
136
|
+
clientFactory: () => ({ generate: { text } }),
|
|
137
|
+
})
|
|
138
|
+
expect(result.text).toBe('public reply')
|
|
139
|
+
const model = text.mock.calls[0][0].model
|
|
140
|
+
if (variant) {
|
|
141
|
+
expect(model).toEqual({ id: 'gpt-5-nano', providerID: 'opencode', variant })
|
|
142
|
+
} else {
|
|
143
|
+
expect(model).toEqual({ id: 'gpt-5-nano', providerID: 'opencode' })
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('does not invent deltas on the generate.text path', async () => {
|
|
148
|
+
const onTextDelta = vi.fn()
|
|
149
|
+
const subscribers = new Set()
|
|
150
|
+
const globalEventHub = {
|
|
151
|
+
subscribeEvent(fn) {
|
|
152
|
+
subscribers.add(fn)
|
|
153
|
+
return () => { subscribers.delete(fn) }
|
|
216
154
|
},
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
await generateOpenCodeText({
|
|
155
|
+
}
|
|
156
|
+
const result = await generateOpenCodeText({
|
|
220
157
|
buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
|
|
221
158
|
getOpenCodeAuthHeaders: () => ({}),
|
|
222
159
|
providerID: 'opencode',
|
|
223
160
|
modelID: 'gpt-5-nano',
|
|
224
|
-
messages: [{ role: 'user', content: '
|
|
225
|
-
clientFactory:
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
161
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
162
|
+
clientFactory: () => ({
|
|
163
|
+
generate: { text: async () => ({ text: 'full reply' }) },
|
|
164
|
+
}),
|
|
165
|
+
onTextDelta,
|
|
166
|
+
globalEventHub,
|
|
229
167
|
})
|
|
230
|
-
expect(
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
file,
|
|
234
|
-
])
|
|
168
|
+
expect(result).toEqual({ text: 'full reply', source: 'generate.text' })
|
|
169
|
+
expect(onTextDelta).not.toHaveBeenCalled()
|
|
170
|
+
expect(subscribers.size).toBe(0)
|
|
235
171
|
})
|
|
236
172
|
|
|
237
|
-
it('
|
|
173
|
+
it('keeps image descriptions for non-vision generate and stays on text path', async () => {
|
|
238
174
|
const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
|
|
239
175
|
const file = { type: 'file', mime: 'text/plain', url: 'data:text/plain;base64,eA==', filename: 'notes.txt' }
|
|
240
176
|
expect(_test.filesForPrompt([image, file], false)).toEqual([file])
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
update: async () => ({ data: { id: 'ses_tmp' } }),
|
|
246
|
-
prompt: vi.fn(),
|
|
247
|
-
promptAsync,
|
|
248
|
-
status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
|
|
249
|
-
messages: async () => ({ data: [completedAssistant('cannot see images')] }),
|
|
250
|
-
delete: async () => ({ data: true }),
|
|
251
|
-
},
|
|
252
|
-
tool: { ids: async () => ({ data: [] }) },
|
|
253
|
-
}))
|
|
177
|
+
expect(_test.imageFilesForSession([image, file], false)).toEqual([])
|
|
178
|
+
|
|
179
|
+
const text = vi.fn(async () => ({ text: 'cannot see images' }))
|
|
180
|
+
const create = vi.fn()
|
|
254
181
|
await generateOpenCodeText({
|
|
255
182
|
buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
|
|
256
183
|
getOpenCodeAuthHeaders: () => ({}),
|
|
257
184
|
providerID: 'opencode',
|
|
258
185
|
modelID: 'deepseek-v4-flash',
|
|
259
186
|
messages: [{ role: 'user', content: 'look', parts: [image, file] }],
|
|
260
|
-
clientFactory:
|
|
261
|
-
|
|
262
|
-
|
|
187
|
+
clientFactory: () => ({
|
|
188
|
+
generate: { text },
|
|
189
|
+
session: { create },
|
|
190
|
+
}),
|
|
191
|
+
forwardImageParts: false,
|
|
263
192
|
})
|
|
264
|
-
expect(
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
193
|
+
expect(create).not.toHaveBeenCalled()
|
|
194
|
+
expect(text.mock.calls[0][0].prompt).toContain('[image: shot.png (image/png)]')
|
|
195
|
+
expect(text.mock.calls[0][0].prompt).toContain('[file: notes.txt (text/plain)]')
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
describe('generateOpenCodeText — attachment session path', () => {
|
|
200
|
+
it('verifies deny-all before prompt, prompts with files, waits, lists, and removes', async () => {
|
|
201
|
+
const agentGet = vi.fn(async () => denyAllAgent)
|
|
202
|
+
const create = vi.fn(async () => ({ id: 'ses_tmp' }))
|
|
203
|
+
const put = vi.fn(async () => undefined)
|
|
204
|
+
const prompt = vi.fn(async () => ({ id: 'inbox_1', type: 'user' }))
|
|
205
|
+
const wait = vi.fn(async () => undefined)
|
|
206
|
+
const list = vi.fn(async () => ({ data: [completedAssistant('saw it')], cursor: {} }))
|
|
207
|
+
const remove = vi.fn(async () => undefined)
|
|
208
|
+
const interrupt = vi.fn(async () => undefined)
|
|
209
|
+
const text = vi.fn()
|
|
210
|
+
const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
|
|
211
|
+
|
|
212
|
+
const result = await generateOpenCodeText({
|
|
213
|
+
buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
|
|
214
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
215
|
+
providerID: 'opencode',
|
|
216
|
+
modelID: 'gpt-4o',
|
|
217
|
+
messages: [
|
|
218
|
+
{ role: 'system', content: 'Be careful' },
|
|
219
|
+
{ role: 'user', content: 'look', parts: [image] },
|
|
220
|
+
],
|
|
221
|
+
variant: 'high',
|
|
222
|
+
clientFactory: () => ({
|
|
223
|
+
generate: { text },
|
|
224
|
+
agent: { get: agentGet },
|
|
225
|
+
session: {
|
|
226
|
+
create,
|
|
227
|
+
prompt,
|
|
228
|
+
wait,
|
|
229
|
+
interrupt,
|
|
230
|
+
remove,
|
|
231
|
+
instructions: { entry: { put } },
|
|
232
|
+
},
|
|
233
|
+
message: { list },
|
|
268
234
|
}),
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
235
|
+
ensureTempDirectory: async ({ agentMarkdown }) => {
|
|
236
|
+
expect(agentMarkdown).toContain('openchamber-tool JSON fences')
|
|
237
|
+
expect(agentMarkdown).toMatch(/action:\s*"\*"/)
|
|
238
|
+
expect(agentMarkdown).toMatch(/effect:\s*deny/)
|
|
239
|
+
return '/tmp/openchamber-llm'
|
|
240
|
+
},
|
|
241
|
+
forwardImageParts: true,
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
expect(result).toEqual({ text: 'saw it', source: 'attachment-session' })
|
|
245
|
+
expect(agentGet).toHaveBeenCalledWith(expect.objectContaining({
|
|
246
|
+
agentID: 'openchamber-llm',
|
|
247
|
+
location: { directory: '/tmp/openchamber-llm' },
|
|
248
|
+
}), expect.anything())
|
|
249
|
+
expect(create).toHaveBeenCalledWith(expect.objectContaining({
|
|
250
|
+
agent: 'openchamber-llm',
|
|
251
|
+
model: { id: 'gpt-4o', providerID: 'opencode', variant: 'high' },
|
|
252
|
+
location: { directory: '/tmp/openchamber-llm' },
|
|
253
|
+
}), expect.anything())
|
|
254
|
+
expect(put).toHaveBeenCalledWith(expect.objectContaining({
|
|
255
|
+
sessionID: 'ses_tmp',
|
|
256
|
+
key: 'system',
|
|
257
|
+
value: 'Be careful',
|
|
258
|
+
}), expect.anything())
|
|
259
|
+
// agent.get must complete before prompt
|
|
260
|
+
expect(agentGet.mock.invocationCallOrder[0]).toBeLessThan(prompt.mock.invocationCallOrder[0])
|
|
261
|
+
expect(prompt).toHaveBeenCalledWith(expect.objectContaining({
|
|
262
|
+
sessionID: 'ses_tmp',
|
|
263
|
+
text: expect.stringContaining('User: look'),
|
|
264
|
+
files: [{ uri: image.url, name: 'shot.png' }],
|
|
265
|
+
delivery: 'steer',
|
|
266
|
+
}), expect.anything())
|
|
267
|
+
expect(wait).toHaveBeenCalledWith({ sessionID: 'ses_tmp' }, expect.anything())
|
|
268
|
+
expect(list).toHaveBeenCalledWith(expect.objectContaining({
|
|
269
|
+
sessionID: 'ses_tmp',
|
|
270
|
+
order: 'desc',
|
|
271
|
+
}), expect.anything())
|
|
272
|
+
expect(remove).toHaveBeenCalledWith({ sessionID: 'ses_tmp' })
|
|
273
|
+
expect(text).not.toHaveBeenCalled()
|
|
272
274
|
})
|
|
273
275
|
|
|
274
|
-
it('
|
|
275
|
-
const promptAsync = vi.fn(async () => ({ response: { status: 204 } }))
|
|
276
|
+
it('fails with llm_attachment_generation_unavailable before prompt when deny-all is missing', async () => {
|
|
276
277
|
const prompt = vi.fn()
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
278
|
+
const remove = vi.fn()
|
|
279
|
+
const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
|
|
280
|
+
|
|
281
|
+
await expect(generateOpenCodeText({
|
|
282
|
+
buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
|
|
283
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
284
|
+
providerID: 'opencode',
|
|
285
|
+
modelID: 'gpt-4o',
|
|
286
|
+
messages: [{ role: 'user', content: 'look', parts: [image] }],
|
|
287
|
+
clientFactory: () => ({
|
|
288
|
+
agent: {
|
|
289
|
+
get: async () => ({
|
|
290
|
+
data: {
|
|
291
|
+
id: 'openchamber-llm',
|
|
292
|
+
permissions: [
|
|
293
|
+
{ action: 'read', resource: 'file', effect: 'allow' },
|
|
294
|
+
],
|
|
295
|
+
},
|
|
296
|
+
}),
|
|
297
|
+
},
|
|
298
|
+
session: {
|
|
299
|
+
create: async () => ({ id: 'ses_tmp' }),
|
|
300
|
+
prompt,
|
|
301
|
+
remove,
|
|
302
|
+
},
|
|
303
|
+
}),
|
|
304
|
+
ensureTempDirectory: async () => '/tmp/openchamber-llm',
|
|
305
|
+
forwardImageParts: true,
|
|
306
|
+
})).rejects.toMatchObject({
|
|
307
|
+
code: 'llm_attachment_generation_unavailable',
|
|
308
|
+
})
|
|
309
|
+
expect(prompt).not.toHaveBeenCalled()
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('removes the session when wait fails and still cleans hub listeners', async () => {
|
|
313
|
+
const subscribers = new Set()
|
|
314
|
+
const globalEventHub = {
|
|
315
|
+
subscribeEvent(fn) {
|
|
316
|
+
subscribers.add(fn)
|
|
317
|
+
return () => { subscribers.delete(fn) }
|
|
291
318
|
},
|
|
292
|
-
|
|
293
|
-
|
|
319
|
+
}
|
|
320
|
+
const remove = vi.fn(async () => undefined)
|
|
321
|
+
const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
|
|
294
322
|
|
|
295
323
|
await expect(generateOpenCodeText({
|
|
296
324
|
buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
|
|
297
325
|
getOpenCodeAuthHeaders: () => ({}),
|
|
298
326
|
providerID: 'opencode',
|
|
299
|
-
modelID: 'gpt-
|
|
300
|
-
messages: [{ role: 'user', content: '
|
|
301
|
-
clientFactory:
|
|
327
|
+
modelID: 'gpt-4o',
|
|
328
|
+
messages: [{ role: 'user', content: 'look', parts: [image] }],
|
|
329
|
+
clientFactory: () => ({
|
|
330
|
+
agent: { get: async () => denyAllAgent },
|
|
331
|
+
session: {
|
|
332
|
+
create: async () => ({ id: 'ses_tmp' }),
|
|
333
|
+
prompt: async () => ({ id: 'inbox' }),
|
|
334
|
+
wait: async () => {
|
|
335
|
+
throw new Error('wait boom')
|
|
336
|
+
},
|
|
337
|
+
remove,
|
|
338
|
+
interrupt: vi.fn(),
|
|
339
|
+
instructions: { entry: { put: async () => {} } },
|
|
340
|
+
},
|
|
341
|
+
message: { list: vi.fn() },
|
|
342
|
+
}),
|
|
302
343
|
ensureTempDirectory: async () => '/tmp/openchamber-llm',
|
|
303
|
-
|
|
344
|
+
forwardImageParts: true,
|
|
345
|
+
onTextDelta: vi.fn(),
|
|
346
|
+
globalEventHub,
|
|
304
347
|
})).rejects.toMatchObject({
|
|
305
348
|
code: 'upstream_error',
|
|
306
|
-
message: '
|
|
349
|
+
message: 'wait boom',
|
|
307
350
|
})
|
|
308
|
-
expect(
|
|
351
|
+
expect(remove).toHaveBeenCalledWith({ sessionID: 'ses_tmp' })
|
|
352
|
+
expect(subscribers.size).toBe(0)
|
|
309
353
|
})
|
|
310
354
|
|
|
311
|
-
it('
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
const promptAsync = vi.fn(async () => ({ response: { status: 204 } }))
|
|
317
|
-
const prompt = vi.fn()
|
|
318
|
-
const result = await generateOpenCodeText({
|
|
355
|
+
it('surfaces assistant error from message.list and cleans up', async () => {
|
|
356
|
+
const remove = vi.fn(async () => undefined)
|
|
357
|
+
const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
|
|
358
|
+
|
|
359
|
+
await expect(generateOpenCodeText({
|
|
319
360
|
buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
|
|
320
361
|
getOpenCodeAuthHeaders: () => ({}),
|
|
321
362
|
providerID: 'opencode',
|
|
322
|
-
modelID: '
|
|
323
|
-
messages: [{ role: 'user', content: '
|
|
324
|
-
fetchImpl,
|
|
363
|
+
modelID: 'gpt-4o',
|
|
364
|
+
messages: [{ role: 'user', content: 'look', parts: [image] }],
|
|
325
365
|
clientFactory: () => ({
|
|
366
|
+
agent: { get: async () => denyAllAgent },
|
|
326
367
|
session: {
|
|
327
|
-
create: async () => ({
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
368
|
+
create: async () => ({ id: 'ses_tmp' }),
|
|
369
|
+
prompt: async () => ({ id: 'inbox' }),
|
|
370
|
+
wait: async () => undefined,
|
|
371
|
+
remove,
|
|
372
|
+
interrupt: vi.fn(),
|
|
373
|
+
instructions: { entry: { put: async () => {} } },
|
|
374
|
+
},
|
|
375
|
+
message: {
|
|
376
|
+
list: async () => ({
|
|
377
|
+
data: [completedAssistant('', {
|
|
378
|
+
finish: 'error',
|
|
379
|
+
error: { message: 'model refused the request' },
|
|
380
|
+
content: [],
|
|
381
|
+
})],
|
|
382
|
+
cursor: {},
|
|
383
|
+
}),
|
|
334
384
|
},
|
|
335
|
-
tool: { ids: async () => ({ data: [] }) },
|
|
336
385
|
}),
|
|
337
386
|
ensureTempDirectory: async () => '/tmp/openchamber-llm',
|
|
387
|
+
forwardImageParts: true,
|
|
388
|
+
})).rejects.toMatchObject({
|
|
389
|
+
code: 'upstream_error',
|
|
390
|
+
message: 'model refused the request',
|
|
338
391
|
})
|
|
339
|
-
expect(
|
|
340
|
-
expect(promptAsync).toHaveBeenCalled()
|
|
341
|
-
expect(prompt).not.toHaveBeenCalled()
|
|
392
|
+
expect(remove).toHaveBeenCalled()
|
|
342
393
|
})
|
|
343
394
|
|
|
344
|
-
it('forwards filtered
|
|
395
|
+
it('forwards filtered session.text.delta tokens via onTextDelta and still returns full text', async () => {
|
|
345
396
|
const subscribers = new Set()
|
|
346
397
|
const globalEventHub = {
|
|
347
398
|
subscribeEvent(fn) {
|
|
@@ -354,112 +405,106 @@ describe('generateOpenCodeText', () => {
|
|
|
354
405
|
}
|
|
355
406
|
const deltas = []
|
|
356
407
|
const onTextDelta = vi.fn((text) => { deltas.push(text) })
|
|
408
|
+
const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
|
|
357
409
|
|
|
358
|
-
const
|
|
359
|
-
// Same-session text deltas
|
|
410
|
+
const prompt = vi.fn(async () => {
|
|
360
411
|
globalEventHub.emit({
|
|
361
412
|
payload: {
|
|
362
|
-
type: '
|
|
363
|
-
|
|
413
|
+
type: 'session.text.delta',
|
|
414
|
+
data: {
|
|
364
415
|
sessionID: 'ses_tmp',
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
field: 'text',
|
|
416
|
+
assistantMessageID: 'msg_asst',
|
|
417
|
+
ordinal: 0,
|
|
368
418
|
delta: 'Hel',
|
|
369
419
|
},
|
|
370
420
|
},
|
|
371
421
|
})
|
|
372
422
|
globalEventHub.emit({
|
|
373
423
|
payload: {
|
|
374
|
-
type: '
|
|
375
|
-
|
|
424
|
+
type: 'session.text.delta',
|
|
425
|
+
data: {
|
|
376
426
|
sessionID: 'ses_tmp',
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
field: 'text',
|
|
427
|
+
assistantMessageID: 'msg_asst',
|
|
428
|
+
ordinal: 1,
|
|
380
429
|
delta: 'lo',
|
|
381
430
|
},
|
|
382
431
|
},
|
|
383
432
|
})
|
|
384
|
-
// Other session —
|
|
433
|
+
// Other session — ignored
|
|
385
434
|
globalEventHub.emit({
|
|
386
435
|
payload: {
|
|
387
|
-
type: '
|
|
388
|
-
|
|
436
|
+
type: 'session.text.delta',
|
|
437
|
+
data: {
|
|
389
438
|
sessionID: 'ses_other',
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
field: 'text',
|
|
439
|
+
assistantMessageID: 'msg_other',
|
|
440
|
+
ordinal: 0,
|
|
393
441
|
delta: 'NOPE',
|
|
394
442
|
},
|
|
395
443
|
},
|
|
396
444
|
})
|
|
397
|
-
// Same session, different
|
|
445
|
+
// Same session, different assistantMessageID after lock — ignored
|
|
398
446
|
globalEventHub.emit({
|
|
399
447
|
payload: {
|
|
400
|
-
type: '
|
|
401
|
-
|
|
448
|
+
type: 'session.text.delta',
|
|
449
|
+
data: {
|
|
402
450
|
sessionID: 'ses_tmp',
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
field: 'text',
|
|
451
|
+
assistantMessageID: 'msg_other_asst',
|
|
452
|
+
ordinal: 2,
|
|
406
453
|
delta: 'SKIP',
|
|
407
454
|
},
|
|
408
455
|
},
|
|
409
456
|
})
|
|
410
|
-
//
|
|
457
|
+
// Stale ordinal — ignored
|
|
411
458
|
globalEventHub.emit({
|
|
412
459
|
payload: {
|
|
413
|
-
type: '
|
|
414
|
-
|
|
460
|
+
type: 'session.text.delta',
|
|
461
|
+
data: {
|
|
415
462
|
sessionID: 'ses_tmp',
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
delta: 'think',
|
|
463
|
+
assistantMessageID: 'msg_asst',
|
|
464
|
+
ordinal: 1,
|
|
465
|
+
delta: 'DUP',
|
|
420
466
|
},
|
|
421
467
|
},
|
|
422
468
|
})
|
|
423
|
-
return {
|
|
469
|
+
return { id: 'inbox' }
|
|
424
470
|
})
|
|
425
471
|
|
|
426
472
|
const result = await generateOpenCodeText({
|
|
427
473
|
buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
|
|
428
474
|
getOpenCodeAuthHeaders: () => ({}),
|
|
429
475
|
providerID: 'opencode',
|
|
430
|
-
modelID: 'gpt-
|
|
431
|
-
messages: [{ role: 'user', content: '
|
|
476
|
+
modelID: 'gpt-4o',
|
|
477
|
+
messages: [{ role: 'user', content: 'look', parts: [image] }],
|
|
432
478
|
clientFactory: () => ({
|
|
479
|
+
agent: { get: async () => denyAllAgent },
|
|
433
480
|
session: {
|
|
434
|
-
create: async () => ({
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
481
|
+
create: async () => ({ id: 'ses_tmp' }),
|
|
482
|
+
prompt,
|
|
483
|
+
wait: async () => undefined,
|
|
484
|
+
remove: async () => undefined,
|
|
485
|
+
interrupt: vi.fn(),
|
|
486
|
+
instructions: { entry: { put: async () => {} } },
|
|
487
|
+
},
|
|
488
|
+
message: {
|
|
489
|
+
list: async () => ({ data: [completedAssistant('Hello')], cursor: {} }),
|
|
441
490
|
},
|
|
442
|
-
tool: { ids: async () => ({ data: [] }) },
|
|
443
491
|
}),
|
|
444
492
|
ensureTempDirectory: async () => '/tmp/openchamber-llm',
|
|
445
|
-
|
|
493
|
+
forwardImageParts: true,
|
|
446
494
|
onTextDelta,
|
|
447
495
|
globalEventHub,
|
|
448
496
|
})
|
|
449
497
|
|
|
450
|
-
expect(result).toEqual({ text: 'Hello', source: '
|
|
498
|
+
expect(result).toEqual({ text: 'Hello', source: 'attachment-session' })
|
|
451
499
|
expect(deltas).toEqual(['Hel', 'lo'])
|
|
452
|
-
expect(onTextDelta).toHaveBeenCalledTimes(2)
|
|
453
|
-
// Listener removed after complete — further emits must not reach onTextDelta
|
|
454
500
|
expect(subscribers.size).toBe(0)
|
|
455
501
|
globalEventHub.emit({
|
|
456
502
|
payload: {
|
|
457
|
-
type: '
|
|
458
|
-
|
|
503
|
+
type: 'session.text.delta',
|
|
504
|
+
data: {
|
|
459
505
|
sessionID: 'ses_tmp',
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
field: 'text',
|
|
506
|
+
assistantMessageID: 'msg_asst',
|
|
507
|
+
ordinal: 3,
|
|
463
508
|
delta: 'late',
|
|
464
509
|
},
|
|
465
510
|
},
|
|
@@ -467,211 +512,232 @@ describe('generateOpenCodeText', () => {
|
|
|
467
512
|
expect(onTextDelta).toHaveBeenCalledTimes(2)
|
|
468
513
|
})
|
|
469
514
|
|
|
470
|
-
it('
|
|
471
|
-
const subscribers = new Set()
|
|
472
|
-
const globalEventHub = {
|
|
473
|
-
subscribeEvent(fn) {
|
|
474
|
-
subscribers.add(fn)
|
|
475
|
-
return () => { subscribers.delete(fn) }
|
|
476
|
-
},
|
|
477
|
-
}
|
|
478
|
-
const onTextDelta = vi.fn()
|
|
479
|
-
|
|
480
|
-
await expect(generateOpenCodeText({
|
|
481
|
-
buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
|
|
482
|
-
getOpenCodeAuthHeaders: () => ({}),
|
|
483
|
-
providerID: 'opencode',
|
|
484
|
-
modelID: 'gpt-5-nano',
|
|
485
|
-
messages: [{ role: 'user', content: 'hi' }],
|
|
486
|
-
clientFactory: () => ({
|
|
487
|
-
session: {
|
|
488
|
-
create: async () => ({ data: { id: 'ses_tmp' } }),
|
|
489
|
-
update: async () => ({ data: { id: 'ses_tmp' } }),
|
|
490
|
-
prompt: vi.fn(),
|
|
491
|
-
promptAsync: async () => ({ response: { status: 204 } }),
|
|
492
|
-
status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
|
|
493
|
-
messages: async () => ({
|
|
494
|
-
data: [{
|
|
495
|
-
info: { role: 'assistant', error: { message: 'boom' }, time: { completed: Date.now() } },
|
|
496
|
-
parts: [],
|
|
497
|
-
}],
|
|
498
|
-
}),
|
|
499
|
-
delete: async () => ({ data: true }),
|
|
500
|
-
},
|
|
501
|
-
tool: { ids: async () => ({ data: [] }) },
|
|
502
|
-
}),
|
|
503
|
-
ensureTempDirectory: async () => '/tmp/openchamber-llm',
|
|
504
|
-
detect: async () => ({ available: false, mode: 'throwaway-session' }),
|
|
505
|
-
onTextDelta,
|
|
506
|
-
globalEventHub,
|
|
507
|
-
})).rejects.toMatchObject({ code: 'upstream_error', message: 'boom' })
|
|
508
|
-
|
|
509
|
-
expect(subscribers.size).toBe(0)
|
|
510
|
-
})
|
|
511
|
-
|
|
512
|
-
it('throws when tool.ids returns an SDK error instead of denying nothing', async () => {
|
|
513
|
-
const remove = vi.fn(async () => ({ data: true }))
|
|
514
|
-
await expect(generateOpenCodeText({
|
|
515
|
-
buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
|
|
516
|
-
getOpenCodeAuthHeaders: () => ({}),
|
|
517
|
-
providerID: 'opencode',
|
|
518
|
-
modelID: 'gpt-5-nano',
|
|
519
|
-
messages: [{ role: 'user', content: 'hi' }],
|
|
520
|
-
clientFactory: () => ({
|
|
521
|
-
session: {
|
|
522
|
-
create: vi.fn(async () => ({ data: { id: 'ses_tmp' } })),
|
|
523
|
-
update: vi.fn(),
|
|
524
|
-
prompt: vi.fn(),
|
|
525
|
-
promptAsync: vi.fn(),
|
|
526
|
-
status: vi.fn(),
|
|
527
|
-
messages: vi.fn(),
|
|
528
|
-
delete: remove,
|
|
529
|
-
},
|
|
530
|
-
tool: { ids: async () => ({ error: { status: 500, message: 'tool catalog unavailable' } }) },
|
|
531
|
-
}),
|
|
532
|
-
ensureTempDirectory: async () => '/tmp/openchamber-llm',
|
|
533
|
-
detect: async () => ({ available: false, mode: 'throwaway-session' }),
|
|
534
|
-
})).rejects.toMatchObject({
|
|
535
|
-
code: 'upstream_error',
|
|
536
|
-
message: expect.stringContaining('tool.ids failed'),
|
|
537
|
-
})
|
|
538
|
-
expect(remove).not.toHaveBeenCalled()
|
|
539
|
-
})
|
|
540
|
-
|
|
541
|
-
it('throws when session.messages returns a deterministic 400', async () => {
|
|
542
|
-
const remove = vi.fn(async () => ({ data: true }))
|
|
543
|
-
await expect(generateOpenCodeText({
|
|
544
|
-
buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
|
|
545
|
-
getOpenCodeAuthHeaders: () => ({}),
|
|
546
|
-
providerID: 'opencode',
|
|
547
|
-
modelID: 'gpt-5-nano',
|
|
548
|
-
messages: [{ role: 'user', content: 'hi' }],
|
|
549
|
-
clientFactory: () => ({
|
|
550
|
-
session: {
|
|
551
|
-
create: async () => ({ data: { id: 'ses_tmp' } }),
|
|
552
|
-
update: async () => ({ data: { id: 'ses_tmp' } }),
|
|
553
|
-
prompt: vi.fn(),
|
|
554
|
-
promptAsync: async () => ({ response: { status: 204 } }),
|
|
555
|
-
status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
|
|
556
|
-
messages: async () => ({ error: { status: 400, message: 'OutputFormatJsonSchema' } }),
|
|
557
|
-
delete: remove,
|
|
558
|
-
},
|
|
559
|
-
tool: { ids: async () => ({ data: ['bash'] }) },
|
|
560
|
-
}),
|
|
561
|
-
ensureTempDirectory: async () => '/tmp/openchamber-llm',
|
|
562
|
-
detect: async () => ({ available: false, mode: 'throwaway-session' }),
|
|
563
|
-
})).rejects.toMatchObject({
|
|
564
|
-
code: 'upstream_error',
|
|
565
|
-
message: expect.stringMatching(/session\.messages failed.*400|OutputFormatJsonSchema/),
|
|
566
|
-
})
|
|
567
|
-
expect(remove).toHaveBeenCalled()
|
|
568
|
-
})
|
|
569
|
-
|
|
570
|
-
it('logs SDK delete errors without erasing a successful generate result', async () => {
|
|
515
|
+
it('logs remove errors without erasing a successful generate result', async () => {
|
|
571
516
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
517
|
+
const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
|
|
572
518
|
try {
|
|
573
519
|
const result = await generateOpenCodeText({
|
|
574
520
|
buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
|
|
575
521
|
getOpenCodeAuthHeaders: () => ({}),
|
|
576
522
|
providerID: 'opencode',
|
|
577
|
-
modelID: 'gpt-
|
|
578
|
-
messages: [{ role: 'user', content: '
|
|
523
|
+
modelID: 'gpt-4o',
|
|
524
|
+
messages: [{ role: 'user', content: 'look', parts: [image] }],
|
|
579
525
|
clientFactory: () => ({
|
|
526
|
+
agent: { get: async () => denyAllAgent },
|
|
580
527
|
session: {
|
|
581
|
-
create: async () => ({
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
528
|
+
create: async () => ({ id: 'ses_tmp' }),
|
|
529
|
+
prompt: async () => ({ id: 'inbox' }),
|
|
530
|
+
wait: async () => undefined,
|
|
531
|
+
remove: async () => {
|
|
532
|
+
throw new Error('delete denied')
|
|
533
|
+
},
|
|
534
|
+
interrupt: vi.fn(),
|
|
535
|
+
instructions: { entry: { put: async () => {} } },
|
|
536
|
+
},
|
|
537
|
+
message: {
|
|
538
|
+
list: async () => ({ data: [completedAssistant('ok')], cursor: {} }),
|
|
588
539
|
},
|
|
589
|
-
tool: { ids: async () => ({ data: [] }) },
|
|
590
540
|
}),
|
|
591
541
|
ensureTempDirectory: async () => '/tmp/openchamber-llm',
|
|
592
|
-
|
|
542
|
+
forwardImageParts: true,
|
|
593
543
|
})
|
|
594
|
-
expect(result).toEqual({ text: 'ok', source: '
|
|
544
|
+
expect(result).toEqual({ text: 'ok', source: 'attachment-session' })
|
|
595
545
|
expect(warn).toHaveBeenCalledWith(
|
|
596
|
-
expect.stringContaining('[llm] failed to
|
|
546
|
+
expect.stringContaining('[llm] failed to remove throwaway OpenCode session:'),
|
|
597
547
|
expect.stringContaining('delete denied'),
|
|
598
548
|
)
|
|
599
549
|
} finally {
|
|
600
550
|
warn.mockRestore()
|
|
601
551
|
}
|
|
602
552
|
})
|
|
553
|
+
})
|
|
603
554
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
555
|
+
describe('subscribeThrowawayTextDeltas', () => {
|
|
556
|
+
it('returns null without a callback or hub', () => {
|
|
557
|
+
expect(_test.subscribeThrowawayTextDeltas({
|
|
558
|
+
sessionID: 'ses_1',
|
|
559
|
+
onTextDelta: null,
|
|
560
|
+
globalEventHub: { subscribeEvent: () => () => {} },
|
|
561
|
+
})).toBeNull()
|
|
562
|
+
expect(_test.subscribeThrowawayTextDeltas({
|
|
563
|
+
sessionID: 'ses_1',
|
|
564
|
+
onTextDelta: () => {},
|
|
565
|
+
globalEventHub: null,
|
|
566
|
+
})).toBeNull()
|
|
567
|
+
})
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
describe('data URL validation', () => {
|
|
571
|
+
it('rejects oversized attachments', () => {
|
|
572
|
+
const huge = Buffer.alloc(_test.MAX_ATTACHMENT_BYTES + 1, 1).toString('base64')
|
|
573
|
+
expect(() => _test.parseDataUrl(`data:image/png;base64,${huge}`)).toThrow(/limit/)
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
it('accepts small valid data URLs', () => {
|
|
577
|
+
const parsed = _test.parseDataUrl('data:image/png;base64,aa')
|
|
578
|
+
expect(parsed?.mime).toBe('image/png')
|
|
579
|
+
expect(parsed?.byteLength).toBeGreaterThan(0)
|
|
580
|
+
})
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
describe('generateOpenCodeText — real OpenCode.make + fake HTTP', () => {
|
|
584
|
+
it('catalog-shaped generate.text completion through the real client', async () => {
|
|
585
|
+
const { OpenCode } = await import('@opencode/client')
|
|
586
|
+
const fetchImpl = vi.fn(async (url, init) => {
|
|
587
|
+
const path = String(url)
|
|
588
|
+
if (path.includes('/api/experimental/generate') && init?.method === 'POST') {
|
|
589
|
+
const body = JSON.parse(init.body)
|
|
590
|
+
expect(body.model).toEqual({ id: 'gpt-5-nano', providerID: 'opencode' })
|
|
591
|
+
expect(body.prompt).toContain('User: hi')
|
|
592
|
+
return jsonResponse(200, { data: { text: 'hello from generate' } })
|
|
593
|
+
}
|
|
594
|
+
return jsonResponse(500, { _tag: 'UnknownError', message: `unexpected ${path}` })
|
|
595
|
+
})
|
|
617
596
|
|
|
618
597
|
const result = await generateOpenCodeText({
|
|
619
598
|
buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
|
|
620
|
-
getOpenCodeAuthHeaders: () => ({}),
|
|
599
|
+
getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic test' }),
|
|
621
600
|
providerID: 'opencode',
|
|
622
601
|
modelID: 'gpt-5-nano',
|
|
623
602
|
messages: [{ role: 'user', content: 'hi' }],
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
603
|
+
clientFactory: () => OpenCode.make({
|
|
604
|
+
baseUrl: 'http://127.0.0.1:4096',
|
|
605
|
+
headers: { Authorization: 'Basic test' },
|
|
606
|
+
fetch: fetchImpl,
|
|
607
|
+
}),
|
|
628
608
|
})
|
|
629
609
|
|
|
630
|
-
expect(result).toEqual({ text: '
|
|
631
|
-
expect(
|
|
632
|
-
expect(subscribers.size).toBe(0)
|
|
610
|
+
expect(result).toEqual({ text: 'hello from generate', source: 'generate.text' })
|
|
611
|
+
expect(fetchImpl).toHaveBeenCalled()
|
|
633
612
|
})
|
|
634
|
-
})
|
|
635
613
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
let
|
|
639
|
-
const
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
return
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
614
|
+
it('attachment deny-all failure happens before prompt on the real client path shape', async () => {
|
|
615
|
+
const { OpenCode } = await import('@opencode/client')
|
|
616
|
+
let prompted = false
|
|
617
|
+
const fetchImpl = vi.fn(async (url, init) => {
|
|
618
|
+
const path = String(url)
|
|
619
|
+
if (path.includes('/api/agent/openchamber-llm')) {
|
|
620
|
+
return jsonResponse(200, {
|
|
621
|
+
location: { directory: '/tmp/openchamber-llm', project: { id: 'p', directory: '/tmp', canonical: '/tmp' } },
|
|
622
|
+
data: {
|
|
623
|
+
id: 'openchamber-llm',
|
|
624
|
+
name: 'openchamber-llm',
|
|
625
|
+
mode: 'primary',
|
|
626
|
+
hidden: true,
|
|
627
|
+
permissions: [{ action: 'bash', resource: '*', effect: 'allow' }],
|
|
628
|
+
request: {},
|
|
629
|
+
},
|
|
630
|
+
})
|
|
631
|
+
}
|
|
632
|
+
if (path.includes('/prompt')) {
|
|
633
|
+
prompted = true
|
|
634
|
+
return jsonResponse(200, { data: { id: 'inbox' } })
|
|
635
|
+
}
|
|
636
|
+
if (path.endsWith('/api/session') && init?.method === 'POST') {
|
|
637
|
+
return jsonResponse(200, {
|
|
638
|
+
data: {
|
|
639
|
+
id: 'ses_tmp',
|
|
640
|
+
projectID: 'p',
|
|
641
|
+
cost: 0,
|
|
642
|
+
tokens: {},
|
|
643
|
+
time: { created: 1, updated: 1 },
|
|
644
|
+
location: { directory: '/tmp/openchamber-llm' },
|
|
645
|
+
},
|
|
646
|
+
})
|
|
647
|
+
}
|
|
648
|
+
return jsonResponse(500, { _tag: 'UnknownError', message: `unexpected ${path}` })
|
|
648
649
|
})
|
|
649
|
-
expect(polls).toBeGreaterThan(5)
|
|
650
|
-
expect(result).toEqual({ text: 'late reply', source: 'throwaway-session' })
|
|
651
|
-
})
|
|
652
650
|
|
|
653
|
-
|
|
654
|
-
await expect(
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
651
|
+
const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
|
|
652
|
+
await expect(generateOpenCodeText({
|
|
653
|
+
buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
|
|
654
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
655
|
+
providerID: 'opencode',
|
|
656
|
+
modelID: 'gpt-4o',
|
|
657
|
+
messages: [{ role: 'user', content: 'look', parts: [image] }],
|
|
658
|
+
clientFactory: () => OpenCode.make({
|
|
659
|
+
baseUrl: 'http://127.0.0.1:4096',
|
|
660
|
+
fetch: fetchImpl,
|
|
661
|
+
}),
|
|
662
|
+
ensureTempDirectory: async () => '/tmp/openchamber-llm',
|
|
663
|
+
forwardImageParts: true,
|
|
664
|
+
})).rejects.toMatchObject({ code: 'llm_attachment_generation_unavailable' })
|
|
665
|
+
expect(prompted).toBe(false)
|
|
661
666
|
})
|
|
662
|
-
})
|
|
663
667
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
668
|
+
it('attachment success path removes the session after message.list', async () => {
|
|
669
|
+
const { OpenCode } = await import('@opencode/client')
|
|
670
|
+
const calls = []
|
|
671
|
+
const fetchImpl = vi.fn(async (url, init) => {
|
|
672
|
+
const path = String(url)
|
|
673
|
+
const method = init?.method || 'GET'
|
|
674
|
+
calls.push(`${method} ${path.replace(/^https?:\/\/[^/]+/, '')}`)
|
|
675
|
+
if (path.includes('/api/agent/openchamber-llm')) {
|
|
676
|
+
return jsonResponse(200, {
|
|
677
|
+
location: { directory: '/tmp/openchamber-llm', project: { id: 'p', directory: '/tmp', canonical: '/tmp' } },
|
|
678
|
+
data: {
|
|
679
|
+
id: 'openchamber-llm',
|
|
680
|
+
name: 'openchamber-llm',
|
|
681
|
+
mode: 'primary',
|
|
682
|
+
hidden: true,
|
|
683
|
+
permissions: [{ action: '*', resource: '*', effect: 'deny' }],
|
|
684
|
+
request: {},
|
|
685
|
+
},
|
|
686
|
+
})
|
|
687
|
+
}
|
|
688
|
+
if (path.endsWith('/api/session') && method === 'POST') {
|
|
689
|
+
return jsonResponse(200, {
|
|
690
|
+
data: {
|
|
691
|
+
id: 'ses_tmp',
|
|
692
|
+
projectID: 'p',
|
|
693
|
+
cost: 0,
|
|
694
|
+
tokens: {},
|
|
695
|
+
time: { created: 1, updated: 1 },
|
|
696
|
+
location: { directory: '/tmp/openchamber-llm' },
|
|
697
|
+
},
|
|
698
|
+
})
|
|
699
|
+
}
|
|
700
|
+
if (path.includes('/instructions/entries/')) {
|
|
701
|
+
return { ok: true, status: 204, headers: { get: () => null }, text: async () => '', json: async () => null, arrayBuffer: async () => new ArrayBuffer(0) }
|
|
702
|
+
}
|
|
703
|
+
if (path.includes('/prompt')) {
|
|
704
|
+
return jsonResponse(200, { data: { id: 'inbox_1', type: 'user', text: 'look' } })
|
|
705
|
+
}
|
|
706
|
+
if (path.includes('/wait')) {
|
|
707
|
+
return { ok: true, status: 204, headers: { get: () => null }, text: async () => '', json: async () => null, arrayBuffer: async () => new ArrayBuffer(0) }
|
|
708
|
+
}
|
|
709
|
+
if (path.includes('/message') && method === 'GET') {
|
|
710
|
+
return jsonResponse(200, {
|
|
711
|
+
data: [completedAssistant('vision ok')],
|
|
712
|
+
cursor: {},
|
|
713
|
+
})
|
|
714
|
+
}
|
|
715
|
+
if (path.includes('/api/session/ses_tmp') && method === 'DELETE') {
|
|
716
|
+
return { ok: true, status: 204, headers: { get: () => null }, text: async () => '', json: async () => null, arrayBuffer: async () => new ArrayBuffer(0) }
|
|
717
|
+
}
|
|
718
|
+
return jsonResponse(500, { _tag: 'UnknownError', message: `unexpected ${method} ${path}` })
|
|
719
|
+
})
|
|
720
|
+
|
|
721
|
+
const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
|
|
722
|
+
const result = await generateOpenCodeText({
|
|
723
|
+
buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
|
|
724
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
725
|
+
providerID: 'opencode',
|
|
726
|
+
modelID: 'gpt-4o',
|
|
727
|
+
messages: [{ role: 'user', content: 'look', parts: [image] }],
|
|
728
|
+
clientFactory: () => OpenCode.make({
|
|
729
|
+
baseUrl: 'http://127.0.0.1:4096',
|
|
730
|
+
fetch: fetchImpl,
|
|
731
|
+
}),
|
|
732
|
+
ensureTempDirectory: async () => '/tmp/openchamber-llm',
|
|
733
|
+
forwardImageParts: true,
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
expect(result).toEqual({ text: 'vision ok', source: 'attachment-session' })
|
|
737
|
+
expect(calls.some((c) => c.startsWith('DELETE ') && c.includes('/api/session/ses_tmp'))).toBe(true)
|
|
738
|
+
const agentIdx = calls.findIndex((c) => c.includes('/api/agent/openchamber-llm'))
|
|
739
|
+
const promptIdx = calls.findIndex((c) => c.includes('/prompt'))
|
|
740
|
+
expect(agentIdx).toBeGreaterThanOrEqual(0)
|
|
741
|
+
expect(promptIdx).toBeGreaterThan(agentIdx)
|
|
676
742
|
})
|
|
677
743
|
})
|