@openchambery/web 1.19.11 → 1.19.12
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/dist/assets/{AssistantView-BwrcqJzu.js → AssistantView-DxIghtun.js} +1 -1
- package/dist/assets/{DiagramView-BQO1cBLs.js → DiagramView-BeUgp_Y0.js} +1 -1
- package/dist/assets/{MarkdownRenderer-C49bfcHx.js → MarkdownRenderer-C4MGa_kQ.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl--takeMXN.js → MarkdownRendererImpl-DB4Q3kGa.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-DGSqWcPN.js → MarkstreamRendererImpl-BFdJzxia.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-LZU2vFAs.js → MobileDetailNavigation-B_RwWOwv.js} +1 -1
- package/dist/assets/{MobileShareBridge-D-BUjACe.js → MobileShareBridge-UiOLx0-2.js} +1 -1
- package/dist/assets/{MobileSurface-OAJjAQCZ.js → MobileSurface-CNChj_dX.js} +1 -1
- package/dist/assets/{MultiRunWindow-BNR9ZWmD.js → MultiRunWindow-C3p3lj3U.js} +1 -1
- package/dist/assets/{SettingsView-DGerg-C2.js → SettingsView-BXfKsz0J.js} +1 -1
- package/dist/assets/{SettingsWindow-CSpd5JbJ.js → SettingsWindow-8IDuPZA5.js} +1 -1
- package/dist/assets/{TerminalView-DS-oUxJr.js → TerminalView-BiKCueth.js} +1 -1
- package/dist/assets/{ToolOutputDialog-Q1k9-IaY.js → ToolOutputDialog-BC2F_Fdl.js} +1 -1
- package/dist/assets/{appThemeRegistry-CG7D-3-q.js → appThemeRegistry-B87OLIgc.js} +1 -1
- package/dist/assets/{gitApi-Cr-joUEw.js → gitApi-tTtHQVA3.js} +1 -1
- package/dist/assets/{insertionBoundaries-X0xbikAA.js → insertionBoundaries-CCQpZ5zn.js} +1 -1
- package/dist/assets/{main-BI7fNQoi.js → main-C_PUoT3E.js} +4 -4
- package/dist/assets/{main-D0L7fIg3.js → main-Dkg31fRY.js} +2 -2
- package/dist/assets/{miniChat-BJ9AclZX.js → miniChat-C9QMj8T8.js} +2 -2
- package/dist/assets/{mobile-OcCu_0mG.js → mobile-Z_X7CbzB.js} +2 -2
- package/dist/assets/{multirun-B0G_BEIN.js → multirun-B0V3hdnD.js} +1 -1
- package/dist/assets/{projectMeta-BNOzn1QS.js → projectMeta-DRW9MQ4z.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-BREc3uhX.js → renderElectronMiniChatApp-Dn1Su1dd.js} +2 -2
- package/dist/assets/{renderMobileApp-C4igZ42L.js → renderMobileApp-BG1JJt1Q.js} +4 -4
- package/dist/assets/{runtimeConfig-P8nVMYzE.js → runtimeConfig-C6matIEB.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-6lVRspis.js → runtimeEndpointReset-kgwmUJDb.js} +1 -1
- package/dist/assets/{sessionLookup-C_9-9ggp.js → sessionLookup-B6P_aCun.js} +1 -1
- package/dist/assets/{useAppFontEffects-CEmOsrr6.js → useAppFontEffects-Dmes1chr.js} +5 -5
- package/dist/assets/{useDesktopWindowControlsLayout-CxaK-zXh.js → useDesktopWindowControlsLayout-DuZ6URPR.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-BIwv5r5l.js → useEffectiveDirectory-CU2ChP0J.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-DHm5kZPx.js → useMobileNavigationStore-C8_J-qTp.js} +1 -1
- package/dist/assets/{useSessionAutoCleanup-CPLP9KWH.js → useSessionAutoCleanup-FOQeu1_T.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-CZ4ZTWEH.js → useWorkerHighlightedLines-GBqKnki1.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/mini-chat.html +2 -2
- package/dist/mobile.html +2 -2
- package/package.json +1 -1
- package/server/lib/assistants/DOCUMENTATION.md +5 -3
- package/server/lib/assistants/contact-tools.js +114 -22
- package/server/lib/assistants/contact-tools.test.js +56 -0
- package/server/lib/assistants/harness.js +17 -3
- package/server/lib/assistants/harness.test.js +120 -1
- package/server/lib/assistants/pi-tools.js +50 -3
- package/server/lib/assistants/pi-tools.test.js +25 -0
- package/server/lib/assistants/service.test.js +43 -0
- package/server/lib/llm/DOCUMENTATION.md +31 -18
- package/server/lib/llm/generate.js +43 -7
- package/server/lib/llm/generate.test.js +98 -1
- package/server/lib/llm/temp-directory.js +34 -13
- package/server/lib/llm/temp-directory.test.js +57 -0
|
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest';
|
|
|
5
5
|
import {
|
|
6
6
|
collectAssistantSkillDirectories,
|
|
7
7
|
createPiCodingRuntime,
|
|
8
|
+
formatPiCodingPrompt,
|
|
8
9
|
mergeSkillsByName,
|
|
9
10
|
PI_CODING_TOOL_NAMES,
|
|
10
11
|
resolveAssistantCwd,
|
|
@@ -78,6 +79,30 @@ describe('mergeSkillsByName', () => {
|
|
|
78
79
|
});
|
|
79
80
|
});
|
|
80
81
|
|
|
82
|
+
describe('formatPiCodingPrompt', () => {
|
|
83
|
+
it('includes full pi tool argument schemas and distinguishes skills from tools', async () => {
|
|
84
|
+
const cwd = temp();
|
|
85
|
+
const home = temp();
|
|
86
|
+
const runtime = await createPiCodingRuntime(cwd, { homeDir: home });
|
|
87
|
+
const prompt = formatPiCodingPrompt({
|
|
88
|
+
cwd: runtime.cwd,
|
|
89
|
+
skillsPrompt: runtime.skillsPrompt,
|
|
90
|
+
tools: runtime.tools,
|
|
91
|
+
});
|
|
92
|
+
expect(prompt).toContain('arguments schema:');
|
|
93
|
+
expect(prompt).toContain('"path"');
|
|
94
|
+
expect(prompt).toContain('"content"');
|
|
95
|
+
expect(prompt).toContain('"edits"');
|
|
96
|
+
expect(prompt).toContain('"oldText"');
|
|
97
|
+
expect(prompt).toContain('"newText"');
|
|
98
|
+
expect(prompt).toContain('"command"');
|
|
99
|
+
expect(prompt).toContain('not OpenCode native tools and not MCP');
|
|
100
|
+
expect(prompt).toContain('never invent skill or MCP tool names');
|
|
101
|
+
expect(prompt).toContain('context7');
|
|
102
|
+
await runtime.close();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
81
106
|
describe('createPiCodingRuntime', () => {
|
|
82
107
|
it('installs read/write/edit/bash and merges global plus project skills', async () => {
|
|
83
108
|
const home = temp();
|
|
@@ -1160,6 +1160,49 @@ describe('assistants service', () => {
|
|
|
1160
1160
|
service.close();
|
|
1161
1161
|
});
|
|
1162
1162
|
|
|
1163
|
+
it('same contact-turn assign gate: model re-send creates one worker and one card', async () => {
|
|
1164
|
+
const directory = root();
|
|
1165
|
+
const project = path.join(directory, 'app');
|
|
1166
|
+
fs.mkdirSync(project, { recursive: true });
|
|
1167
|
+
const creates = [];
|
|
1168
|
+
const service = setup(directory, {
|
|
1169
|
+
create: async (input) => {
|
|
1170
|
+
creates.push(input);
|
|
1171
|
+
return { data: { id: `ses_${creates.length}` } };
|
|
1172
|
+
},
|
|
1173
|
+
promptAsync: async () => ({ response: { status: 204 } }),
|
|
1174
|
+
}, {
|
|
1175
|
+
runContactTurn: async ({ tools, userText }) => {
|
|
1176
|
+
const assign = tools.find((tool) => tool.name === 'assign_session');
|
|
1177
|
+
const args = { prompt: userText, projectPath: project, title: 'Once' };
|
|
1178
|
+
const first = await assign.execute('call_1', args);
|
|
1179
|
+
const second = await assign.execute('call_2', args);
|
|
1180
|
+
const thirdDifferent = await assign.execute('call_3', { ...args, prompt: `${userText} again` });
|
|
1181
|
+
expect(first.terminate).toBe(true);
|
|
1182
|
+
expect(second.terminate).toBe(true);
|
|
1183
|
+
expect(second.details.assigned.sessionID).toBe(first.details.assigned.sessionID);
|
|
1184
|
+
expect(thirdDifferent.details.error).toBe('validation_error');
|
|
1185
|
+
return {
|
|
1186
|
+
text: first.content[0].text,
|
|
1187
|
+
bubbles: [first.content[0].text],
|
|
1188
|
+
cards: first.details.card ? [first.details.card, second.details.card].filter(Boolean) : [],
|
|
1189
|
+
tools,
|
|
1190
|
+
};
|
|
1191
|
+
},
|
|
1192
|
+
});
|
|
1193
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1194
|
+
await settleSend(service, assistant.id, {
|
|
1195
|
+
messageID: 'client_assign_once',
|
|
1196
|
+
parts: [{ type: 'text', text: 'Fix once' }],
|
|
1197
|
+
});
|
|
1198
|
+
expect(creates).toHaveLength(1);
|
|
1199
|
+
const page = service.contactMessages(assistant.id);
|
|
1200
|
+
const cards = page.messages.flatMap((message) => (message.parts || []).filter((part) => part.type === 'card'));
|
|
1201
|
+
expect(cards).toHaveLength(1);
|
|
1202
|
+
expect(cards[0]).toMatchObject({ cardType: 'session', sessionID: 'ses_1' });
|
|
1203
|
+
service.close();
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1163
1206
|
it('creates another assistant from create_assistant and persists the assistant card', async () => {
|
|
1164
1207
|
const directory = root();
|
|
1165
1208
|
const service = setup(directory, {}, {
|
|
@@ -29,27 +29,40 @@ The 1.18.4 client exposes `GET /provider`, `GET /config/providers`, and
|
|
|
29
29
|
the response is JSON (`Content-Type` or a JSON object body). SPA / OpenCode
|
|
30
30
|
HTML `200 <!doctype` is not generate — fall through to the throwaway path.
|
|
31
31
|
Bundled 1.18.4 has no sessionless generate.
|
|
32
|
-
2. Otherwise create a throwaway archived OpenCode session
|
|
33
|
-
|
|
34
|
-
`
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
32
|
+
2. Otherwise create a throwaway archived OpenCode session under a hidden
|
|
33
|
+
`openchamber-llm` agent whose frontmatter uses OpenCode 1.18
|
|
34
|
+
`permission: { "*": deny }` (not legacy `action`/`resource`/`effect` lists —
|
|
35
|
+
those load without a terminal `*` deny). `session.create` also sets
|
|
36
|
+
`permission: [{ permission: '*', pattern: '*', action: 'deny' }]` (SDK
|
|
37
|
+
PermissionRuleset; verified to round-trip on the session object). Deny every
|
|
38
|
+
catalog tool via `client.tool.ids()` → `{ [id]: false }` on `promptAsync`;
|
|
39
|
+
a `tool.ids` SDK/transport error fails generate (never silent empty deny).
|
|
40
|
+
Send messages as `system` + user text via `session.promptAsync` (v2
|
|
41
|
+
`session.prompt` only forwards `{ id, prompt, delivery, resume }` and drops
|
|
42
|
+
`model`/`parts` — that produced empty assistant text and a 502). Contact
|
|
43
|
+
file parts reuse the existing OpenCode `{ type: 'file', mime, url, filename? }`
|
|
44
|
+
delivery shape (data URLs in the contact SQLite store — not a second
|
|
45
|
+
attachment store) and are forwarded on `promptAsync` only when the connected
|
|
46
|
+
catalog marks that model as image-capable (`modalities.input`, `input`, or
|
|
47
|
+
`attachment`). Non-vision models (for example deepseek-v4-flash) keep the
|
|
48
|
+
`[image: …]` description and any text-file bytes, and skip image data URLs so
|
|
49
|
+
generate cannot stall on unsupported vision parts. Non-image text files are
|
|
50
|
+
also inlined into the flattened prompt. Wait for idle via `session.status` +
|
|
51
|
+
`session.messages`; a deterministic `session.messages` SDK error (for example
|
|
52
|
+
HTTP 400) fails generate immediately. Then delete the session and surface
|
|
53
|
+
delete SDK errors in diagnostics without erasing a successful text result.
|
|
54
|
+
Throwaway sessions use `metadata.openchamber.llm.purpose = 'chat-completions'`
|
|
55
|
+
(and archived immediately) so sidebar visibility, session-index, session-title,
|
|
56
|
+
and notification/push fanout treat them as system-owned — same contract as
|
|
50
57
|
non-empty `smallModel.purpose`. This is a text generator only — never the
|
|
51
58
|
contact transcript and never a coding SessionPrompt loop. Upstream
|
|
52
59
|
`info.error.message` is forwarded on 502.
|
|
60
|
+
`ensureLlmTempDirectory` keeps one process-wide throwaway root (`openchamber-llm-…`).
|
|
61
|
+
Concurrent first callers share a single `mkdtemp` inflight; every caller still
|
|
62
|
+
writes/refreshes `.opencode/agent/<name>.md` after settle so a mid-flight ensure
|
|
63
|
+
cannot skip a newer agent body. Concurrent warm refreshes serialize through one
|
|
64
|
+
write chain (last committed body wins; no interleaved partial writes).
|
|
65
|
+
`stopLlmTempDirectory` clears the singleton for shutdown/tests.
|
|
53
66
|
|
|
54
67
|
### Internal token callback (in-process only)
|
|
55
68
|
|
|
@@ -6,19 +6,34 @@ const SETTLE_POLL_MS = 250;
|
|
|
6
6
|
const INCOMPLETE_ASSISTANT_SETTLE_PROBES = 2;
|
|
7
7
|
const EMPTY_IDLE_PROBES = 5;
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Session-level deny-all. OpenCode 1.18 session.create serializes this
|
|
11
|
+
* PermissionRuleset shape (permission/pattern/action). Confirmed on local
|
|
12
|
+
* 1.18.23/1.18.29: create returns the same rule on the session object.
|
|
13
|
+
*/
|
|
14
|
+
export const LLM_SESSION_DENY_PERMISSION = Object.freeze([
|
|
15
|
+
Object.freeze({ permission: '*', pattern: '*', action: 'deny' }),
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Agent frontmatter for OpenCode 1.18. Runtime /agent metadata uses
|
|
20
|
+
* permission:[{permission,pattern,action}]. Markdown config accepts the
|
|
21
|
+
* PermissionActionConfig map (`"*": deny`) / scalar `deny` / tools:"*":false —
|
|
22
|
+
* not the legacy action/resource/effect list (that loads but does not add
|
|
23
|
+
* a terminal * deny, so build-like allows and skill guidance stay visible).
|
|
24
|
+
*/
|
|
9
25
|
const AGENT_MARKDOWN = `---
|
|
10
26
|
mode: primary
|
|
11
27
|
hidden: true
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
resource: "*"
|
|
15
|
-
effect: deny
|
|
28
|
+
permission:
|
|
29
|
+
"*": deny
|
|
16
30
|
---
|
|
17
31
|
|
|
18
32
|
You generate responses for an application-owned assistant. Follow the supplied system instructions and response format.
|
|
19
33
|
The application executes its registered tools, including openchamber-tool JSON fences in your response, and supplies their results on the next request.
|
|
20
34
|
Emit the requested application tool call when an action requires one. Your native tool permissions describe this generator process; the application's supplied tool catalog describes the assistant's capabilities.
|
|
21
35
|
Report execution and failures from supplied tool results only.
|
|
36
|
+
Do not call native OpenCode tools, MCP tools, or skill tools. Native permissions are denied for this generator session.
|
|
22
37
|
`;
|
|
23
38
|
|
|
24
39
|
const isMissing = (result) =>
|
|
@@ -152,7 +167,11 @@ const waitForIdleAssistant = async ({ client, sessionID, directory, signal }) =>
|
|
|
152
167
|
directory,
|
|
153
168
|
limit: 20,
|
|
154
169
|
}, { signal });
|
|
155
|
-
if (
|
|
170
|
+
if (messagesResult?.error) {
|
|
171
|
+
// Deterministic upstream failures (e.g. 400) must not look like "still settling".
|
|
172
|
+
failGenerate(`OpenCode LLM session.messages failed: ${sdkErrorMessage(messagesResult, 'messages failed')}`);
|
|
173
|
+
}
|
|
174
|
+
if (Array.isArray(messagesResult?.data)) {
|
|
156
175
|
const lastInfo = readMessageInfo(messagesResult.data.at(-1));
|
|
157
176
|
if (lastInfo?.role === 'assistant') {
|
|
158
177
|
emptyIdleProbes = 0;
|
|
@@ -469,13 +488,23 @@ export async function generateOpenCodeText({
|
|
|
469
488
|
? clientFactory()
|
|
470
489
|
: createOpencodeClient({ baseUrl, directory: workingDirectory, headers });
|
|
471
490
|
|
|
472
|
-
|
|
491
|
+
let toolIds;
|
|
492
|
+
try {
|
|
493
|
+
toolIds = await client.tool.ids({ directory: workingDirectory });
|
|
494
|
+
} catch (error) {
|
|
495
|
+
failGenerate(`OpenCode tool.ids failed: ${error?.message || 'tool.ids failed'}`);
|
|
496
|
+
}
|
|
497
|
+
if (toolIds?.error) {
|
|
498
|
+
failGenerate(`OpenCode tool.ids failed: ${sdkErrorMessage(toolIds, 'tool.ids failed')}`);
|
|
499
|
+
}
|
|
473
500
|
const tools = deniedTools(Array.isArray(toolIds?.data) ? toolIds.data : []);
|
|
474
501
|
|
|
475
502
|
const created = await client.session.create({
|
|
476
503
|
directory: workingDirectory,
|
|
477
504
|
title: '[openchamber-llm] generate',
|
|
478
505
|
agent: LLM_AGENT_NAME,
|
|
506
|
+
// Session fence: deny every native OpenCode/MCP/skill tool for this throwaway.
|
|
507
|
+
permission: LLM_SESSION_DENY_PERMISSION,
|
|
479
508
|
metadata: { openchamber: { llm: { purpose: 'chat-completions' } } },
|
|
480
509
|
}, { signal: controller.signal });
|
|
481
510
|
const sessionID = created?.data?.id;
|
|
@@ -548,7 +577,13 @@ export async function generateOpenCodeText({
|
|
|
548
577
|
}
|
|
549
578
|
unsubscribeDeltas = null;
|
|
550
579
|
try {
|
|
551
|
-
await client.session.delete({ sessionID, directory: workingDirectory });
|
|
580
|
+
const deleted = await client.session.delete({ sessionID, directory: workingDirectory });
|
|
581
|
+
if (deleted?.error) {
|
|
582
|
+
console.warn(
|
|
583
|
+
'[llm] failed to delete throwaway OpenCode session:',
|
|
584
|
+
sdkErrorMessage(deleted, 'delete failed'),
|
|
585
|
+
);
|
|
586
|
+
}
|
|
552
587
|
} catch (error) {
|
|
553
588
|
console.warn('[llm] failed to delete throwaway OpenCode session:', error?.message || error);
|
|
554
589
|
}
|
|
@@ -570,4 +605,5 @@ export const _test = {
|
|
|
570
605
|
subscribeThrowawayTextDeltas,
|
|
571
606
|
LLM_AGENT_NAME,
|
|
572
607
|
AGENT_MARKDOWN,
|
|
608
|
+
LLM_SESSION_DENY_PERMISSION,
|
|
573
609
|
};
|
|
@@ -74,8 +74,11 @@ describe('generateOpenCodeText', () => {
|
|
|
74
74
|
clientFactory: createOpencodeClient,
|
|
75
75
|
ensureTempDirectory: async ({ agentMarkdown }) => {
|
|
76
76
|
expect(agentMarkdown).toContain('openchamber-tool JSON fences')
|
|
77
|
-
expect(agentMarkdown).
|
|
77
|
+
expect(agentMarkdown).toMatch(/"\*"\s*:\s*deny/)
|
|
78
|
+
expect(agentMarkdown).toContain('permission:')
|
|
79
|
+
expect(agentMarkdown).not.toContain('effect: deny')
|
|
78
80
|
expect(agentMarkdown).toContain('Report execution and failures from supplied tool results only.')
|
|
81
|
+
expect(agentMarkdown).toContain('Native permissions are denied')
|
|
79
82
|
return '/tmp/openchamber-llm'
|
|
80
83
|
},
|
|
81
84
|
detect: async () => ({ available: false, mode: 'throwaway-session' }),
|
|
@@ -83,6 +86,8 @@ describe('generateOpenCodeText', () => {
|
|
|
83
86
|
|
|
84
87
|
expect(create).toHaveBeenCalledWith(expect.objectContaining({
|
|
85
88
|
title: expect.stringContaining('[openchamber-llm]'),
|
|
89
|
+
agent: 'openchamber-llm',
|
|
90
|
+
permission: [{ permission: '*', pattern: '*', action: 'deny' }],
|
|
86
91
|
metadata: { openchamber: { llm: { purpose: 'chat-completions' } } },
|
|
87
92
|
}), expect.anything())
|
|
88
93
|
expect(update).toHaveBeenCalled()
|
|
@@ -417,6 +422,98 @@ describe('generateOpenCodeText', () => {
|
|
|
417
422
|
expect(subscribers.size).toBe(0)
|
|
418
423
|
})
|
|
419
424
|
|
|
425
|
+
it('throws when tool.ids returns an SDK error instead of denying nothing', async () => {
|
|
426
|
+
const remove = vi.fn(async () => ({ data: true }))
|
|
427
|
+
await expect(generateOpenCodeText({
|
|
428
|
+
buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
|
|
429
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
430
|
+
providerID: 'opencode',
|
|
431
|
+
modelID: 'gpt-5-nano',
|
|
432
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
433
|
+
clientFactory: () => ({
|
|
434
|
+
session: {
|
|
435
|
+
create: vi.fn(async () => ({ data: { id: 'ses_tmp' } })),
|
|
436
|
+
update: vi.fn(),
|
|
437
|
+
prompt: vi.fn(),
|
|
438
|
+
promptAsync: vi.fn(),
|
|
439
|
+
status: vi.fn(),
|
|
440
|
+
messages: vi.fn(),
|
|
441
|
+
delete: remove,
|
|
442
|
+
},
|
|
443
|
+
tool: { ids: async () => ({ error: { status: 500, message: 'tool catalog unavailable' } }) },
|
|
444
|
+
}),
|
|
445
|
+
ensureTempDirectory: async () => '/tmp/openchamber-llm',
|
|
446
|
+
detect: async () => ({ available: false, mode: 'throwaway-session' }),
|
|
447
|
+
})).rejects.toMatchObject({
|
|
448
|
+
code: 'upstream_error',
|
|
449
|
+
message: expect.stringContaining('tool.ids failed'),
|
|
450
|
+
})
|
|
451
|
+
expect(remove).not.toHaveBeenCalled()
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
it('throws when session.messages returns a deterministic 400', async () => {
|
|
455
|
+
const remove = vi.fn(async () => ({ data: true }))
|
|
456
|
+
await expect(generateOpenCodeText({
|
|
457
|
+
buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
|
|
458
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
459
|
+
providerID: 'opencode',
|
|
460
|
+
modelID: 'gpt-5-nano',
|
|
461
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
462
|
+
clientFactory: () => ({
|
|
463
|
+
session: {
|
|
464
|
+
create: async () => ({ data: { id: 'ses_tmp' } }),
|
|
465
|
+
update: async () => ({ data: { id: 'ses_tmp' } }),
|
|
466
|
+
prompt: vi.fn(),
|
|
467
|
+
promptAsync: async () => ({ response: { status: 204 } }),
|
|
468
|
+
status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
|
|
469
|
+
messages: async () => ({ error: { status: 400, message: 'OutputFormatJsonSchema' } }),
|
|
470
|
+
delete: remove,
|
|
471
|
+
},
|
|
472
|
+
tool: { ids: async () => ({ data: ['bash'] }) },
|
|
473
|
+
}),
|
|
474
|
+
ensureTempDirectory: async () => '/tmp/openchamber-llm',
|
|
475
|
+
detect: async () => ({ available: false, mode: 'throwaway-session' }),
|
|
476
|
+
})).rejects.toMatchObject({
|
|
477
|
+
code: 'upstream_error',
|
|
478
|
+
message: expect.stringMatching(/session\.messages failed.*400|OutputFormatJsonSchema/),
|
|
479
|
+
})
|
|
480
|
+
expect(remove).toHaveBeenCalled()
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
it('logs SDK delete errors without erasing a successful generate result', async () => {
|
|
484
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
485
|
+
try {
|
|
486
|
+
const result = await generateOpenCodeText({
|
|
487
|
+
buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
|
|
488
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
489
|
+
providerID: 'opencode',
|
|
490
|
+
modelID: 'gpt-5-nano',
|
|
491
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
492
|
+
clientFactory: () => ({
|
|
493
|
+
session: {
|
|
494
|
+
create: async () => ({ data: { id: 'ses_tmp' } }),
|
|
495
|
+
update: async () => ({ data: { id: 'ses_tmp' } }),
|
|
496
|
+
prompt: vi.fn(),
|
|
497
|
+
promptAsync: async () => ({ response: { status: 204 } }),
|
|
498
|
+
status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
|
|
499
|
+
messages: async () => ({ data: [completedAssistant('ok')] }),
|
|
500
|
+
delete: async () => ({ error: { status: 500, message: 'delete denied' } }),
|
|
501
|
+
},
|
|
502
|
+
tool: { ids: async () => ({ data: [] }) },
|
|
503
|
+
}),
|
|
504
|
+
ensureTempDirectory: async () => '/tmp/openchamber-llm',
|
|
505
|
+
detect: async () => ({ available: false, mode: 'throwaway-session' }),
|
|
506
|
+
})
|
|
507
|
+
expect(result).toEqual({ text: 'ok', source: 'throwaway-session' })
|
|
508
|
+
expect(warn).toHaveBeenCalledWith(
|
|
509
|
+
expect.stringContaining('[llm] failed to delete throwaway OpenCode session:'),
|
|
510
|
+
expect.stringContaining('delete denied'),
|
|
511
|
+
)
|
|
512
|
+
} finally {
|
|
513
|
+
warn.mockRestore()
|
|
514
|
+
}
|
|
515
|
+
})
|
|
516
|
+
|
|
420
517
|
it('does not invent deltas on the sessionless /generate JSON path', async () => {
|
|
421
518
|
const onTextDelta = vi.fn()
|
|
422
519
|
const subscribers = new Set()
|
|
@@ -6,28 +6,49 @@ import path from 'node:path';
|
|
|
6
6
|
let tempDirectory = null;
|
|
7
7
|
/** @type {Promise<string> | null} */
|
|
8
8
|
let inflight = null;
|
|
9
|
+
/** Serialize markdown refreshes so concurrent ensure cannot interleave one file. */
|
|
10
|
+
let writeChain = Promise.resolve();
|
|
11
|
+
|
|
12
|
+
const writeAgentMarkdown = async (root, agentName, agentMarkdown) => {
|
|
13
|
+
const agentDir = path.join(root, '.opencode', 'agent');
|
|
14
|
+
await fs.mkdir(agentDir, { recursive: true });
|
|
15
|
+
await fs.writeFile(path.join(agentDir, `${agentName}.md`), agentMarkdown, 'utf8');
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const enqueueWrite = (work) => {
|
|
19
|
+
const run = writeChain.catch(() => {}).then(work);
|
|
20
|
+
writeChain = run.catch(() => {});
|
|
21
|
+
return run;
|
|
22
|
+
};
|
|
9
23
|
|
|
10
24
|
export async function ensureLlmTempDirectory({ agentName, agentMarkdown }) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
25
|
+
// Long-lived servers / HMR must refresh the hidden agent markdown so a
|
|
26
|
+
// permission-format fix is not stuck behind a stale temp directory.
|
|
27
|
+
// Concurrent first callers share one mkdtemp; every caller still writes its
|
|
28
|
+
// markdown after settle so a mid-flight ensure cannot skip a newer body.
|
|
29
|
+
// Concurrent warm writes are serialized (last committed body wins).
|
|
30
|
+
if (!tempDirectory) {
|
|
31
|
+
if (!inflight) {
|
|
32
|
+
inflight = (async () => {
|
|
33
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-llm-'));
|
|
34
|
+
tempDirectory = root;
|
|
35
|
+
return root;
|
|
36
|
+
})().finally(() => {
|
|
37
|
+
inflight = null;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
await inflight;
|
|
23
41
|
}
|
|
24
|
-
|
|
42
|
+
const root = tempDirectory;
|
|
43
|
+
await enqueueWrite(() => writeAgentMarkdown(root, agentName, agentMarkdown));
|
|
44
|
+
return root;
|
|
25
45
|
}
|
|
26
46
|
|
|
27
47
|
export async function stopLlmTempDirectory() {
|
|
28
48
|
const dir = tempDirectory;
|
|
29
49
|
tempDirectory = null;
|
|
30
50
|
inflight = null;
|
|
51
|
+
writeChain = Promise.resolve();
|
|
31
52
|
if (!dir) return;
|
|
32
53
|
try {
|
|
33
54
|
await fs.rm(dir, { recursive: true, force: true });
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
4
|
+
import { ensureLlmTempDirectory, stopLlmTempDirectory } from './temp-directory.js';
|
|
5
|
+
|
|
6
|
+
const readAgent = async (root, agentName) =>
|
|
7
|
+
fs.readFile(path.join(root, '.opencode', 'agent', `${agentName}.md`), 'utf8');
|
|
8
|
+
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
await stopLlmTempDirectory();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('ensureLlmTempDirectory', () => {
|
|
14
|
+
it('shares one directory across concurrent first ensures and applies each markdown body', async () => {
|
|
15
|
+
const firstBody = '---\nname: a\n---\nFIRST\n';
|
|
16
|
+
const secondBody = '---\nname: a\n---\nSECOND\n';
|
|
17
|
+
const [first, second] = await Promise.all([
|
|
18
|
+
ensureLlmTempDirectory({ agentName: 'openchamber-llm', agentMarkdown: firstBody }),
|
|
19
|
+
ensureLlmTempDirectory({ agentName: 'openchamber-llm', agentMarkdown: secondBody }),
|
|
20
|
+
]);
|
|
21
|
+
expect(first).toBe(second);
|
|
22
|
+
const written = await readAgent(first, 'openchamber-llm');
|
|
23
|
+
// Last writer wins; both callers must have completed a full write after settle.
|
|
24
|
+
expect(written === firstBody || written === secondBody).toBe(true);
|
|
25
|
+
expect(written).toMatch(/FIRST|SECOND/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('refreshes markdown on a warm directory under concurrent ensure', async () => {
|
|
29
|
+
const root = await ensureLlmTempDirectory({
|
|
30
|
+
agentName: 'openchamber-llm',
|
|
31
|
+
agentMarkdown: '---\nname: a\n---\nSEED\n',
|
|
32
|
+
});
|
|
33
|
+
const bodies = Array.from({ length: 8 }, (_, index) => `---\nname: a\n---\nREFRESH-${index}\n`);
|
|
34
|
+
const roots = await Promise.all(
|
|
35
|
+
bodies.map((agentMarkdown) => ensureLlmTempDirectory({ agentName: 'openchamber-llm', agentMarkdown })),
|
|
36
|
+
);
|
|
37
|
+
expect(new Set(roots)).toEqual(new Set([root]));
|
|
38
|
+
const written = await readAgent(root, 'openchamber-llm');
|
|
39
|
+
expect(bodies).toContain(written);
|
|
40
|
+
expect(written).toMatch(/REFRESH-\d+/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('stop clears the singleton so the next ensure creates a fresh directory', async () => {
|
|
44
|
+
const first = await ensureLlmTempDirectory({
|
|
45
|
+
agentName: 'openchamber-llm',
|
|
46
|
+
agentMarkdown: '---\nname: a\n---\nA\n',
|
|
47
|
+
});
|
|
48
|
+
await stopLlmTempDirectory();
|
|
49
|
+
const second = await ensureLlmTempDirectory({
|
|
50
|
+
agentName: 'openchamber-llm',
|
|
51
|
+
agentMarkdown: '---\nname: a\n---\nB\n',
|
|
52
|
+
});
|
|
53
|
+
expect(second).not.toBe(first);
|
|
54
|
+
await expect(fs.access(first)).rejects.toThrow();
|
|
55
|
+
expect(await readAgent(second, 'openchamber-llm')).toContain('B');
|
|
56
|
+
});
|
|
57
|
+
});
|