@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
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { createMessageQueueRuntime, parseQueuedItemInput } from './runtime.js';
|
|
6
|
+
|
|
7
|
+
const SESSION = 'ses_queue_test_1';
|
|
8
|
+
const DIRECTORY = '/repo';
|
|
9
|
+
|
|
10
|
+
const item = (overrides = {}) => ({
|
|
11
|
+
content: 'follow up',
|
|
12
|
+
text: 'follow up',
|
|
13
|
+
attachments: [],
|
|
14
|
+
sendConfig: { providerID: 'anthropic', modelID: 'claude', agent: 'build' },
|
|
15
|
+
...overrides,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const tempDirs = [];
|
|
19
|
+
const makeDataDir = () => {
|
|
20
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-message-queue-'));
|
|
21
|
+
tempDirs.push(dir);
|
|
22
|
+
return dir;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.useRealTimers();
|
|
27
|
+
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A fake OpenCode: status map, message tail, command list, and a log of every
|
|
32
|
+
* prompt/command it received.
|
|
33
|
+
*/
|
|
34
|
+
const createOpenCode = () => {
|
|
35
|
+
const state = {
|
|
36
|
+
statuses: {},
|
|
37
|
+
tail: [],
|
|
38
|
+
commands: [],
|
|
39
|
+
sent: [],
|
|
40
|
+
failNext: null,
|
|
41
|
+
};
|
|
42
|
+
const fetchImpl = vi.fn(async (url, init = {}) => {
|
|
43
|
+
const { pathname } = new URL(url);
|
|
44
|
+
const method = init.method ?? 'GET';
|
|
45
|
+
if (state.failNext && state.failNext.test(pathname)) {
|
|
46
|
+
state.failNext = null;
|
|
47
|
+
return new Response('boom', { status: 500 });
|
|
48
|
+
}
|
|
49
|
+
if (pathname === '/session/status') return Response.json(state.statuses);
|
|
50
|
+
if (pathname.endsWith('/message')) return Response.json(state.tail);
|
|
51
|
+
if (pathname === '/command') return Response.json(state.commands);
|
|
52
|
+
if (method === 'POST' && (pathname.endsWith('/prompt_async') || pathname.endsWith('/command'))) {
|
|
53
|
+
state.sent.push({ path: pathname, body: JSON.parse(init.body) });
|
|
54
|
+
return new Response(null, { status: 204 });
|
|
55
|
+
}
|
|
56
|
+
return new Response('not found', { status: 404 });
|
|
57
|
+
});
|
|
58
|
+
return { state, fetchImpl };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const createRuntime = ({ dataDir = makeDataDir(), openCode = createOpenCode(), knowledge = null, retryDelayMs } = {}) => {
|
|
62
|
+
let eventHandler = () => {};
|
|
63
|
+
let statusHandler = () => {};
|
|
64
|
+
const broadcasts = [];
|
|
65
|
+
const promptSent = [];
|
|
66
|
+
const options = {
|
|
67
|
+
globalEventHub: {
|
|
68
|
+
subscribeEvent(handler) { eventHandler = handler; return () => {}; },
|
|
69
|
+
subscribeStatus(handler) { statusHandler = handler; return () => {}; },
|
|
70
|
+
},
|
|
71
|
+
buildOpenCodeUrl: (fetchPath) => `http://opencode.test${fetchPath}`,
|
|
72
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
73
|
+
sessionKnowledgeRuntime: knowledge,
|
|
74
|
+
broadcastGlobalUiEvent: (event) => broadcasts.push(event),
|
|
75
|
+
onPromptSent: (sessionId) => promptSent.push(sessionId),
|
|
76
|
+
dataDir,
|
|
77
|
+
fetchImpl: openCode.fetchImpl,
|
|
78
|
+
dispatchQuietMs: 0,
|
|
79
|
+
abortHoldMs: 50,
|
|
80
|
+
};
|
|
81
|
+
if (retryDelayMs) options.retryDelayMs = retryDelayMs;
|
|
82
|
+
const runtime = createMessageQueueRuntime(options);
|
|
83
|
+
return {
|
|
84
|
+
runtime,
|
|
85
|
+
openCode,
|
|
86
|
+
dataDir,
|
|
87
|
+
broadcasts,
|
|
88
|
+
promptSent,
|
|
89
|
+
emit: (payload, directory = DIRECTORY) => eventHandler({ payload, directory }),
|
|
90
|
+
connect: () => statusHandler({ type: 'connect' }),
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const settle = async (ms = 30) => {
|
|
95
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
describe('parseQueuedItemInput', () => {
|
|
99
|
+
it('rejects an item the server could not deliver later', () => {
|
|
100
|
+
expect(() => parseQueuedItemInput({ content: 'x' })).toThrow(TypeError);
|
|
101
|
+
expect(() => parseQueuedItemInput(item({ content: '', text: '' }))).toThrow(TypeError);
|
|
102
|
+
expect(() => parseQueuedItemInput(item({ attachments: [{ filename: 'a.png' }] }))).toThrow(TypeError);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('keeps delivery fields and trims blank edges of the content', () => {
|
|
106
|
+
const parsed = parseQueuedItemInput(item({ content: '\n\nhello\n', text: 'hello', agentMention: 'reviewer' }));
|
|
107
|
+
expect(parsed).toEqual({
|
|
108
|
+
content: 'hello',
|
|
109
|
+
text: 'hello',
|
|
110
|
+
agentMention: 'reviewer',
|
|
111
|
+
attachments: [],
|
|
112
|
+
context: [],
|
|
113
|
+
sendConfig: { providerID: 'anthropic', modelID: 'claude', agent: 'build' },
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('keeps captured context and rejects a malformed part', () => {
|
|
118
|
+
const context = [
|
|
119
|
+
{ kind: 'context', text: 'Comment on `a.ts`', metadata: { openchamberContext: { kind: 'code-comment' } }, instructions: '' },
|
|
120
|
+
{ kind: 'instruction', text: 'use the skill' },
|
|
121
|
+
{ kind: 'synthetic', text: 'conflict payload' },
|
|
122
|
+
];
|
|
123
|
+
expect(parseQueuedItemInput(item({ context })).context).toEqual([
|
|
124
|
+
{ kind: 'context', text: 'Comment on `a.ts`', metadata: { openchamberContext: { kind: 'code-comment' } } },
|
|
125
|
+
{ kind: 'instruction', text: 'use the skill' },
|
|
126
|
+
{ kind: 'synthetic', text: 'conflict payload' },
|
|
127
|
+
]);
|
|
128
|
+
expect(() => parseQueuedItemInput(item({ context: [{ kind: 'context', text: 'no metadata' }] }))).toThrow(TypeError);
|
|
129
|
+
expect(() => parseQueuedItemInput(item({ context: [{ kind: 'other', text: 'x' }] }))).toThrow(TypeError);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('accepts an item that is only context', () => {
|
|
133
|
+
const parsed = parseQueuedItemInput(item({ content: '', text: '', context: [{ kind: 'synthetic', text: 'just context' }] }));
|
|
134
|
+
expect(parsed.text).toBe('');
|
|
135
|
+
expect(parsed.context).toHaveLength(1);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('message queue runtime', () => {
|
|
140
|
+
it('delivers the head of the queue when the session goes idle, in order', async () => {
|
|
141
|
+
const { runtime, openCode, emit, promptSent, broadcasts } = createRuntime();
|
|
142
|
+
runtime.start();
|
|
143
|
+
openCode.state.statuses = { [SESSION]: { type: 'busy' } };
|
|
144
|
+
|
|
145
|
+
await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'first', text: 'first' }));
|
|
146
|
+
await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'second', text: 'second' }));
|
|
147
|
+
await settle();
|
|
148
|
+
expect(openCode.state.sent).toHaveLength(0);
|
|
149
|
+
|
|
150
|
+
openCode.state.statuses = {};
|
|
151
|
+
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
152
|
+
await settle();
|
|
153
|
+
|
|
154
|
+
expect(openCode.state.sent).toHaveLength(1);
|
|
155
|
+
expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/prompt_async`);
|
|
156
|
+
expect(openCode.state.sent[0].body).toEqual({
|
|
157
|
+
model: { providerID: 'anthropic', modelID: 'claude' },
|
|
158
|
+
agent: 'build',
|
|
159
|
+
parts: [{ type: 'text', text: 'first' }],
|
|
160
|
+
});
|
|
161
|
+
expect(promptSent).toEqual([SESSION]);
|
|
162
|
+
expect(runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['second']);
|
|
163
|
+
// Clients learned about the in-flight item and then the removal.
|
|
164
|
+
expect(broadcasts.at(-1)).toMatchObject({
|
|
165
|
+
type: 'openchamber:message-queue.updated',
|
|
166
|
+
properties: { session: { sessionId: SESSION, sendingId: null } },
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// The next turn: busy, then idle again — the second message goes out.
|
|
170
|
+
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'busy' } } });
|
|
171
|
+
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
172
|
+
await settle();
|
|
173
|
+
expect(openCode.state.sent).toHaveLength(2);
|
|
174
|
+
expect(runtime.sessionSnapshot(SESSION).items).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('does not send into a running turn even when the status event says idle', async () => {
|
|
178
|
+
const { runtime, openCode, emit } = createRuntime();
|
|
179
|
+
runtime.start();
|
|
180
|
+
openCode.state.tail = [{ info: { role: 'assistant', time: { created: 1 } } }];
|
|
181
|
+
await runtime.enqueue(SESSION, DIRECTORY, item());
|
|
182
|
+
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
183
|
+
await settle();
|
|
184
|
+
expect(openCode.state.sent).toHaveLength(0);
|
|
185
|
+
|
|
186
|
+
// The reply completes: that alone drains the queue (a missed idle event
|
|
187
|
+
// must not strand it).
|
|
188
|
+
openCode.state.tail = [{ info: { role: 'assistant', time: { created: 1, completed: 2 } } }];
|
|
189
|
+
emit({ type: 'message.updated', properties: { info: { role: 'assistant', sessionID: SESSION, time: { created: 1, completed: 2 } } } });
|
|
190
|
+
await settle();
|
|
191
|
+
expect(openCode.state.sent).toHaveLength(1);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('treats an unreachable OpenCode as unknown, not idle', async () => {
|
|
195
|
+
const { runtime, openCode, emit } = createRuntime({ retryDelayMs: () => 10 });
|
|
196
|
+
runtime.start();
|
|
197
|
+
await runtime.enqueue(SESSION, DIRECTORY, item());
|
|
198
|
+
openCode.state.failNext = /\/session\/status$/;
|
|
199
|
+
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
200
|
+
await settle(5);
|
|
201
|
+
expect(openCode.state.sent).toHaveLength(0);
|
|
202
|
+
// Retried after the status fetch recovers.
|
|
203
|
+
await settle(40);
|
|
204
|
+
expect(openCode.state.sent).toHaveLength(1);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('keeps a failed item and retries with backoff', async () => {
|
|
208
|
+
const { runtime, openCode, emit, broadcasts } = createRuntime({ retryDelayMs: () => 20 });
|
|
209
|
+
runtime.start();
|
|
210
|
+
await runtime.enqueue(SESSION, DIRECTORY, item());
|
|
211
|
+
openCode.state.failNext = /prompt_async$/;
|
|
212
|
+
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
213
|
+
await settle(10);
|
|
214
|
+
expect(openCode.state.sent).toHaveLength(0);
|
|
215
|
+
expect(runtime.sessionSnapshot(SESSION).items).toHaveLength(1);
|
|
216
|
+
expect(runtime.sessionSnapshot(SESSION).sendingId).toBeNull();
|
|
217
|
+
expect(broadcasts.at(-1).properties.session.sendingId).toBeNull();
|
|
218
|
+
await settle(40);
|
|
219
|
+
expect(openCode.state.sent).toHaveLength(1);
|
|
220
|
+
expect(runtime.sessionSnapshot(SESSION).items).toHaveLength(0);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('holds delivery briefly after a user abort', async () => {
|
|
224
|
+
const { runtime, openCode, emit } = createRuntime();
|
|
225
|
+
runtime.start();
|
|
226
|
+
await runtime.enqueue(SESSION, DIRECTORY, item());
|
|
227
|
+
emit({ type: 'message.updated', properties: { info: { role: 'assistant', sessionID: SESSION, error: { name: 'MessageAbortedError' } } } });
|
|
228
|
+
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
229
|
+
await settle(10);
|
|
230
|
+
expect(openCode.state.sent).toHaveLength(0);
|
|
231
|
+
await settle(80);
|
|
232
|
+
expect(openCode.state.sent).toHaveLength(1);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('honors a hold until it is released', async () => {
|
|
236
|
+
const { runtime, openCode, emit } = createRuntime();
|
|
237
|
+
runtime.start();
|
|
238
|
+
await runtime.enqueue(SESSION, DIRECTORY, item());
|
|
239
|
+
runtime.setHold(SESSION, true, 60_000);
|
|
240
|
+
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
241
|
+
await settle();
|
|
242
|
+
expect(openCode.state.sent).toHaveLength(0);
|
|
243
|
+
|
|
244
|
+
runtime.setHold(SESSION, false);
|
|
245
|
+
await settle();
|
|
246
|
+
expect(openCode.state.sent).toHaveLength(1);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('survives a restart and delivers once OpenCode reconnects', async () => {
|
|
250
|
+
const dataDir = makeDataDir();
|
|
251
|
+
const first = createRuntime({ dataDir });
|
|
252
|
+
first.runtime.start();
|
|
253
|
+
first.openCode.state.statuses = { [SESSION]: { type: 'busy' } };
|
|
254
|
+
await first.runtime.enqueue(SESSION, DIRECTORY, item({ content: 'persisted', text: 'persisted' }));
|
|
255
|
+
await first.runtime.flush();
|
|
256
|
+
first.runtime.stop();
|
|
257
|
+
|
|
258
|
+
const second = createRuntime({ dataDir });
|
|
259
|
+
second.runtime.start();
|
|
260
|
+
await second.runtime.load();
|
|
261
|
+
expect(second.runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['persisted']);
|
|
262
|
+
second.connect();
|
|
263
|
+
await settle();
|
|
264
|
+
expect(second.openCode.state.sent).toHaveLength(1);
|
|
265
|
+
expect(second.openCode.state.sent[0].body.parts).toEqual([{ type: 'text', text: 'persisted' }]);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('moves an unreadable queue file aside instead of treating it as empty', async () => {
|
|
269
|
+
const dataDir = makeDataDir();
|
|
270
|
+
fs.writeFileSync(path.join(dataDir, 'message-queue.json'), '{ not json');
|
|
271
|
+
const { runtime } = createRuntime({ dataDir });
|
|
272
|
+
await runtime.load();
|
|
273
|
+
expect(runtime.snapshot().sessions).toEqual([]);
|
|
274
|
+
expect(fs.readdirSync(dataDir).some((name) => name.startsWith('message-queue.json.corrupt-'))).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('refuses to remove or take the item currently being sent', async () => {
|
|
278
|
+
const { runtime, openCode, emit } = createRuntime();
|
|
279
|
+
runtime.start();
|
|
280
|
+
let release;
|
|
281
|
+
// status map, message tail, then the prompt itself (held open until released)
|
|
282
|
+
openCode.fetchImpl.mockImplementationOnce(async () => Response.json({}))
|
|
283
|
+
.mockImplementationOnce(async () => Response.json([]))
|
|
284
|
+
.mockImplementationOnce(() => new Promise((resolve) => { release = () => resolve(new Response(null, { status: 204 })); }));
|
|
285
|
+
const { itemId } = await runtime.enqueue(SESSION, DIRECTORY, item());
|
|
286
|
+
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
287
|
+
await settle();
|
|
288
|
+
expect(runtime.sessionSnapshot(SESSION).sendingId).toBe(itemId);
|
|
289
|
+
|
|
290
|
+
await expect(runtime.remove(SESSION, itemId)).rejects.toMatchObject({ status: 409 });
|
|
291
|
+
await expect(runtime.take(SESSION, itemId)).rejects.toMatchObject({ status: 409 });
|
|
292
|
+
const taken = await runtime.takeAll(SESSION);
|
|
293
|
+
expect(taken.items).toEqual([]);
|
|
294
|
+
expect(runtime.sessionSnapshot(SESSION).items).toHaveLength(1);
|
|
295
|
+
|
|
296
|
+
release();
|
|
297
|
+
await settle();
|
|
298
|
+
expect(runtime.sessionSnapshot(SESSION).items).toHaveLength(0);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('take hands back the full payload and leaves the rest queued', async () => {
|
|
302
|
+
const { runtime } = createRuntime();
|
|
303
|
+
runtime.start();
|
|
304
|
+
const attachment = { id: 'a1', filename: 'shot.png', mimeType: 'image/png', size: 3, source: 'local', dataUrl: 'data:image/png;base64,AAA=' };
|
|
305
|
+
const first = await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'with image', attachments: [attachment] }));
|
|
306
|
+
await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'plain' }));
|
|
307
|
+
|
|
308
|
+
expect(runtime.sessionSnapshot(SESSION).items[0].attachments[0]).not.toHaveProperty('dataUrl');
|
|
309
|
+
const taken = await runtime.take(SESSION, first.itemId);
|
|
310
|
+
expect(taken.item.attachments[0].dataUrl).toBe(attachment.dataUrl);
|
|
311
|
+
expect(runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['plain']);
|
|
312
|
+
|
|
313
|
+
const all = await runtime.takeAll(SESSION);
|
|
314
|
+
expect(all.items.map((entry) => entry.content)).toEqual(['plain']);
|
|
315
|
+
expect(runtime.snapshot().sessions).toEqual([]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('names the directory in the broadcast that empties a queue', async () => {
|
|
319
|
+
// The UI keys its projection by directory; without it the client cannot
|
|
320
|
+
// tell which queue just delivered its last message and keeps showing it.
|
|
321
|
+
const { runtime, emit, broadcasts, openCode } = createRuntime();
|
|
322
|
+
runtime.start();
|
|
323
|
+
await runtime.enqueue(SESSION, DIRECTORY, item());
|
|
324
|
+
openCode.state.statuses = {};
|
|
325
|
+
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
326
|
+
await settle();
|
|
327
|
+
|
|
328
|
+
expect(openCode.state.sent).toHaveLength(1);
|
|
329
|
+
expect(runtime.snapshot().sessions).toEqual([]);
|
|
330
|
+
expect(broadcasts.at(-1).properties.session).toEqual({ sessionId: SESSION, directory: DIRECTORY, items: [], sendingId: null });
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('reorders only with a complete permutation', async () => {
|
|
334
|
+
const { runtime } = createRuntime();
|
|
335
|
+
runtime.start();
|
|
336
|
+
const a = await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'a' }));
|
|
337
|
+
const b = await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'b' }));
|
|
338
|
+
await expect(runtime.reorder(SESSION, [b.itemId])).rejects.toThrow(TypeError);
|
|
339
|
+
await runtime.reorder(SESSION, [b.itemId, a.itemId]);
|
|
340
|
+
expect(runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['b', 'a']);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('drops the queue of a deleted session', async () => {
|
|
344
|
+
const { runtime, emit, broadcasts } = createRuntime();
|
|
345
|
+
runtime.start();
|
|
346
|
+
await runtime.enqueue(SESSION, DIRECTORY, item());
|
|
347
|
+
emit({ type: 'session.deleted', properties: { info: { id: SESSION } } });
|
|
348
|
+
expect(runtime.snapshot().sessions).toEqual([]);
|
|
349
|
+
expect(broadcasts.at(-1).properties.session).toMatchObject({ sessionId: SESSION, items: [] });
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('dispatches a queued slash command through the command endpoint', async () => {
|
|
353
|
+
const { runtime, openCode, emit } = createRuntime();
|
|
354
|
+
runtime.start();
|
|
355
|
+
openCode.state.commands = [{ name: 'review' }];
|
|
356
|
+
await runtime.enqueue(SESSION, DIRECTORY, item({ content: '/review src', text: '/review src', sendConfig: { providerID: 'p', modelID: 'm', agent: 'build', variant: 'max' } }));
|
|
357
|
+
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
358
|
+
await settle();
|
|
359
|
+
expect(openCode.state.sent).toHaveLength(1);
|
|
360
|
+
expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/command`);
|
|
361
|
+
expect(openCode.state.sent[0].body).toEqual({ command: 'review', arguments: 'src', model: 'p/m', agent: 'build', variant: 'max' });
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('delivers captured context as synthetic parts, instructions first, before project knowledge', async () => {
|
|
365
|
+
const knowledge = {
|
|
366
|
+
resolvePendingForSession: async () => ({ text: 'pinned notes', signature: 'sig-1' }),
|
|
367
|
+
recordDelivered: async () => {},
|
|
368
|
+
};
|
|
369
|
+
const { runtime, openCode, emit } = createRuntime({ knowledge });
|
|
370
|
+
runtime.start();
|
|
371
|
+
const metadata = { openchamberContext: { kind: 'github-pr', number: 7, title: 'PR', url: 'https://x/pr/7' } };
|
|
372
|
+
await runtime.enqueue(SESSION, DIRECTORY, item({
|
|
373
|
+
agentMention: 'reviewer',
|
|
374
|
+
attachments: [{ id: 'a', filename: 'f.txt', mimeType: 'text/plain', size: 1, source: 'local', dataUrl: 'data:text/plain,hi' }],
|
|
375
|
+
context: [
|
|
376
|
+
{ kind: 'context', text: 'the diff', metadata, instructions: 'how to read it' },
|
|
377
|
+
{ kind: 'synthetic', text: 'conflict payload' },
|
|
378
|
+
{ kind: 'instruction', text: 'use the skill' },
|
|
379
|
+
],
|
|
380
|
+
}));
|
|
381
|
+
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
382
|
+
await settle();
|
|
383
|
+
expect(openCode.state.sent[0].body.parts).toEqual([
|
|
384
|
+
{ type: 'text', text: 'follow up' },
|
|
385
|
+
{ type: 'file', mime: 'text/plain', filename: 'f.txt', url: 'data:text/plain,hi' },
|
|
386
|
+
{ type: 'text', text: 'how to read it', synthetic: true },
|
|
387
|
+
{ type: 'text', text: 'the diff', synthetic: true, metadata },
|
|
388
|
+
{ type: 'text', text: 'conflict payload', synthetic: true },
|
|
389
|
+
{ type: 'text', text: 'use the skill', synthetic: true },
|
|
390
|
+
{ type: 'text', text: 'pinned notes', synthetic: true },
|
|
391
|
+
{ type: 'agent', name: 'reviewer' },
|
|
392
|
+
]);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('keeps files on the command route, which is all that route accepts', async () => {
|
|
396
|
+
const { runtime, openCode, emit } = createRuntime();
|
|
397
|
+
runtime.start();
|
|
398
|
+
openCode.state.commands = [{ name: 'review' }];
|
|
399
|
+
await runtime.enqueue(SESSION, DIRECTORY, item({
|
|
400
|
+
content: '/review',
|
|
401
|
+
text: '/review',
|
|
402
|
+
attachments: [{ id: 'a', filename: 'f.txt', mimeType: 'text/plain', size: 1, source: 'local', dataUrl: 'data:text/plain,hi' }],
|
|
403
|
+
}));
|
|
404
|
+
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
405
|
+
await settle();
|
|
406
|
+
expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/command`);
|
|
407
|
+
expect(openCode.state.sent[0].body.parts).toEqual([{ type: 'file', mime: 'text/plain', filename: 'f.txt', url: 'data:text/plain,hi' }]);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('sends a command queued with context as its expanded prompt, context included', async () => {
|
|
411
|
+
// The command route rejects text parts, so a command with captured
|
|
412
|
+
// context takes the prompt route with the template expanded, exactly as
|
|
413
|
+
// the composer does.
|
|
414
|
+
const { runtime, openCode, emit } = createRuntime();
|
|
415
|
+
runtime.start();
|
|
416
|
+
openCode.state.commands = [{ name: 'review', source: 'command', template: 'Review $1 with focus on $2' }];
|
|
417
|
+
const metadata = { openchamberContext: { kind: 'chat-quote', quote: 'q', text: 'why?' } };
|
|
418
|
+
await runtime.enqueue(SESSION, DIRECTORY, item({
|
|
419
|
+
content: '/review src "error handling"',
|
|
420
|
+
text: '/review src "error handling"',
|
|
421
|
+
context: [{ kind: 'context', text: 'quoted', metadata }],
|
|
422
|
+
}));
|
|
423
|
+
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
424
|
+
await settle();
|
|
425
|
+
expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/prompt_async`);
|
|
426
|
+
expect(openCode.state.sent[0].body.parts).toEqual([
|
|
427
|
+
{ type: 'text', text: 'Review src with focus on error handling' },
|
|
428
|
+
{ type: 'text', text: 'quoted', synthetic: true, metadata },
|
|
429
|
+
]);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('sends a skill queued with context as an explicit invocation, context included', async () => {
|
|
433
|
+
const { runtime, openCode, emit } = createRuntime();
|
|
434
|
+
runtime.start();
|
|
435
|
+
openCode.state.commands = [{ name: 'grill', source: 'skill', template: 'skill body' }];
|
|
436
|
+
await runtime.enqueue(SESSION, DIRECTORY, item({
|
|
437
|
+
content: '/grill auth',
|
|
438
|
+
text: '/grill auth',
|
|
439
|
+
context: [{ kind: 'synthetic', text: 'focus on tests' }],
|
|
440
|
+
}));
|
|
441
|
+
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
442
|
+
await settle();
|
|
443
|
+
expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/prompt_async`);
|
|
444
|
+
expect(openCode.state.sent[0].body.parts).toEqual([
|
|
445
|
+
{ type: 'text', text: '/grill auth' },
|
|
446
|
+
{ type: 'text', text: 'focus on tests', synthetic: true },
|
|
447
|
+
{ type: 'text', text: 'The user explicitly invoked the grill skill. Use the corresponding skill tool to handle this request.', synthetic: true },
|
|
448
|
+
]);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('keeps captured context out of snapshots and broadcasts, and hands it back on take', async () => {
|
|
452
|
+
const { runtime, broadcasts } = createRuntime();
|
|
453
|
+
runtime.start();
|
|
454
|
+
const context = [{ kind: 'synthetic', text: 'a large diff' }];
|
|
455
|
+
const { itemId } = await runtime.enqueue(SESSION, DIRECTORY, item({ context }));
|
|
456
|
+
expect(runtime.sessionSnapshot(SESSION).items[0]).not.toHaveProperty('context');
|
|
457
|
+
expect(runtime.sessionSnapshot(SESSION).items[0].text).toBe('follow up');
|
|
458
|
+
expect(broadcasts.at(-1).properties.session.items[0]).not.toHaveProperty('context');
|
|
459
|
+
const taken = await runtime.take(SESSION, itemId);
|
|
460
|
+
expect(taken.item.context).toEqual(context);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('attaches pending project knowledge and records its delivery', async () => {
|
|
464
|
+
const recorded = [];
|
|
465
|
+
const knowledge = {
|
|
466
|
+
resolvePendingForSession: async () => ({ text: 'pinned notes', signature: 'sig-1' }),
|
|
467
|
+
recordDelivered: async (sessionId, directory, signature) => { recorded.push({ sessionId, directory, signature }); },
|
|
468
|
+
};
|
|
469
|
+
const { runtime, openCode, emit } = createRuntime({ knowledge });
|
|
470
|
+
runtime.start();
|
|
471
|
+
await runtime.enqueue(SESSION, DIRECTORY, item({ agentMention: 'reviewer', attachments: [{ id: 'a', filename: 'f.txt', mimeType: 'text/plain', size: 1, source: 'local', dataUrl: 'data:text/plain,hi' }] }));
|
|
472
|
+
emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
|
|
473
|
+
await settle();
|
|
474
|
+
expect(openCode.state.sent[0].body.parts).toEqual([
|
|
475
|
+
{ type: 'text', text: 'follow up' },
|
|
476
|
+
{ type: 'file', mime: 'text/plain', filename: 'f.txt', url: 'data:text/plain,hi' },
|
|
477
|
+
{ type: 'text', text: 'pinned notes', synthetic: true },
|
|
478
|
+
{ type: 'agent', name: 'reviewer' },
|
|
479
|
+
]);
|
|
480
|
+
expect(recorded).toEqual([{ sessionId: SESSION, directory: DIRECTORY, signature: 'sig-1' }]);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
@@ -253,6 +253,40 @@ const latestCompletedAssistantMessageID = async ({ client, sessionID, directory
|
|
|
253
253
|
return asNonEmptyString(latest?.id);
|
|
254
254
|
};
|
|
255
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Upper bound on one archive batch.
|
|
258
|
+
*
|
|
259
|
+
* The batch is applied one session at a time against OpenCode, so an unbounded
|
|
260
|
+
* list would hold a request open for as long as the list is large. Callers with
|
|
261
|
+
* more sessions than this send several batches and keep their own partial
|
|
262
|
+
* results.
|
|
263
|
+
*/
|
|
264
|
+
const MAX_ARCHIVE_BATCH = 500;
|
|
265
|
+
|
|
266
|
+
const parseArchiveRequest = (payload) => {
|
|
267
|
+
const rawIds = payload?.ids;
|
|
268
|
+
if (!Array.isArray(rawIds) || rawIds.length === 0) {
|
|
269
|
+
return { ok: false, error: 'ids must be a non-empty array of session ids' };
|
|
270
|
+
}
|
|
271
|
+
if (rawIds.length > MAX_ARCHIVE_BATCH) {
|
|
272
|
+
return { ok: false, error: `ids must contain at most ${MAX_ARCHIVE_BATCH} session ids` };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const ids = [];
|
|
276
|
+
for (const value of rawIds) {
|
|
277
|
+
const id = asNonEmptyString(value);
|
|
278
|
+
if (!id) return { ok: false, error: 'ids must contain non-empty session ids' };
|
|
279
|
+
ids.push(id);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const archivedAt = payload?.archivedAt;
|
|
283
|
+
if (archivedAt !== undefined && (!Number.isSafeInteger(archivedAt) || archivedAt <= 0)) {
|
|
284
|
+
return { ok: false, error: 'archivedAt must be a positive integer timestamp' };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { ok: true, ids, archivedAt: archivedAt ?? Date.now() };
|
|
288
|
+
};
|
|
289
|
+
|
|
256
290
|
const resolveRequestedDirectory = async ({ payload, readSettingsFromDiskMigrated, sanitizeProjects, validateDirectoryPath }) => {
|
|
257
291
|
const projectID = asNonEmptyString(payload?.projectId) || asNonEmptyString(payload?.projectID);
|
|
258
292
|
if (projectID) {
|
|
@@ -579,6 +613,64 @@ export const createOpenChamberSessionService = (dependencies) => {
|
|
|
579
613
|
return { model, agent, variant, promptDispatched: true, dispatchedAsCommand: Boolean(resolvedCommand) };
|
|
580
614
|
};
|
|
581
615
|
|
|
616
|
+
/**
|
|
617
|
+
* Archive a batch of sessions in one request.
|
|
618
|
+
*
|
|
619
|
+
* The UI archives every session linked to a worktree before removing it.
|
|
620
|
+
* Doing that from the browser costs one request per session plus a store
|
|
621
|
+
* reconciliation between each of them, which is what made deleting a
|
|
622
|
+
* worktree with many sessions take tens of seconds. Here the batch stays on
|
|
623
|
+
* the server, next to OpenCode, and the client reconciles once.
|
|
624
|
+
*
|
|
625
|
+
* Sessions are updated one at a time on purpose: they are archived against a
|
|
626
|
+
* single OpenCode instance, and a fan-out of concurrent writes would trade a
|
|
627
|
+
* UI stall for server event-loop starvation. One failed session never stops
|
|
628
|
+
* the batch — it is reported in `failedIds` while the rest still archive, so
|
|
629
|
+
* callers keep the partial-failure behaviour they already show.
|
|
630
|
+
*/
|
|
631
|
+
const archive = async (payload = {}) => {
|
|
632
|
+
const parsed = parseArchiveRequest(payload);
|
|
633
|
+
if (!parsed.ok) {
|
|
634
|
+
throw new OpenChamberControlError(parsed.error, 400);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const resolvedDirectory = await resolveRequestedDirectory({
|
|
638
|
+
payload,
|
|
639
|
+
readSettingsFromDiskMigrated,
|
|
640
|
+
sanitizeProjects,
|
|
641
|
+
validateDirectoryPath,
|
|
642
|
+
});
|
|
643
|
+
if (!resolvedDirectory.ok) {
|
|
644
|
+
throw new OpenChamberControlError(resolvedDirectory.error, resolvedDirectory.status || 400);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250);
|
|
648
|
+
|
|
649
|
+
const directory = resolvedDirectory.directory;
|
|
650
|
+
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
|
|
651
|
+
const client = createOpencodeClient({ baseUrl, headers: getOpenCodeAuthHeaders() });
|
|
652
|
+
|
|
653
|
+
const archived = [];
|
|
654
|
+
const failedIds = [];
|
|
655
|
+
for (const sessionID of parsed.ids) {
|
|
656
|
+
try {
|
|
657
|
+
const response = await client.session.update({
|
|
658
|
+
sessionID,
|
|
659
|
+
directory,
|
|
660
|
+
time: { archived: parsed.archivedAt },
|
|
661
|
+
});
|
|
662
|
+
const session = response?.data;
|
|
663
|
+
if (session?.id) archived.push(session);
|
|
664
|
+
else failedIds.push(sessionID);
|
|
665
|
+
} catch (error) {
|
|
666
|
+
console.warn('[OpenChamberSessions] failed to archive session', sessionID, error);
|
|
667
|
+
failedIds.push(sessionID);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return { directory, archived, failedIds };
|
|
672
|
+
};
|
|
673
|
+
|
|
582
674
|
const create = async (payload = {}) => {
|
|
583
675
|
const title = asNonEmptyString(payload.title);
|
|
584
676
|
const prompt = asNonEmptyString(payload.prompt);
|
|
@@ -813,6 +905,7 @@ export const createOpenChamberSessionService = (dependencies) => {
|
|
|
813
905
|
|
|
814
906
|
return {
|
|
815
907
|
create,
|
|
908
|
+
archive,
|
|
816
909
|
send: (sessionID, payload) => runExisting('send', sessionID, payload),
|
|
817
910
|
fork: (sessionID, payload) => runExisting('fork', sessionID, payload),
|
|
818
911
|
};
|
|
@@ -843,6 +936,15 @@ export const registerOpenChamberSessionRoutes = (app, dependencies) => {
|
|
|
843
936
|
}
|
|
844
937
|
});
|
|
845
938
|
|
|
939
|
+
app.post('/api/openchamber/sessions/archive', express.json({ limit: '1mb' }), async (req, res) => {
|
|
940
|
+
try {
|
|
941
|
+
return res.json(await service.archive(req.body && typeof req.body === 'object' ? req.body : {}));
|
|
942
|
+
} catch (error) {
|
|
943
|
+
console.error('[OpenChamberSessions] failed to archive sessions:', error);
|
|
944
|
+
return sendServiceError(res, error, 'Failed to archive sessions');
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
|
|
846
948
|
app.post(
|
|
847
949
|
'/api/openchamber/sessions/:sessionId/send',
|
|
848
950
|
express.json({ limit: '1mb' }),
|