@vantran-se/openchamber-web 1.22.1 → 1.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -3
- package/bin/cli.js +11 -1
- package/bin/cli.test.js +92 -0
- package/bin/lib/cli-startup.js +49 -0
- package/bin/lib/commands-startup.js +41 -6
- package/dist/assets/{CodeMirrorCommentWidgets-C53Q7pbX.js → CodeMirrorCommentWidgets-DFg2wnpo.js} +1 -1
- package/dist/assets/DiffView-3Dn4QHbu.js +4 -0
- package/dist/assets/DirtyBranchSwitchDialog-CwOerqVi.js +1 -0
- package/dist/assets/FilesView-BdTOpew9.js +17 -0
- package/dist/assets/GitView-Dqf9vHJh.js +6 -0
- package/dist/assets/{JsonTreeView-Bl0lp9Rs.js → JsonTreeView-BKdsjg5B.js} +1 -1
- package/dist/assets/LinearIssuesView-CmbeL7I6.js +1 -0
- package/dist/assets/MarkdownImageGallery-_5gEVr0X.js +1 -0
- package/dist/assets/MarkdownRendererImpl-wVZlx4dd.js +12 -0
- package/dist/assets/OnboardingScreen-DdP4_nuc.js +1 -0
- package/dist/assets/{PatchDiff-D6NXAfJk.js → PatchDiff-am3SAss2.js} +1 -1
- package/dist/assets/PierreDiffViewer-DCsydlKs.js +161 -0
- package/dist/assets/PlanView-BoJxdRZe.js +5 -0
- package/dist/assets/SettingsView-CcM8pF5O.js +28 -0
- package/dist/assets/{SettingsWindow-BiWDBRvb.js → SettingsWindow-DVTdDSol.js} +1 -1
- package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
- package/dist/assets/{ToolOutputDialog-BQexMQIm.js → ToolOutputDialog-BgfTbDZ4.js} +6 -6
- package/dist/assets/ToolPartDiffPreview-C7WLk5i9.js +8 -0
- package/dist/assets/{VirtualizedFileDiff-Dpz2ra8h.js → VirtualizedFileDiff-DcdgkH-x.js} +1 -1
- package/dist/assets/WalkthroughView-BEVUDxuw.js +4 -0
- package/dist/assets/{computeVirtualFileMetrics-DtyozJdt.js → computeVirtualFileMetrics-C8byMnRh.js} +2 -2
- package/dist/assets/contextPanelEmbeddedChat-CirXEPJq.js +1 -0
- package/dist/assets/de-C8jV7J9y.js +10 -0
- package/dist/assets/es-BI6J1M5R.js +16 -0
- package/dist/assets/fr-D6oDFt7E.js +14 -0
- package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
- package/dist/assets/{index-DmhujXbT.js → index-BB7ryz8y.js} +1 -1
- package/dist/assets/index-BqhiCmYS.js +39 -0
- package/dist/assets/index-FqSOBz0J.css +1 -0
- package/dist/assets/{isDiffPlainText-XyulMHxx.js → isDiffPlainText-45ujMPGb.js} +1 -1
- package/dist/assets/{isFilePlainText-B19Ddps1.js → isFilePlainText-Cc4KFSYi.js} +1 -1
- package/dist/assets/ja-C_4i5EQu.js +16 -0
- package/dist/assets/ko-BFbOTpbq.js +16 -0
- package/dist/assets/{main-B2a_JfKZ.js → main-C9ETLKFc.js} +2 -2
- package/dist/assets/main-CJmb2dF8.js +930 -0
- package/dist/assets/markdownCore-DVuZpnli.js +5 -0
- package/dist/assets/{miniChat-B3CSU_Ps.js → miniChat-B4Ytoq2n.js} +2 -2
- package/dist/assets/{mobile-QNcFIVCW.js → mobile-BUT6-lo1.js} +2 -2
- package/dist/assets/patchFileDiff-8j1apICU.js +13 -0
- package/dist/assets/pl-COFvT56l.js +16 -0
- package/dist/assets/pt-BR-DtVOO2Lj.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-B6vUG-vF.js +2 -0
- package/dist/assets/renderMobileApp-BNgHraq6.js +25 -0
- package/dist/assets/runtimeEndpointReset-B8LYpWvr.js +17 -0
- package/dist/assets/scheduledTasksApi-GC4epIYA.js +1 -0
- package/dist/assets/{shikiHighlight-DE-OwqGw.js → shikiHighlight-BJISeZsQ.js} +1 -1
- package/dist/assets/surface-BvRzS58U.js +1 -0
- package/dist/assets/tr-B8Q0Go2l.js +7 -0
- package/dist/assets/uk-Dk4a-Fn-.js +16 -0
- package/dist/assets/{useAppFontEffects-COAeIX-D.js → useAppFontEffects-DpFNefUA.js} +153 -179
- package/dist/assets/useGitComparison-Dfxaqd0s.js +1 -0
- package/dist/assets/useInlineCommentController-CscsE4t-.js +2 -0
- package/dist/assets/useRootScrollLock-BaCvuRlT.js +1 -0
- package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-DdV8vhHn.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-BG3v1Ghq.js → vendor-codemirror-language-data-DzlSIu_W.js} +2 -2
- package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
- package/dist/assets/vendor-zod-BPAANqvH.js +39 -0
- package/dist/assets/{web-components-BNMwa0qm.js → web-components-CdGfR2XJ.js} +1 -1
- package/dist/assets/zh-CN-B_BXxG1Q.js +16 -0
- package/dist/assets/zh-TW-DLrl1SPZ.js +16 -0
- package/dist/index.html +5 -43
- package/dist/mini-chat.html +4 -4
- package/dist/mobile.html +4 -4
- package/package.json +8 -5
- package/server/index.d.ts +15 -0
- package/server/index.js +82 -13
- package/server/lib/agent-tool/runtime.js +2 -19
- package/server/lib/changelog/update-notes.js +82 -0
- package/server/lib/changelog/update-notes.test.js +59 -0
- package/server/lib/data-dir-migration.js +40 -0
- package/server/lib/data-dir-migration.test.js +59 -0
- package/server/lib/dev-tunnel/DOCUMENTATION.md +13 -4
- package/server/lib/dev-tunnel/runtime.js +4 -4
- package/server/lib/dev-tunnel/tunnel.test.js +20 -0
- package/server/lib/event-stream/runtime.test.js +12 -12
- package/server/lib/fs/DOCUMENTATION.md +5 -0
- package/server/lib/fs/routes.js +63 -21
- package/server/lib/fs/routes.test.js +187 -1
- package/server/lib/git/DOCUMENTATION.md +32 -6
- package/server/lib/git/routes.js +40 -4
- package/server/lib/git/service.js +291 -215
- package/server/lib/git/service.test.js +593 -25
- package/server/lib/inherited-env.js +16 -9
- package/server/lib/inherited-env.test.js +8 -9
- package/server/lib/mcp-reconnect/DOCUMENTATION.md +55 -0
- package/server/lib/mcp-reconnect/runtime.js +122 -0
- package/server/lib/mcp-reconnect/runtime.test.js +140 -0
- package/server/lib/message-queue/DOCUMENTATION.md +161 -0
- package/server/lib/message-queue/runtime.js +870 -0
- package/server/lib/message-queue/runtime.test.js +482 -0
- package/server/lib/network-defaults.js +23 -0
- package/server/lib/network-defaults.test.js +52 -0
- package/server/lib/openchamber-sessions/routes.js +102 -0
- package/server/lib/openchamber-sessions/routes.test.js +88 -0
- package/server/lib/opencode/DOCUMENTATION.md +30 -6
- package/server/lib/opencode/bootstrap-runtime.js +2 -0
- package/server/lib/opencode/config-paths.test.js +116 -0
- package/server/lib/opencode/core-routes.js +1 -0
- package/server/lib/opencode/feature-routes-runtime.js +7 -0
- package/server/lib/opencode/input-history-scope.js +14 -0
- package/server/lib/opencode/managed-plugin-config.js +35 -0
- package/server/lib/opencode/openchamber-routes.js +64 -4
- package/server/lib/opencode/openchamber-routes.test.js +130 -3
- package/server/lib/opencode/plugins.js +2 -2
- package/server/lib/opencode/providers.js +44 -2
- package/server/lib/opencode/providers.test.js +131 -4
- package/server/lib/opencode/routes.js +9 -7
- package/server/lib/opencode/server-startup-runtime.js +1 -1
- package/server/lib/opencode/settings-files.js +214 -0
- package/server/lib/opencode/settings-helpers.js +113 -41
- package/server/lib/opencode/settings-helpers.test.js +342 -0
- package/server/lib/opencode/settings-registry.json +747 -0
- package/server/lib/opencode/settings-runtime.js +141 -20
- package/server/lib/opencode/settings-runtime.test.js +191 -4
- package/server/lib/opencode/shared.js +4 -1
- package/server/lib/opencode/shutdown-runtime.js +2 -0
- package/server/lib/opencode/skill-routes.js +2 -1
- package/server/lib/opencode/snippets.js +1 -2
- package/server/lib/package-manager.js +4 -29
- package/server/lib/package-manager.test.js +2 -2
- package/server/lib/project-context/DOCUMENTATION.md +25 -1
- package/server/lib/project-context/routes.http.test.js +18 -0
- package/server/lib/project-context/routes.js +15 -0
- package/server/lib/project-context/runtime.js +248 -23
- package/server/lib/project-context/runtime.test.js +106 -3
- package/server/lib/projects/DOCUMENTATION.md +76 -0
- package/server/lib/projects/project-config.js +142 -9
- package/server/lib/projects/project-id.js +17 -0
- package/server/lib/projects/project-setup.js +409 -0
- package/server/lib/projects/project-setup.test.js +557 -0
- package/server/lib/projects/routes.js +59 -0
- package/server/lib/quota/DOCUMENTATION.md +40 -2
- package/server/lib/quota/credentials/providers.js +4 -0
- package/server/lib/quota/credentials/store.js +1 -1
- package/server/lib/quota/credentials/store.test.js +4 -4
- package/server/lib/quota/index.js +1 -0
- package/server/lib/quota/providers/cline-pass.js +125 -0
- package/server/lib/quota/providers/cline-pass.test.js +103 -0
- package/server/lib/quota/providers/exe-dev.js +75 -0
- package/server/lib/quota/providers/exe-dev.test.js +47 -0
- package/server/lib/quota/providers/hyper.js +118 -0
- package/server/lib/quota/providers/hyper.test.js +126 -0
- package/server/lib/quota/providers/index.js +22 -0
- package/server/lib/quota/providers/neuralwatt.js +3 -6
- package/server/lib/quota/providers/neuralwatt.test.js +7 -7
- package/server/lib/quota/providers/ollama-cloud.js +31 -0
- package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
- package/server/lib/quota/providers/opencode-go.js +2 -1
- package/server/lib/quota/providers/opencode-go.test.js +31 -2
- package/server/lib/quota/providers/openrouter.js +119 -24
- package/server/lib/quota/providers/openrouter.test.js +330 -0
- package/server/lib/quota/routes.js +2 -0
- package/server/lib/quota/utils/auth.js +1 -1
- package/server/lib/relay/DOCUMENTATION.md +56 -1
- package/server/lib/relay/cross-compat.test.js +26 -0
- package/server/lib/relay/downstream-scheduler.js +154 -0
- package/server/lib/relay/downstream-scheduler.test.js +162 -0
- package/server/lib/relay/e2ee.js +10 -8
- package/server/lib/relay/flow-control.test.js +177 -0
- package/server/lib/relay/host-client.js +37 -8
- package/server/lib/relay/host-client.test.js +9 -1
- package/server/lib/relay/tunnel-codec.js +9 -0
- package/server/lib/relay/tunnel-host.js +69 -18
- package/server/lib/relay/tunnel-host.test.js +9 -1
- package/server/lib/security/request-security.js +21 -6
- package/server/lib/security/request-security.test.js +42 -8
- package/server/lib/session-assist/runtime.js +7 -10
- package/server/lib/session-goal/DOCUMENTATION.md +20 -7
- package/server/lib/session-goal/create.js +3 -1
- package/server/lib/session-goal/runtime.js +81 -20
- package/server/lib/session-goal/runtime.test.js +426 -0
- package/server/lib/skills-catalog/DOCUMENTATION.md +1 -1
- package/server/lib/skills-catalog/install.js +3 -2
- package/server/lib/small-model/DOCUMENTATION.md +7 -2
- package/server/lib/small-model/call.js +17 -6
- package/server/lib/small-model/call.test.js +52 -0
- package/server/lib/small-model/index.js +7 -11
- package/server/lib/small-model/routes.js +2 -1
- package/server/lib/small-model/runtime-providers.js +15 -7
- package/server/lib/small-model/runtime-providers.test.js +7 -1
- package/server/lib/system-prompt/runtime.js +2 -21
- package/server/lib/terminal/DOCUMENTATION.md +14 -10
- package/server/lib/terminal/runtime.js +190 -31
- package/server/lib/terminal/runtime.test.js +571 -37
- package/server/lib/terminal/shells.js +17 -0
- package/server/lib/terminal/shells.test.js +32 -1
- package/server/lib/ui-auth/ui-auth.js +4 -1
- package/server/lib/ui-auth/ui-auth.test.js +16 -0
- package/server/lib/walkthrough/DOCUMENTATION.md +33 -14
- package/server/lib/walkthrough/index.js +1 -0
- package/server/lib/walkthrough/model-settings.js +8 -12
- package/server/lib/walkthrough/prompt.js +3 -1
- package/server/lib/walkthrough/routes.test.js +26 -5
- package/server/lib/walkthrough/sources.js +23 -2
- package/dist/assets/CommitSection-X-f2RgYr.js +0 -1
- package/dist/assets/DiffView-Dh1ey2x9.js +0 -4
- package/dist/assets/FilesView-3ZYP72ki.js +0 -17
- package/dist/assets/GitView-CMuN7z2Z.js +0 -6
- package/dist/assets/LinearIssuesView-CQiUDU8b.js +0 -1
- package/dist/assets/MarkdownImageGallery-bA0PS-tn.js +0 -1
- package/dist/assets/MarkdownRendererImpl-BGQi2PLq.js +0 -12
- package/dist/assets/OnboardingScreen-D3kYJIb6.js +0 -1
- package/dist/assets/PierreDiffViewer-Ct2A13NO.js +0 -157
- package/dist/assets/PlanView-6AUjzNQn.js +0 -5
- package/dist/assets/SettingsView-CHI8fFaK.js +0 -28
- package/dist/assets/ToolPartDiffPreview-DPAATNn6.js +0 -8
- package/dist/assets/WalkthroughView-AX-u2pFg.js +0 -4
- package/dist/assets/contextPanelEmbeddedChat-C9EcRSDF.js +0 -1
- package/dist/assets/de-DqVn5vme.js +0 -9
- package/dist/assets/es-hj43Z53i.js +0 -15
- package/dist/assets/fr-BmkZt2mF.js +0 -13
- package/dist/assets/index-L_ncV-6H.css +0 -1
- package/dist/assets/index-qES19dlJ.js +0 -7
- package/dist/assets/ja-DioMIEGF.js +0 -15
- package/dist/assets/ko-BqPgaQJb.js +0 -15
- package/dist/assets/main-CYgSu6dx.js +0 -928
- package/dist/assets/markdownCore-CWRCqSH2.js +0 -3
- package/dist/assets/patchFileDiff-BpMQZnYu.js +0 -10
- package/dist/assets/pl-slULBF4R.js +0 -15
- package/dist/assets/pt-BR-BqH8fuCH.js +0 -15
- package/dist/assets/renderElectronMiniChatApp-D77bmLqo.js +0 -2
- package/dist/assets/renderMobileApp-BQ3Kdm-d.js +0 -25
- package/dist/assets/runtimeEndpointReset-CRGn1gVj.js +0 -16
- package/dist/assets/scheduledTasksApi-BSRCtP6T.js +0 -1
- package/dist/assets/tr-CjEbw1Aw.js +0 -6
- package/dist/assets/uk-BkkfxbOs.js +0 -15
- package/dist/assets/useInlineCommentController-JsBboQwt.js +0 -2
- package/dist/assets/useRootScrollLock-C0N9mCwz.js +0 -1
- package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
- package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
- package/dist/assets/vendor-zod-BLfBSgBh.js +0 -39
- package/dist/assets/zh-CN-C6FMdVgv.js +0 -15
- package/dist/assets/zh-TW-CdcMjDxv.js +0 -15
|
@@ -46,6 +46,78 @@ const startIdleTick = async (fetchImpl) => {
|
|
|
46
46
|
return { runtime, getSmallModelService };
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
const assistantMessage = (id, infoOverrides = {}) => ({
|
|
50
|
+
info: {
|
|
51
|
+
id,
|
|
52
|
+
sessionID: SESSION_ID,
|
|
53
|
+
role: 'assistant',
|
|
54
|
+
providerID: 'provider',
|
|
55
|
+
modelID: 'model',
|
|
56
|
+
time: { created: 2, completed: 2 },
|
|
57
|
+
tokens: { input: 1, output: 1, cache: { read: 0 } },
|
|
58
|
+
...infoOverrides,
|
|
59
|
+
},
|
|
60
|
+
parts: [{ type: 'text', text: 'The agent made progress.' }],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const createRuntimeHarness = ({ messages, messageFactory, goalOverrides = {}, maxAutoTurns = 20 }) => {
|
|
64
|
+
const requests = [];
|
|
65
|
+
let messageFetchCount = 0;
|
|
66
|
+
const activeSession = {
|
|
67
|
+
...session,
|
|
68
|
+
metadata: { openchamber: { goal: { ...goal, ...goalOverrides } } },
|
|
69
|
+
};
|
|
70
|
+
const service = {
|
|
71
|
+
generateSmallModelText: vi.fn(async () => ({
|
|
72
|
+
text: '{"verdict":"continue","note":"More work remains"}',
|
|
73
|
+
providerID: 'provider',
|
|
74
|
+
modelID: 'model',
|
|
75
|
+
})),
|
|
76
|
+
};
|
|
77
|
+
const fetchImpl = vi.fn(async (input, init = {}) => {
|
|
78
|
+
const pathname = requestPath(input);
|
|
79
|
+
requests.push({ pathname, method: init.method ?? 'GET', body: init.body });
|
|
80
|
+
if (pathname === `/session/${SESSION_ID}` && init.method === 'PATCH') {
|
|
81
|
+
activeSession.metadata = JSON.parse(init.body).metadata;
|
|
82
|
+
return jsonResponse(activeSession);
|
|
83
|
+
}
|
|
84
|
+
if (pathname === `/session/${SESSION_ID}`) return jsonResponse(activeSession);
|
|
85
|
+
if (pathname === '/session/status') return jsonResponse({});
|
|
86
|
+
if (pathname === `/session/${SESSION_ID}/children`) return jsonResponse([]);
|
|
87
|
+
if (pathname === `/session/${SESSION_ID}/message`) {
|
|
88
|
+
const nextMessages = messageFactory ? messageFactory(messageFetchCount) : messages;
|
|
89
|
+
messageFetchCount += 1;
|
|
90
|
+
return jsonResponse(nextMessages);
|
|
91
|
+
}
|
|
92
|
+
if (pathname === `/session/${SESSION_ID}/prompt_async`) return jsonResponse({ ok: true });
|
|
93
|
+
throw new Error(`Unexpected request: ${pathname}`);
|
|
94
|
+
});
|
|
95
|
+
vi.stubGlobal('fetch', fetchImpl);
|
|
96
|
+
const runtime = createSessionGoalRuntime({
|
|
97
|
+
buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
|
|
98
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
99
|
+
getSmallModelService: async () => service,
|
|
100
|
+
isEnabled: () => true,
|
|
101
|
+
idleQuietMs: 10,
|
|
102
|
+
maxAutoTurns,
|
|
103
|
+
});
|
|
104
|
+
return { runtime, requests, service, activeSession };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const runIdleTick = async (runtime) => {
|
|
108
|
+
runtime.processPayload({
|
|
109
|
+
type: 'session.status',
|
|
110
|
+
properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
|
|
111
|
+
});
|
|
112
|
+
await vi.runOnlyPendingTimersAsync();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const lastPatchedGoal = (requests) => {
|
|
116
|
+
const patches = requests.filter((request) => request.pathname === `/session/${SESSION_ID}` && request.method === 'PATCH');
|
|
117
|
+
expect(patches.length).toBeGreaterThan(0);
|
|
118
|
+
return JSON.parse(patches.at(-1).body).metadata.openchamber.goal;
|
|
119
|
+
};
|
|
120
|
+
|
|
49
121
|
describe('session goal live activity gate', () => {
|
|
50
122
|
beforeEach(() => {
|
|
51
123
|
vi.useFakeTimers();
|
|
@@ -178,4 +250,358 @@ describe('session goal live activity gate', () => {
|
|
|
178
250
|
});
|
|
179
251
|
runtime.stop();
|
|
180
252
|
});
|
|
253
|
+
|
|
254
|
+
it('skips audit and sends continuation prompt when assistant message finishes with length stop', async () => {
|
|
255
|
+
const requests = [];
|
|
256
|
+
const fetchImpl = vi.fn(async (input, init = {}) => {
|
|
257
|
+
const pathname = requestPath(input);
|
|
258
|
+
requests.push({ pathname, method: init.method ?? 'GET', body: init.body });
|
|
259
|
+
if (pathname === `/session/${SESSION_ID}` && init.method === 'PATCH') return jsonResponse(session);
|
|
260
|
+
if (pathname === `/session/${SESSION_ID}`) return jsonResponse(session);
|
|
261
|
+
if (pathname === '/session/status') return jsonResponse({});
|
|
262
|
+
if (pathname === `/session/${SESSION_ID}/children`) return jsonResponse([]);
|
|
263
|
+
if (pathname === `/session/${SESSION_ID}/prompt_async`) return jsonResponse({ ok: true });
|
|
264
|
+
if (pathname === `/session/${SESSION_ID}/message`) {
|
|
265
|
+
return jsonResponse([{
|
|
266
|
+
info: {
|
|
267
|
+
id: 'msg_assistant_len',
|
|
268
|
+
sessionID: SESSION_ID,
|
|
269
|
+
role: 'assistant',
|
|
270
|
+
providerID: 'provider',
|
|
271
|
+
modelID: 'model',
|
|
272
|
+
finish: 'length',
|
|
273
|
+
time: { completed: 2 },
|
|
274
|
+
tokens: { input: 100, output: 4096, reasoning: 4096, cache: { read: 0 } },
|
|
275
|
+
},
|
|
276
|
+
parts: [{ type: 'reasoning', text: 'Drafting extensive implementation...' }],
|
|
277
|
+
}]);
|
|
278
|
+
}
|
|
279
|
+
throw new Error(`Unexpected request: ${pathname}`);
|
|
280
|
+
});
|
|
281
|
+
const service = {
|
|
282
|
+
generateSmallModelText: vi.fn(),
|
|
283
|
+
};
|
|
284
|
+
vi.stubGlobal('fetch', fetchImpl);
|
|
285
|
+
const runtime = createSessionGoalRuntime({
|
|
286
|
+
buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
|
|
287
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
288
|
+
getSmallModelService: async () => service,
|
|
289
|
+
isEnabled: () => true,
|
|
290
|
+
idleQuietMs: 10,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
runtime.processPayload({
|
|
294
|
+
type: 'session.status',
|
|
295
|
+
properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
|
|
296
|
+
});
|
|
297
|
+
await vi.runOnlyPendingTimersAsync();
|
|
298
|
+
|
|
299
|
+
// Audit must be skipped on length-truncated turn
|
|
300
|
+
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
301
|
+
|
|
302
|
+
// Goal accounting is persisted and turnsUsed incremented
|
|
303
|
+
const patch = requests.find((request) => request.pathname === `/session/${SESSION_ID}` && request.method === 'PATCH');
|
|
304
|
+
expect(patch).toBeDefined();
|
|
305
|
+
const writtenGoal = JSON.parse(patch.body).metadata.openchamber.goal;
|
|
306
|
+
expect(writtenGoal).toMatchObject({
|
|
307
|
+
status: 'active',
|
|
308
|
+
turnsUsed: 2,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Continuation prompt is dispatched
|
|
312
|
+
const promptAsync = requests.find((request) => request.pathname === `/session/${SESSION_ID}/prompt_async` && request.method === 'POST');
|
|
313
|
+
expect(promptAsync).toBeDefined();
|
|
314
|
+
const promptBody = JSON.parse(promptAsync.body);
|
|
315
|
+
expect(promptBody.parts[0].text).toContain('Continue working toward the active session goal.');
|
|
316
|
+
runtime.stop();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('skips audit and sends continuation prompt when assistant message carries MessageOutputLengthError', async () => {
|
|
320
|
+
const requests = [];
|
|
321
|
+
const fetchImpl = vi.fn(async (input, init = {}) => {
|
|
322
|
+
const pathname = requestPath(input);
|
|
323
|
+
requests.push({ pathname, method: init.method ?? 'GET', body: init.body });
|
|
324
|
+
if (pathname === `/session/${SESSION_ID}` && init.method === 'PATCH') return jsonResponse(session);
|
|
325
|
+
if (pathname === `/session/${SESSION_ID}`) return jsonResponse(session);
|
|
326
|
+
if (pathname === '/session/status') return jsonResponse({});
|
|
327
|
+
if (pathname === `/session/${SESSION_ID}/children`) return jsonResponse([]);
|
|
328
|
+
if (pathname === `/session/${SESSION_ID}/prompt_async`) return jsonResponse({ ok: true });
|
|
329
|
+
if (pathname === `/session/${SESSION_ID}/message`) {
|
|
330
|
+
return jsonResponse([{
|
|
331
|
+
info: {
|
|
332
|
+
id: 'msg_assistant_len_err',
|
|
333
|
+
sessionID: SESSION_ID,
|
|
334
|
+
role: 'assistant',
|
|
335
|
+
providerID: 'provider',
|
|
336
|
+
modelID: 'model',
|
|
337
|
+
error: { name: 'MessageOutputLengthError', message: 'Maximum token limit reached' },
|
|
338
|
+
time: { completed: 2 },
|
|
339
|
+
tokens: { input: 100, output: 4096, reasoning: 4096, cache: { read: 0 } },
|
|
340
|
+
},
|
|
341
|
+
parts: [{ type: 'reasoning', text: 'Drafting extensive implementation...' }],
|
|
342
|
+
}]);
|
|
343
|
+
}
|
|
344
|
+
throw new Error(`Unexpected request: ${pathname}`);
|
|
345
|
+
});
|
|
346
|
+
const service = {
|
|
347
|
+
generateSmallModelText: vi.fn(),
|
|
348
|
+
};
|
|
349
|
+
vi.stubGlobal('fetch', fetchImpl);
|
|
350
|
+
const runtime = createSessionGoalRuntime({
|
|
351
|
+
buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
|
|
352
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
353
|
+
getSmallModelService: async () => service,
|
|
354
|
+
isEnabled: () => true,
|
|
355
|
+
idleQuietMs: 10,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
runtime.processPayload({
|
|
359
|
+
type: 'session.status',
|
|
360
|
+
properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
|
|
361
|
+
});
|
|
362
|
+
await vi.runOnlyPendingTimersAsync();
|
|
363
|
+
|
|
364
|
+
// Audit must be skipped on MessageOutputLengthError
|
|
365
|
+
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
366
|
+
|
|
367
|
+
// Goal remains active and turnsUsed incremented
|
|
368
|
+
const patch = requests.find((request) => request.pathname === `/session/${SESSION_ID}` && request.method === 'PATCH');
|
|
369
|
+
expect(patch).toBeDefined();
|
|
370
|
+
const writtenGoal = JSON.parse(patch.body).metadata.openchamber.goal;
|
|
371
|
+
expect(writtenGoal).toMatchObject({
|
|
372
|
+
status: 'active',
|
|
373
|
+
turnsUsed: 2,
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// Continuation prompt is dispatched
|
|
377
|
+
const promptAsync = requests.find((request) => request.pathname === `/session/${SESSION_ID}/prompt_async` && request.method === 'POST');
|
|
378
|
+
expect(promptAsync).toBeDefined();
|
|
379
|
+
runtime.stop();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it.each([
|
|
383
|
+
{ name: 'APIError', error: { name: 'APIError', message: 'provider failed' } },
|
|
384
|
+
{ name: 'StructuredOutputError', error: { name: 'StructuredOutputError', message: 'invalid output' } },
|
|
385
|
+
{ name: 'unnamed errors', error: {} },
|
|
386
|
+
])('blocks a length finish when the assistant also has a non-length $name', async ({ error }) => {
|
|
387
|
+
const { runtime, requests, service } = createRuntimeHarness({
|
|
388
|
+
messages: [assistantMessage('msg_assistant_error', { finish: 'length', error })],
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
await runIdleTick(runtime);
|
|
392
|
+
|
|
393
|
+
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
394
|
+
expect(requests.some((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toBe(false);
|
|
395
|
+
expect(lastPatchedGoal(requests)).toMatchObject({
|
|
396
|
+
status: 'blocked',
|
|
397
|
+
statusReason: error.name || 'assistant turn failed',
|
|
398
|
+
});
|
|
399
|
+
runtime.stop();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('blocks after two consecutive length-truncated agent turns without persisting a counter, regardless of message IDs', async () => {
|
|
403
|
+
const firstLength = assistantMessage('z', { finish: 'length', time: { created: 10, completed: 11 } });
|
|
404
|
+
const summary = assistantMessage('summary', { summary: true, time: { created: 15, completed: 16 } });
|
|
405
|
+
const secondLength = assistantMessage('a', { finish: 'length', time: { created: 20, completed: 21 } });
|
|
406
|
+
const { runtime, requests, service } = createRuntimeHarness({
|
|
407
|
+
messageFactory: (fetchCount) => fetchCount < 2 ? [firstLength] : [firstLength, summary, secondLength],
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
await runIdleTick(runtime);
|
|
411
|
+
runtime.processPayload({
|
|
412
|
+
type: 'session.status',
|
|
413
|
+
properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
|
|
414
|
+
});
|
|
415
|
+
await vi.runOnlyPendingTimersAsync();
|
|
416
|
+
|
|
417
|
+
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
418
|
+
expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(1);
|
|
419
|
+
expect(lastPatchedGoal(requests)).toMatchObject({
|
|
420
|
+
status: 'blocked',
|
|
421
|
+
statusReason: 'repeated output truncation',
|
|
422
|
+
});
|
|
423
|
+
runtime.stop();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('allows one explicit Resume after repeated truncation, then blocks another cutoff', async () => {
|
|
427
|
+
const first = assistantMessage('first', { finish: 'length', time: { created: 10, completed: 11 } });
|
|
428
|
+
const second = assistantMessage('second', { finish: 'length', time: { created: 20, completed: 21 } });
|
|
429
|
+
const messages = [first, second];
|
|
430
|
+
const { runtime, requests, activeSession } = createRuntimeHarness({ messages });
|
|
431
|
+
try {
|
|
432
|
+
await runIdleTick(runtime);
|
|
433
|
+
expect(lastPatchedGoal(requests).status).toBe('blocked');
|
|
434
|
+
expect(requests.filter((request) => request.pathname.endsWith('/prompt_async'))).toHaveLength(0);
|
|
435
|
+
Object.assign(activeSession.metadata.openchamber.goal, { status: 'active', statusReason: 'resumed', turnsUsed: 0 });
|
|
436
|
+
await runIdleTick(runtime);
|
|
437
|
+
expect(lastPatchedGoal(requests)).toMatchObject({ status: 'active', statusReason: '', turnsUsed: 1 });
|
|
438
|
+
expect(requests.filter((request) => request.pathname.endsWith('/prompt_async'))).toHaveLength(1);
|
|
439
|
+
messages.push(assistantMessage('third', { finish: 'length', time: { created: 30, completed: 31 } }));
|
|
440
|
+
await runIdleTick(runtime);
|
|
441
|
+
expect(lastPatchedGoal(requests)).toMatchObject({ status: 'blocked', statusReason: 'repeated output truncation' });
|
|
442
|
+
expect(requests.filter((request) => request.pathname.endsWith('/prompt_async'))).toHaveLength(1);
|
|
443
|
+
} finally {
|
|
444
|
+
runtime.stop();
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it.each([
|
|
449
|
+
{ tokenBudget: 1, error: undefined, expectedStatus: 'budgetLimited' },
|
|
450
|
+
{ tokenBudget: null, error: { name: 'APIError' }, expectedStatus: 'blocked' },
|
|
451
|
+
])('keeps $expectedStatus protection on explicit Resume', async ({ tokenBudget, error, expectedStatus }) => {
|
|
452
|
+
const { runtime, requests } = createRuntimeHarness({
|
|
453
|
+
goalOverrides: { statusReason: 'resumed', tokenBudget },
|
|
454
|
+
messages: [assistantMessage('resumed', { finish: 'length', error })],
|
|
455
|
+
});
|
|
456
|
+
try {
|
|
457
|
+
await runIdleTick(runtime);
|
|
458
|
+
expect(lastPatchedGoal(requests).status).toBe(expectedStatus);
|
|
459
|
+
expect(requests.filter((request) => request.pathname.endsWith('/prompt_async'))).toHaveLength(0);
|
|
460
|
+
} finally {
|
|
461
|
+
runtime.stop();
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('continues after a truncated agent turn followed by a length-finished summary', async () => {
|
|
466
|
+
const firstLength = assistantMessage('agent-length', { finish: 'length', time: { created: 10, completed: 11 } });
|
|
467
|
+
const summary = assistantMessage('summary', {
|
|
468
|
+
summary: true,
|
|
469
|
+
finish: 'length',
|
|
470
|
+
time: { created: 15, completed: 16 },
|
|
471
|
+
});
|
|
472
|
+
const { runtime, requests, service } = createRuntimeHarness({
|
|
473
|
+
messageFactory: (fetchCount) => fetchCount < 2 ? [firstLength] : [firstLength, summary],
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
await runIdleTick(runtime);
|
|
477
|
+
await runIdleTick(runtime);
|
|
478
|
+
|
|
479
|
+
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
480
|
+
expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(2);
|
|
481
|
+
expect(lastPatchedGoal(requests)).toMatchObject({ status: 'active' });
|
|
482
|
+
runtime.stop();
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('does not infer a repeated streak when a completed assistant timestamp is missing', async () => {
|
|
486
|
+
const firstLength = assistantMessage('z', { finish: 'length', time: { completed: 11 } });
|
|
487
|
+
const secondLength = assistantMessage('a', { finish: 'length', time: { created: 20, completed: 21 } });
|
|
488
|
+
const { runtime, requests, service } = createRuntimeHarness({
|
|
489
|
+
messageFactory: (fetchCount) => fetchCount < 2 ? [firstLength] : [firstLength, secondLength],
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
await runIdleTick(runtime);
|
|
493
|
+
await runIdleTick(runtime);
|
|
494
|
+
|
|
495
|
+
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
496
|
+
expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(2);
|
|
497
|
+
expect(lastPatchedGoal(requests)).toMatchObject({ status: 'active' });
|
|
498
|
+
runtime.stop();
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('ignores a missing timestamp on an older assistant when known post-goal turns establish a streak', async () => {
|
|
502
|
+
const olderLength = assistantMessage('legacy', { finish: 'length', time: { completed: 5 } });
|
|
503
|
+
const firstLength = assistantMessage('z', { finish: 'length', time: { created: 10, completed: 11 } });
|
|
504
|
+
const secondLength = assistantMessage('a', { finish: 'length', time: { created: 20, completed: 21 } });
|
|
505
|
+
const { runtime, requests, service } = createRuntimeHarness({
|
|
506
|
+
goalOverrides: { createdAt: 6 },
|
|
507
|
+
messageFactory: (fetchCount) => fetchCount < 2 ? [firstLength] : [olderLength, firstLength, secondLength],
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
await runIdleTick(runtime);
|
|
511
|
+
await runIdleTick(runtime);
|
|
512
|
+
|
|
513
|
+
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
514
|
+
expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(1);
|
|
515
|
+
expect(lastPatchedGoal(requests)).toMatchObject({
|
|
516
|
+
status: 'blocked',
|
|
517
|
+
statusReason: 'repeated output truncation',
|
|
518
|
+
});
|
|
519
|
+
runtime.stop();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('does not count a previous truncated turn created at the goal boundary', async () => {
|
|
523
|
+
const firstLength = assistantMessage('before-boundary', { finish: 'length', time: { created: 10, completed: 11 } });
|
|
524
|
+
const secondLength = assistantMessage('after-boundary', { finish: 'length', time: { created: 20, completed: 21 } });
|
|
525
|
+
const { runtime, requests, service } = createRuntimeHarness({
|
|
526
|
+
goalOverrides: { createdAt: 10 },
|
|
527
|
+
messageFactory: (fetchCount) => fetchCount < 2 ? [firstLength] : [firstLength, secondLength],
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
await runIdleTick(runtime);
|
|
531
|
+
await runIdleTick(runtime);
|
|
532
|
+
|
|
533
|
+
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
534
|
+
expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(2);
|
|
535
|
+
expect(lastPatchedGoal(requests)).toMatchObject({ status: 'active' });
|
|
536
|
+
runtime.stop();
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('allows length recovery after an ordinary assistant turn breaks the streak', async () => {
|
|
540
|
+
const firstLength = assistantMessage('msg_length_before_normal', { finish: 'length' });
|
|
541
|
+
const normal = assistantMessage('msg_normal');
|
|
542
|
+
const secondLength = assistantMessage('msg_length_after_normal', { finish: 'length' });
|
|
543
|
+
const { runtime, requests, service } = createRuntimeHarness({
|
|
544
|
+
messageFactory: (fetchCount) => fetchCount < 2
|
|
545
|
+
? [firstLength]
|
|
546
|
+
: [firstLength, normal, secondLength],
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
await runIdleTick(runtime);
|
|
550
|
+
await runIdleTick(runtime);
|
|
551
|
+
|
|
552
|
+
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
553
|
+
expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(2);
|
|
554
|
+
expect(lastPatchedGoal(requests)).toMatchObject({ status: 'active' });
|
|
555
|
+
runtime.stop();
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('keeps MessageAbortedError pause behavior instead of continuing', async () => {
|
|
559
|
+
const { runtime, requests, service } = createRuntimeHarness({
|
|
560
|
+
messages: [assistantMessage('msg_aborted', { error: { name: 'MessageAbortedError' } })],
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
await runIdleTick(runtime);
|
|
564
|
+
|
|
565
|
+
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
566
|
+
expect(requests.some((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toBe(false);
|
|
567
|
+
expect(lastPatchedGoal(requests)).toMatchObject({
|
|
568
|
+
status: 'paused',
|
|
569
|
+
statusReason: 'paused after abort',
|
|
570
|
+
});
|
|
571
|
+
runtime.stop();
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('checks the token budget before allowing length recovery', async () => {
|
|
575
|
+
const { runtime, requests, service } = createRuntimeHarness({
|
|
576
|
+
goalOverrides: { tokenBudget: 5 },
|
|
577
|
+
messages: [assistantMessage('msg_budget_length', {
|
|
578
|
+
finish: 'length',
|
|
579
|
+
tokens: { input: 3, output: 3, cache: { read: 0 } },
|
|
580
|
+
})],
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
await runIdleTick(runtime);
|
|
584
|
+
|
|
585
|
+
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
586
|
+
expect(requests.some((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toBe(false);
|
|
587
|
+
expect(lastPatchedGoal(requests)).toMatchObject({ status: 'budgetLimited' });
|
|
588
|
+
runtime.stop();
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('checks the auto-continuation cap before allowing length recovery', async () => {
|
|
592
|
+
const { runtime, requests, service } = createRuntimeHarness({
|
|
593
|
+
maxAutoTurns: 1,
|
|
594
|
+
messages: [assistantMessage('msg_cap_length', { finish: 'length' })],
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
await runIdleTick(runtime);
|
|
598
|
+
|
|
599
|
+
expect(service.generateSmallModelText).not.toHaveBeenCalled();
|
|
600
|
+
expect(requests.some((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toBe(false);
|
|
601
|
+
expect(lastPatchedGoal(requests)).toMatchObject({
|
|
602
|
+
status: 'blocked',
|
|
603
|
+
statusReason: 'auto-continuation limit reached',
|
|
604
|
+
});
|
|
605
|
+
runtime.stop();
|
|
606
|
+
});
|
|
181
607
|
});
|
|
@@ -58,7 +58,7 @@ The following functions are internal helpers used by exported functions:
|
|
|
58
58
|
- `safeRm(dir)`: Safely remove directory recursively (ignores errors).
|
|
59
59
|
- `ensureDir(dirPath)`: Ensure directory exists with recursive creation.
|
|
60
60
|
- `copyDirectoryNoSymlinks(srcDir, dstDir)`: Copy directory contents without symlinks, with path traversal protection.
|
|
61
|
-
- `normalizeUserSkillDir(userSkillDir)`: Normalize user skill directory path (handles legacy `~/.config/opencode/skill`
|
|
61
|
+
- `normalizeUserSkillDir(userSkillDir)`: Normalize the user skill directory path (handles the legacy `skill` directory in the XDG config location, or `~/.config/opencode/skill` when XDG is unset, by selecting the plural `skills` directory when appropriate).
|
|
62
62
|
|
|
63
63
|
### Git Clone Helpers (`install.js`, `scan.js`)
|
|
64
64
|
- `cloneRepo({ cloneUrl, identity, tempDir })`: Clone git repository with preferred partial clone (`--filter=blob:none`) and fallback. Uses non-interactive mode.
|
|
@@ -4,13 +4,14 @@ import path from 'path';
|
|
|
4
4
|
|
|
5
5
|
import { assertGitAvailable, looksLikeAuthError, runGit } from './git.js';
|
|
6
6
|
import { parseSkillRepoSource } from './source.js';
|
|
7
|
+
import { OPENCODE_CONFIG_DIR } from '../opencode/shared.js';
|
|
7
8
|
|
|
8
9
|
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
|
9
10
|
|
|
10
11
|
function normalizeUserSkillDir(userSkillDir) {
|
|
11
12
|
if (!userSkillDir) return null;
|
|
12
|
-
const legacySkillDir = path.join(
|
|
13
|
-
const pluralSkillDir = path.join(
|
|
13
|
+
const legacySkillDir = path.join(OPENCODE_CONFIG_DIR, 'skill');
|
|
14
|
+
const pluralSkillDir = path.join(OPENCODE_CONFIG_DIR, 'skills');
|
|
14
15
|
if (userSkillDir === legacySkillDir) {
|
|
15
16
|
if (fs.existsSync(legacySkillDir) && !fs.existsSync(pluralSkillDir)) return legacySkillDir;
|
|
16
17
|
return pluralSkillDir;
|
|
@@ -91,6 +91,10 @@ other runtime API.
|
|
|
91
91
|
a blocker instead of a raw 500 message.
|
|
92
92
|
- `call.js` — wire formats and per-provider auth, replicating OpenCode's
|
|
93
93
|
plugin auth loaders:
|
|
94
|
+
- OpenCode-hosted providers receive `x-opencode-session`. Session-backed
|
|
95
|
+
features reuse the real OpenCode session id, walkthrough retries reuse the
|
|
96
|
+
walkthrough cache key, and standalone one-shot actions receive a fresh
|
|
97
|
+
opaque id for that generation.
|
|
94
98
|
- **GitHub Copilot**: fetches the requested model's authenticated `/models`
|
|
95
99
|
metadata from `https://api.githubcopilot.com` (or
|
|
96
100
|
`copilot-api.<enterprise>`) and honors its advertised endpoint, preferring
|
|
@@ -113,8 +117,9 @@ other runtime API.
|
|
|
113
117
|
- Everything else: OpenAI-compatible `/chat/completions` against the
|
|
114
118
|
provider's base URL, resolved from (1) `provider.<id>.options.baseURL`
|
|
115
119
|
in the OpenCode config, (2) the hardcoded `https://api.openai.com/v1`
|
|
116
|
-
endpoint, (3) the endpoint OpenCode resolved at runtime,
|
|
117
|
-
|
|
120
|
+
endpoint, (3) the selected model's endpoint OpenCode resolved at runtime,
|
|
121
|
+
(4) the provider-level runtime endpoint, or (5) the provider's `api` field
|
|
122
|
+
from the models.dev catalog. The credential follows
|
|
118
123
|
the same shape: config `options.apiKey`, then the runtime credential, then
|
|
119
124
|
the auth.json entry. `provider.<id>.options.headers` is sent with the
|
|
120
125
|
request and overrides the bearer default, so gateways that authenticate on
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import os from 'os';
|
|
3
3
|
import path from 'path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
4
5
|
import { readAuthFile, writeAuthFile } from '../opencode/auth.js';
|
|
5
6
|
import { readConfig, readConfigLayers, isPlainObject } from '../opencode/shared.js';
|
|
6
7
|
import { getCatalogProvider } from './catalog.js';
|
|
@@ -605,6 +606,8 @@ const readProviderConfig = (workingDirectory, providerID) => {
|
|
|
605
606
|
}
|
|
606
607
|
}
|
|
607
608
|
|
|
609
|
+
const getRuntimeModel = (runtimeProvider, modelID) => runtimeProvider?.models?.get(modelID) ?? null;
|
|
610
|
+
|
|
608
611
|
// ---------------------------------------------------------------------------
|
|
609
612
|
// Dispatch
|
|
610
613
|
// ---------------------------------------------------------------------------
|
|
@@ -645,10 +648,11 @@ export async function resolveProviderLogin({ auth, workingDirectory, providerID
|
|
|
645
648
|
|| null;
|
|
646
649
|
}
|
|
647
650
|
|
|
648
|
-
export async function callSmallModel({ auth, catalog, workingDirectory, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) {
|
|
651
|
+
export async function callSmallModel({ auth, catalog, workingDirectory, sessionID, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) {
|
|
649
652
|
const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS;
|
|
650
653
|
const providerConfig = readProviderConfig(workingDirectory, providerID);
|
|
651
654
|
const runtimeProvider = await getRuntimeProvider(providerID);
|
|
655
|
+
const runtimeModel = getRuntimeModel(runtimeProvider, modelID);
|
|
652
656
|
// Match OpenCode's resolveSDK precedence: config `provider.<id>.options`
|
|
653
657
|
// wins, then what OpenCode itself resolved at runtime (the only place a
|
|
654
658
|
// plugin's credential exists), and the auth.json entry last.
|
|
@@ -758,9 +762,10 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider
|
|
|
758
762
|
// base URL for that provider (openai itself included). When a custom provider
|
|
759
763
|
// is not in the catalog (e.g. a user-configured OpenAI-compatible proxy),
|
|
760
764
|
// fall back to its baseURL from the OpenCode provider config, then to the
|
|
761
|
-
// endpoint OpenCode resolved at runtime
|
|
762
|
-
//
|
|
763
|
-
//
|
|
765
|
+
// selected model's endpoint OpenCode resolved at runtime, then to the
|
|
766
|
+
// provider-level runtime endpoint. For a plugin provider, the runtime listing
|
|
767
|
+
// is the only place those endpoints exist, and several are local proxies the
|
|
768
|
+
// plugin itself runs. The openai provider also respects
|
|
764
769
|
// provider.openai.options.baseURL — OpenCode itself uses the same config for
|
|
765
770
|
// all providers including openai.
|
|
766
771
|
const provider = getCatalogProvider(catalog, providerID);
|
|
@@ -770,7 +775,8 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider
|
|
|
770
775
|
? providerConfigUrl
|
|
771
776
|
: providerID === 'openai'
|
|
772
777
|
? defaultOpenaiUrl
|
|
773
|
-
:
|
|
778
|
+
: runtimeModel?.api?.url
|
|
779
|
+
?? runtimeProvider?.baseURL
|
|
774
780
|
?? (typeof provider?.api === 'string' && provider.api
|
|
775
781
|
? provider.api
|
|
776
782
|
: null);
|
|
@@ -795,7 +801,12 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider
|
|
|
795
801
|
baseURL,
|
|
796
802
|
// Configured headers last: a gateway that authenticates on its own header
|
|
797
803
|
// must be able to override the bearer default rather than sit beside it.
|
|
798
|
-
headers: mergeHeadersCaseInsensitive(
|
|
804
|
+
headers: mergeHeadersCaseInsensitive(
|
|
805
|
+
mergeHeadersCaseInsensitive({ Authorization: `Bearer ${apiKey}` }, providerConfig?.headers),
|
|
806
|
+
providerID.startsWith('opencode')
|
|
807
|
+
? { 'x-opencode-session': typeof sessionID === 'string' && sessionID.trim() ? sessionID.trim() : randomUUID() }
|
|
808
|
+
: null,
|
|
809
|
+
),
|
|
799
810
|
modelID,
|
|
800
811
|
prompt,
|
|
801
812
|
system,
|
|
@@ -483,6 +483,29 @@ describe('callSmallModel — custom provider config', () => {
|
|
|
483
483
|
});
|
|
484
484
|
|
|
485
485
|
describe('catalog-based base URL (no config override)', () => {
|
|
486
|
+
it('identifies OpenCode Go requests with the owning conversation', async () => {
|
|
487
|
+
readConfig.mockReturnValue({});
|
|
488
|
+
fetchMock.mockResolvedValue(ok('ok'));
|
|
489
|
+
|
|
490
|
+
await callSmallModel({
|
|
491
|
+
auth: { 'opencode-go': { type: 'api', key: 'go-key' } },
|
|
492
|
+
catalog: {
|
|
493
|
+
'opencode-go': {
|
|
494
|
+
id: 'opencode-go',
|
|
495
|
+
api: 'https://opencode.ai/zen/go/v1',
|
|
496
|
+
models: { utility: { id: 'utility' } },
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
workingDirectory: '/proj',
|
|
500
|
+
sessionID: 'ses_conversation',
|
|
501
|
+
providerID: 'opencode-go',
|
|
502
|
+
modelID: 'utility',
|
|
503
|
+
prompt: 'hi',
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
expect(lastCall(fetchMock).init.headers['x-opencode-session']).toBe('ses_conversation');
|
|
507
|
+
});
|
|
508
|
+
|
|
486
509
|
it('uses the catalog api field when no config baseURL is set', async () => {
|
|
487
510
|
readConfig.mockReturnValue({});
|
|
488
511
|
fetchMock.mockResolvedValue(ok('ok'));
|
|
@@ -542,6 +565,35 @@ describe('callSmallModel — custom provider config', () => {
|
|
|
542
565
|
expect(init.headers.Authorization).toBe('Bearer plugin-key');
|
|
543
566
|
});
|
|
544
567
|
|
|
568
|
+
it('uses the selected runtime model endpoint', async () => {
|
|
569
|
+
readConfig.mockReturnValue({});
|
|
570
|
+
getRuntimeProvider.mockResolvedValue({
|
|
571
|
+
id: 'runtime-provider',
|
|
572
|
+
apiKey: 'plugin-key',
|
|
573
|
+
baseURL: 'https://runtime-provider/v1beta',
|
|
574
|
+
models: new Map([
|
|
575
|
+
['first-model', { api: { url: 'https://runtime-provider/v1beta', npm: '@ai-sdk/google' } }],
|
|
576
|
+
['selected-model', { api: { url: 'https://runtime-provider/v1', npm: '@ai-sdk/openai' } }],
|
|
577
|
+
]),
|
|
578
|
+
anonymousZen: false,
|
|
579
|
+
});
|
|
580
|
+
fetchMock.mockResolvedValue(ok('done'));
|
|
581
|
+
|
|
582
|
+
await callSmallModel({
|
|
583
|
+
auth: {},
|
|
584
|
+
catalog: {},
|
|
585
|
+
workingDirectory: '/proj',
|
|
586
|
+
providerID: 'runtime-provider',
|
|
587
|
+
modelID: 'selected-model',
|
|
588
|
+
prompt: 'hi',
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
const { url, init } = lastCall(fetchMock);
|
|
592
|
+
expect(url).toBe('https://runtime-provider/v1/chat/completions');
|
|
593
|
+
expect(url).not.toContain('/v1beta');
|
|
594
|
+
expect(JSON.parse(init.body).model).toBe('selected-model');
|
|
595
|
+
});
|
|
596
|
+
|
|
545
597
|
it('keeps the ChatGPT-plan login on its own transport instead of the runtime key', async () => {
|
|
546
598
|
readConfig.mockReturnValue({});
|
|
547
599
|
// OpenCode reports an OAuth access token as `options.apiKey` for openai;
|
|
@@ -6,6 +6,7 @@ import { readConfigLayers } from '../opencode/shared.js';
|
|
|
6
6
|
import { getModelCatalog } from './catalog.js';
|
|
7
7
|
import { resolveSmallModel, parseModelRef, isUsableAuthEntry, getAuthEntryForProvider } from './resolve.js';
|
|
8
8
|
import { DEDICATED_WIRE_FORMAT_PROVIDERS, callSmallModel, resolveProviderLogin } from './call.js';
|
|
9
|
+
import { readMergedSettingsSync } from '../opencode/settings-files.js';
|
|
9
10
|
import { getRuntimeProviderSnapshot } from './runtime-providers.js';
|
|
10
11
|
|
|
11
12
|
// Never a small model, whatever the transport looks like. A plugin can publish
|
|
@@ -26,16 +27,10 @@ const OPENCHAMBER_SETTINGS_FILE = path.join(
|
|
|
26
27
|
// OpenChamber's own settings: when the user unchecks "use default small model"
|
|
27
28
|
// their explicit override outranks every other resolution step.
|
|
28
29
|
const readSmallModelSettingsOverride = () => {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (settings.smallModelUseDefault !== false) return null;
|
|
34
|
-
const override = typeof settings.smallModelOverride === 'string' ? settings.smallModelOverride.trim() : '';
|
|
35
|
-
return override || null;
|
|
36
|
-
} catch {
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
30
|
+
const settings = readMergedSettingsSync({ fs, path, settingsFilePath: OPENCHAMBER_SETTINGS_FILE });
|
|
31
|
+
if (settings.smallModelUseDefault !== false) return null;
|
|
32
|
+
const override = typeof settings.smallModelOverride === 'string' ? settings.smallModelOverride.trim() : '';
|
|
33
|
+
return override || null;
|
|
39
34
|
};
|
|
40
35
|
|
|
41
36
|
// Rough safety clamp so a huge input never blows the model's context window.
|
|
@@ -102,7 +97,7 @@ const readConfiguredSmallModel = (workingDirectory) => {
|
|
|
102
97
|
* Generates text with the user's small model, resolved and authenticated
|
|
103
98
|
* entirely server-side from the OpenCode config and auth store.
|
|
104
99
|
*/
|
|
105
|
-
export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider = false, responseSchema, timeoutMs, signal, onOverflow = 'truncate' }) {
|
|
100
|
+
export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, sessionID, preferredProviderID, preferredModelID, restrictToPreferredProvider = false, responseSchema, timeoutMs, signal, onOverflow = 'truncate' }) {
|
|
106
101
|
if (typeof prompt !== 'string' || !prompt.trim()) {
|
|
107
102
|
throw Object.assign(new Error('prompt is required'), { statusCode: 400 });
|
|
108
103
|
}
|
|
@@ -169,6 +164,7 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens,
|
|
|
169
164
|
auth,
|
|
170
165
|
catalog,
|
|
171
166
|
workingDirectory: directory,
|
|
167
|
+
sessionID,
|
|
172
168
|
providerID: resolved.providerID,
|
|
173
169
|
modelID: resolved.modelID,
|
|
174
170
|
prompt: clamped.prompt,
|