@vantran-se/openchamber-web 1.22.1 → 1.22.2
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 +4 -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-CV1HNV5l.js} +1 -1
- package/dist/assets/{DiffView-Dh1ey2x9.js → DiffView-DkkSa30n.js} +3 -3
- package/dist/assets/DirtyBranchSwitchDialog-DW4WaXPc.js +1 -0
- package/dist/assets/FilesView-DDu8zDy5.js +17 -0
- package/dist/assets/GitView-1Ixo8z-M.js +6 -0
- package/dist/assets/{JsonTreeView-Bl0lp9Rs.js → JsonTreeView-DvCLL7dT.js} +1 -1
- package/dist/assets/LinearIssuesView-Bs1rZrF2.js +1 -0
- package/dist/assets/{MarkdownImageGallery-bA0PS-tn.js → MarkdownImageGallery-DyJUc6h9.js} +1 -1
- package/dist/assets/MarkdownRendererImpl-Bdm8vMkS.js +12 -0
- package/dist/assets/{OnboardingScreen-D3kYJIb6.js → OnboardingScreen-Dg66B6oB.js} +1 -1
- package/dist/assets/{PatchDiff-D6NXAfJk.js → PatchDiff-CAduNjfZ.js} +1 -1
- package/dist/assets/{PierreDiffViewer-Ct2A13NO.js → PierreDiffViewer-CTIb1GYD.js} +2 -2
- package/dist/assets/PlanView-trDaszfq.js +5 -0
- package/dist/assets/SettingsView-DStMfBmy.js +28 -0
- package/dist/assets/{SettingsWindow-BiWDBRvb.js → SettingsWindow-DQQFnrSZ.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BQexMQIm.js → ToolOutputDialog-SjY2XZlA.js} +6 -6
- package/dist/assets/ToolPartDiffPreview-DJdzUQPE.js +8 -0
- package/dist/assets/{VirtualizedFileDiff-Dpz2ra8h.js → VirtualizedFileDiff-Iehm4iMH.js} +1 -1
- package/dist/assets/{WalkthroughView-AX-u2pFg.js → WalkthroughView-BALp2d9O.js} +2 -2
- package/dist/assets/{computeVirtualFileMetrics-DtyozJdt.js → computeVirtualFileMetrics-BKZA43vN.js} +2 -2
- package/dist/assets/{contextPanelEmbeddedChat-C9EcRSDF.js → contextPanelEmbeddedChat-D2PM_pEv.js} +1 -1
- package/dist/assets/{de-DqVn5vme.js → de-DlSNLAcy.js} +6 -5
- package/dist/assets/{es-hj43Z53i.js → es-D1GbowZ3.js} +6 -5
- package/dist/assets/{fr-BmkZt2mF.js → fr-BPOLXIAY.js} +6 -5
- package/dist/assets/{index-DmhujXbT.js → index-Ce0LR32V.js} +1 -1
- package/dist/assets/index-D42fPvt4.css +1 -0
- package/dist/assets/index-DlFbCkEy.js +39 -0
- package/dist/assets/{isDiffPlainText-XyulMHxx.js → isDiffPlainText-DaB2u_Kv.js} +1 -1
- package/dist/assets/{isFilePlainText-B19Ddps1.js → isFilePlainText-yJZa2IAC.js} +1 -1
- package/dist/assets/{ja-DioMIEGF.js → ja-Dm2-m9io.js} +7 -6
- package/dist/assets/{ko-BqPgaQJb.js → ko-CIXlzQ5-.js} +7 -6
- package/dist/assets/main-B7qTsd_w.js +930 -0
- package/dist/assets/{main-B2a_JfKZ.js → main-CyQrD9dG.js} +2 -2
- package/dist/assets/{markdownCore-CWRCqSH2.js → markdownCore-CeEwGgEK.js} +2 -2
- package/dist/assets/{miniChat-B3CSU_Ps.js → miniChat-7cPEx4sV.js} +2 -2
- package/dist/assets/{mobile-QNcFIVCW.js → mobile-QtzkPTM8.js} +2 -2
- package/dist/assets/{patchFileDiff-BpMQZnYu.js → patchFileDiff-No55fDiD.js} +1 -1
- package/dist/assets/pl-B2zmkOpw.js +16 -0
- package/dist/assets/{pt-BR-BqH8fuCH.js → pt-BR-C-JE5H6y.js} +6 -5
- package/dist/assets/{renderElectronMiniChatApp-D77bmLqo.js → renderElectronMiniChatApp-ePTkBJNS.js} +2 -2
- package/dist/assets/renderMobileApp-D9RjB9DX.js +25 -0
- package/dist/assets/runtimeEndpointReset-DfJIWwwo.js +17 -0
- package/dist/assets/{scheduledTasksApi-BSRCtP6T.js → scheduledTasksApi-DuhdyYEa.js} +1 -1
- package/dist/assets/{shikiHighlight-DE-OwqGw.js → shikiHighlight-EHEg58-n.js} +1 -1
- package/dist/assets/tr-MJmRIzEY.js +7 -0
- package/dist/assets/{uk-BkkfxbOs.js → uk-BmD7eM8k.js} +6 -5
- package/dist/assets/{useAppFontEffects-COAeIX-D.js → useAppFontEffects-CuJPKtI1.js} +151 -180
- package/dist/assets/useInlineCommentController-DD7_7OP_.js +2 -0
- package/dist/assets/{useRootScrollLock-C0N9mCwz.js → useRootScrollLock-DRGAe8PN.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-BG3v1Ghq.js → vendor-codemirror-language-data-9ZzoH0o0.js} +2 -2
- package/dist/assets/vendor-zod-CK21ihK-.js +39 -0
- package/dist/assets/{web-components-BNMwa0qm.js → web-components-PttKBrhi.js} +1 -1
- package/dist/assets/{zh-CN-C6FMdVgv.js → zh-CN-JJHl4ehT.js} +6 -5
- package/dist/assets/{zh-TW-CdcMjDxv.js → zh-TW-DPc4DEEW.js} +6 -5
- package/dist/index.html +5 -5
- package/dist/mini-chat.html +4 -4
- package/dist/mobile.html +4 -4
- package/package.json +8 -4
- package/server/index.js +38 -5
- 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/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/fs/DOCUMENTATION.md +2 -0
- package/server/lib/fs/routes.js +32 -21
- package/server/lib/fs/routes.test.js +77 -0
- package/server/lib/git/DOCUMENTATION.md +3 -1
- package/server/lib/git/routes.js +16 -0
- package/server/lib/git/service.js +107 -17
- package/server/lib/git/service.test.js +171 -0
- 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 +158 -0
- package/server/lib/message-queue/runtime.js +870 -0
- package/server/lib/message-queue/runtime.test.js +482 -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 +13 -5
- 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 +5 -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.test.js +2 -2
- 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 +4 -4
- package/server/lib/opencode/server-startup-runtime.js +1 -1
- package/server/lib/opencode/settings-helpers.js +28 -0
- package/server/lib/opencode/settings-helpers.test.js +177 -0
- package/server/lib/opencode/settings-runtime.js +2 -2
- package/server/lib/opencode/settings-runtime.test.js +29 -0
- 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/quota/DOCUMENTATION.md +2 -1
- 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/providers/exe-dev.js +75 -0
- package/server/lib/quota/providers/exe-dev.test.js +47 -0
- package/server/lib/quota/providers/index.js +7 -0
- package/server/lib/quota/providers/opencode-go.js +1 -0
- package/server/lib/quota/providers/opencode-go.test.js +5 -1
- package/server/lib/quota/routes.js +2 -0
- package/server/lib/quota/utils/auth.js +1 -1
- package/server/lib/relay/DOCUMENTATION.md +1 -1
- package/server/lib/relay/host-client.test.js +9 -1
- package/server/lib/relay/tunnel-host.js +3 -1
- 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 +1 -0
- 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 +77 -11
- 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 +4 -0
- package/server/lib/small-model/call.js +8 -2
- package/server/lib/small-model/call.test.js +23 -0
- package/server/lib/small-model/index.js +2 -1
- package/server/lib/small-model/routes.js +2 -1
- package/server/lib/system-prompt/runtime.js +2 -21
- package/server/lib/terminal/DOCUMENTATION.md +11 -7
- package/server/lib/terminal/runtime.js +180 -26
- package/server/lib/terminal/runtime.test.js +553 -26
- 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/index.js +1 -0
- package/server/lib/walkthrough/routes.test.js +26 -5
- package/dist/assets/CommitSection-X-f2RgYr.js +0 -1
- 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/MarkdownRendererImpl-BGQi2PLq.js +0 -12
- 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/index-L_ncV-6H.css +0 -1
- package/dist/assets/index-qES19dlJ.js +0 -7
- package/dist/assets/main-CYgSu6dx.js +0 -928
- package/dist/assets/pl-slULBF4R.js +0 -15
- package/dist/assets/renderMobileApp-BQ3Kdm-d.js +0 -25
- package/dist/assets/runtimeEndpointReset-CRGn1gVj.js +0 -16
- package/dist/assets/tr-CjEbw1Aw.js +0 -6
- package/dist/assets/useInlineCommentController-JsBboQwt.js +0 -2
- package/dist/assets/vendor-zod-BLfBSgBh.js +0 -39
|
@@ -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
|
|
@@ -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';
|
|
@@ -645,7 +646,7 @@ export async function resolveProviderLogin({ auth, workingDirectory, providerID
|
|
|
645
646
|
|| null;
|
|
646
647
|
}
|
|
647
648
|
|
|
648
|
-
export async function callSmallModel({ auth, catalog, workingDirectory, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) {
|
|
649
|
+
export async function callSmallModel({ auth, catalog, workingDirectory, sessionID, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) {
|
|
649
650
|
const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS;
|
|
650
651
|
const providerConfig = readProviderConfig(workingDirectory, providerID);
|
|
651
652
|
const runtimeProvider = await getRuntimeProvider(providerID);
|
|
@@ -795,7 +796,12 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider
|
|
|
795
796
|
baseURL,
|
|
796
797
|
// Configured headers last: a gateway that authenticates on its own header
|
|
797
798
|
// must be able to override the bearer default rather than sit beside it.
|
|
798
|
-
headers: mergeHeadersCaseInsensitive(
|
|
799
|
+
headers: mergeHeadersCaseInsensitive(
|
|
800
|
+
mergeHeadersCaseInsensitive({ Authorization: `Bearer ${apiKey}` }, providerConfig?.headers),
|
|
801
|
+
providerID.startsWith('opencode')
|
|
802
|
+
? { 'x-opencode-session': typeof sessionID === 'string' && sessionID.trim() ? sessionID.trim() : randomUUID() }
|
|
803
|
+
: null,
|
|
804
|
+
),
|
|
799
805
|
modelID,
|
|
800
806
|
prompt,
|
|
801
807
|
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'));
|
|
@@ -102,7 +102,7 @@ const readConfiguredSmallModel = (workingDirectory) => {
|
|
|
102
102
|
* Generates text with the user's small model, resolved and authenticated
|
|
103
103
|
* entirely server-side from the OpenCode config and auth store.
|
|
104
104
|
*/
|
|
105
|
-
export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider = false, responseSchema, timeoutMs, signal, onOverflow = 'truncate' }) {
|
|
105
|
+
export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, sessionID, preferredProviderID, preferredModelID, restrictToPreferredProvider = false, responseSchema, timeoutMs, signal, onOverflow = 'truncate' }) {
|
|
106
106
|
if (typeof prompt !== 'string' || !prompt.trim()) {
|
|
107
107
|
throw Object.assign(new Error('prompt is required'), { statusCode: 400 });
|
|
108
108
|
}
|
|
@@ -169,6 +169,7 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens,
|
|
|
169
169
|
auth,
|
|
170
170
|
catalog,
|
|
171
171
|
workingDirectory: directory,
|
|
172
|
+
sessionID,
|
|
172
173
|
providerID: resolved.providerID,
|
|
173
174
|
modelID: resolved.modelID,
|
|
174
175
|
prompt: clamped.prompt,
|
|
@@ -21,13 +21,14 @@ export function registerSmallModelRoutes(app, { getSmallModelService }) {
|
|
|
21
21
|
app.post('/api/small-model/generate', async (req, res) => {
|
|
22
22
|
try {
|
|
23
23
|
const { generateSmallModelText } = await getSmallModelService();
|
|
24
|
-
const { prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider } = req.body || {};
|
|
24
|
+
const { prompt, system, maxOutputTokens, model, directory, sessionID, preferredProviderID, preferredModelID, restrictToPreferredProvider } = req.body || {};
|
|
25
25
|
const result = await generateSmallModelText({
|
|
26
26
|
prompt,
|
|
27
27
|
system,
|
|
28
28
|
maxOutputTokens,
|
|
29
29
|
model,
|
|
30
30
|
directory,
|
|
31
|
+
sessionID,
|
|
31
32
|
preferredProviderID,
|
|
32
33
|
preferredModelID,
|
|
33
34
|
restrictToPreferredProvider: restrictToPreferredProvider === true,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { parse as parseJsonc } from 'jsonc-parser';
|
|
2
1
|
import { pathToFileURL } from 'node:url';
|
|
2
|
+
import { appendManagedPlugin } from '../opencode/managed-plugin-config.js';
|
|
3
3
|
|
|
4
4
|
const PROVIDER_PROMPT_BOUNDARY = 'You are powered by the model named';
|
|
5
5
|
const MINIMAL_IDENTITY = 'You are OpenCode, a coding agent.';
|
|
@@ -33,25 +33,6 @@ export const OpenChamberSystemPromptPlugin = async () => ({
|
|
|
33
33
|
})
|
|
34
34
|
`;
|
|
35
35
|
|
|
36
|
-
const mergePluginConfig = (rawConfig, pluginUrl) => {
|
|
37
|
-
const errors = [];
|
|
38
|
-
const parsed = typeof rawConfig === 'string' && rawConfig.trim()
|
|
39
|
-
? parseJsonc(rawConfig, errors, { allowTrailingComma: true })
|
|
40
|
-
: {};
|
|
41
|
-
if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
42
|
-
throw new Error('OPENCODE_CONFIG_CONTENT must contain a valid JSON object before OpenChamber can inject its system prompt optimizer');
|
|
43
|
-
}
|
|
44
|
-
if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) {
|
|
45
|
-
throw new Error('OPENCODE_CONFIG_CONTENT plugin must be an array before OpenChamber can inject its system prompt optimizer');
|
|
46
|
-
}
|
|
47
|
-
const configured = Array.isArray(parsed.plugin) ? parsed.plugin : [];
|
|
48
|
-
parsed.plugin = [
|
|
49
|
-
...configured.filter((value) => value !== pluginUrl && (!Array.isArray(value) || value[0] !== pluginUrl)),
|
|
50
|
-
pluginUrl,
|
|
51
|
-
];
|
|
52
|
-
return JSON.stringify(parsed);
|
|
53
|
-
};
|
|
54
|
-
|
|
55
36
|
export const createSystemPromptRuntime = ({ fsPromises, path, dataDir }) => {
|
|
56
37
|
const pluginDirectory = path.join(dataDir, 'system-prompt');
|
|
57
38
|
const pluginPath = path.join(pluginDirectory, 'openchamber-system-prompt-plugin.js');
|
|
@@ -60,7 +41,7 @@ export const createSystemPromptRuntime = ({ fsPromises, path, dataDir }) => {
|
|
|
60
41
|
await fsPromises.mkdir(pluginDirectory, { recursive: true });
|
|
61
42
|
await fsPromises.writeFile(pluginPath, createPluginSource(), { mode: 0o600 });
|
|
62
43
|
return {
|
|
63
|
-
OPENCODE_CONFIG_CONTENT:
|
|
44
|
+
OPENCODE_CONFIG_CONTENT: appendManagedPlugin(rawConfig, pathToFileURL(pluginPath).href, 'system prompt optimizer'),
|
|
64
45
|
};
|
|
65
46
|
};
|
|
66
47
|
|