@openchambery/web 1.19.15-beta.4 → 1.19.15-beta.5

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.
Files changed (50) hide show
  1. package/dist/assets/{AssistantView-bxT9mr97.js → AssistantView-BYhhmoA2.js} +2 -2
  2. package/dist/assets/{DiagramView-DJPrdklU.js → DiagramView-BhZGZR7a.js} +1 -1
  3. package/dist/assets/{MarkdownRenderer-4gjE7aM5.js → MarkdownRenderer-4ZqkTOkJ.js} +2 -2
  4. package/dist/assets/{MarkdownRendererImpl-CBdwjEMH.js → MarkdownRendererImpl-DVRCygDq.js} +1 -1
  5. package/dist/assets/{MarkstreamRendererImpl-CHwYXkzv.js → MarkstreamRendererImpl-D9n5y8Ya.js} +1 -1
  6. package/dist/assets/{MobileDetailNavigation-DLmATOi0.js → MobileDetailNavigation-CjegoTpK.js} +1 -1
  7. package/dist/assets/{MobileShareBridge-BbvoAeXg.js → MobileShareBridge-C3psjnIM.js} +1 -1
  8. package/dist/assets/{MobileSurface-BBOx43VU.js → MobileSurface-B2W_Ivc3.js} +1 -1
  9. package/dist/assets/{MultiRunWindow-B7XPMkdc.js → MultiRunWindow-ail0iv6X.js} +1 -1
  10. package/dist/assets/{SettingsView-DABJm3u8.js → SettingsView-D8k2DBQR.js} +1 -1
  11. package/dist/assets/{SettingsWindow-D7n0m-52.js → SettingsWindow-X8noQG66.js} +1 -1
  12. package/dist/assets/{TerminalView-DERnAkUi.js → TerminalView-C3El4ekK.js} +1 -1
  13. package/dist/assets/{ToolOutputDialog-Bx3plCCc.js → ToolOutputDialog-Dwzyq-3O.js} +1 -1
  14. package/dist/assets/{appThemeRegistry-BdHiNL-J.js → appThemeRegistry-DEvc-N79.js} +1 -1
  15. package/dist/assets/{fileMentionTouchSelection-B3sIMQCF.js → fileMentionTouchSelection-CPVr_gQU.js} +1 -1
  16. package/dist/assets/{gitApi-CDc5ynsh.js → gitApi-BLjX-HPB.js} +1 -1
  17. package/dist/assets/{insertionBoundaries-3yNePddt.js → insertionBoundaries-J-X6p8SZ.js} +1 -1
  18. package/dist/assets/{main-C_21O0IU.js → main-DE9ZPE7v.js} +2 -2
  19. package/dist/assets/{main-5GGJlyEB.js → main-D_CE0IdS.js} +4 -4
  20. package/dist/assets/{miniChat-Ti_tFK96.js → miniChat-DYzPkhik.js} +2 -2
  21. package/dist/assets/{mobile-BUYP8DBL.js → mobile-Bgjyr78h.js} +2 -2
  22. package/dist/assets/{multirun-CXazK6qX.js → multirun-C2B8paly.js} +1 -1
  23. package/dist/assets/{projectMeta-sXx-r5jn.js → projectMeta-CqNKRnOv.js} +1 -1
  24. package/dist/assets/{renderElectronMiniChatApp-CLx8jNP4.js → renderElectronMiniChatApp-B485dcjy.js} +2 -2
  25. package/dist/assets/{renderMobileApp-BGKxwMFU.js → renderMobileApp-CDH1hj4K.js} +4 -4
  26. package/dist/assets/{runtimeConfig-YCy1Bcb2.js → runtimeConfig-CVXjAkK_.js} +1 -1
  27. package/dist/assets/{runtimeEndpointReset-Dh3YvmRM.js → runtimeEndpointReset-CPp_AS4G.js} +1 -1
  28. package/dist/assets/{sessionLookup-BzkA2pF0.js → sessionLookup-DZv5D4F6.js} +1 -1
  29. package/dist/assets/{useAppFontEffects-BLw4Av7h.js → useAppFontEffects-BC0f-NKT.js} +5 -5
  30. package/dist/assets/{useDesktopWindowControlsLayout-DNDIYvwP.js → useDesktopWindowControlsLayout-CDn9P73b.js} +1 -1
  31. package/dist/assets/{useEffectiveDirectory-B_3sINMx.js → useEffectiveDirectory-BP7xec6F.js} +1 -1
  32. package/dist/assets/{useMobileNavigationStore-BBVnSn9p.js → useMobileNavigationStore-C0wYiZ5R.js} +1 -1
  33. package/dist/assets/{useSessionAutoCleanup-SsGxsSRB.js → useSessionAutoCleanup-BBUtm7fU.js} +1 -1
  34. package/dist/assets/{useWorkerHighlightedLines-WCuVI6O_.js → useWorkerHighlightedLines-D7Jpcbjg.js} +1 -1
  35. package/dist/index.html +2 -2
  36. package/dist/mini-chat.html +2 -2
  37. package/dist/mobile.html +2 -2
  38. package/package.json +1 -1
  39. package/server/lib/assistants/DOCUMENTATION.md +31 -6
  40. package/server/lib/assistants/contact-tools.js +1 -16
  41. package/server/lib/assistants/contact-tools.test.js +0 -27
  42. package/server/lib/assistants/harness.js +140 -133
  43. package/server/lib/assistants/harness.test.js +207 -43
  44. package/server/lib/assistants/service.js +10 -33
  45. package/server/lib/assistants/service.test.js +75 -3
  46. package/server/lib/llm/DOCUMENTATION.md +8 -1
  47. package/server/lib/llm/completions.js +5 -0
  48. package/server/lib/llm/completions.test.js +19 -0
  49. package/server/lib/llm/generate.js +5 -1
  50. package/server/lib/llm/generate.test.js +23 -0
@@ -75,6 +75,28 @@ const settleSend = async (service, assistantID, body) => {
75
75
  const assistantInput = { name: 'A', providerID: 'p', modelID: 'm' };
76
76
 
77
77
  describe('assistants service', () => {
78
+ it('rehydrates the saved thinking variant into contact turns and preserves it on a rejected write', async () => {
79
+ const directory = root();
80
+ const runContactTurn = vi.fn(async () => ({ text: 'done', bubbles: ['done'] }));
81
+ let service = setup(directory, {}, { runContactTurn });
82
+ try {
83
+ const assistant = service.createAssistant({ ...assistantInput, variant: 'high' });
84
+ service.close();
85
+ service = setup(directory, {}, { runContactTurn });
86
+ await settleSend(service, assistant.id, { messageID: 'variant-restored', parts: [{ type: 'text', text: 'hello' }] });
87
+ expect(runContactTurn.mock.calls[0][0].assistant.variant).toBe('high');
88
+ await expect(service.updateAssistant(assistant.id, { expectedRevision: 999, variant: 'low' })).rejects.toBeDefined();
89
+ await settleSend(service, assistant.id, { messageID: 'variant-after-conflict', parts: [{ type: 'text', text: 'hello' }] });
90
+ expect(runContactTurn.mock.calls[1][0].assistant.variant).toBe('high');
91
+ await service.updateAssistant(assistant.id, { expectedRevision: 1, variant: null });
92
+ await settleSend(service, assistant.id, { messageID: 'variant-default', parts: [{ type: 'text', text: 'hello' }] });
93
+ expect(runContactTurn.mock.calls[2][0].assistant.variant).toBeNull();
94
+ } finally {
95
+ service.close();
96
+ fs.rmSync(directory, { recursive: true, force: true });
97
+ }
98
+ });
99
+
78
100
  it.each(['steer', 'archive', 'delete'])('%s_session performs the real scoped SDK operation', async (operation) => {
79
101
  const directory = root();
80
102
  const mutate = vi.fn(async () => ({ data: true }));
@@ -3670,11 +3692,11 @@ describe('assistants service', () => {
3670
3692
  service.close();
3671
3693
  });
3672
3694
 
3673
- it('notifies like a contact SMS when a contact turn completes', async () => {
3695
+ it('notifies with the latest model message without replaying progress at completion', async () => {
3674
3696
  const onContactTurnComplete = vi.fn();
3675
3697
  const service = setup(root(), {}, {
3676
3698
  onContactTurnComplete,
3677
- runContactTurn: async () => ({ text: '我去找一下', bubbles: ['我去找一下', 'Opened a coding session.'] }),
3699
+ runContactTurn: async () => ({ text: '任务已安排。', bubbles: ['我去找一下', '任务已安排。'] }),
3678
3700
  });
3679
3701
  const assistant = service.createAssistant({ ...assistantInput, name: '大小白' });
3680
3702
  await settleSend(service, assistant.id, {
@@ -3686,7 +3708,7 @@ describe('assistants service', () => {
3686
3708
  name: '大小白',
3687
3709
  turnID: 'notify_1',
3688
3710
  status: 'complete',
3689
- body: '我去找一下\nOpened a coding session.',
3711
+ body: '任务已安排。',
3690
3712
  });
3691
3713
  service.close();
3692
3714
  });
@@ -4341,6 +4363,56 @@ describe('read_session referenced conversation', () => {
4341
4363
  service.close();
4342
4364
  });
4343
4365
 
4366
+ it('persists public model messages before generation and tool completion, with no final replay', async () => {
4367
+ const directory = root();
4368
+ const events = [];
4369
+ let releaseGeneration;
4370
+ let releaseTool;
4371
+ const generationGate = new Promise((resolve) => { releaseGeneration = resolve; });
4372
+ const toolGate = new Promise((resolve) => { releaseTool = resolve; });
4373
+ const prefix = '```openchamber-message\n{"text":"我先读一下会话,确认目前的进展。"}\n```\n';
4374
+ const tool = '```openchamber-tool\n{"name":"read_session","arguments":{"sessionID":"ses_progress"}}\n```';
4375
+ let calls = 0;
4376
+ const read = vi.fn(async () => {
4377
+ await toolGate;
4378
+ return { data: [] };
4379
+ });
4380
+ const service = setup(directory, {
4381
+ get: async () => ({ data: { id: 'ses_progress', directory } }), messages: read,
4382
+ }, {
4383
+ onContactTurnEvent: (event) => events.push(event),
4384
+ runContactTurn: realRunContactTurn,
4385
+ createChatCompletion: async ({ onTextDelta }) => {
4386
+ if (++calls === 1) {
4387
+ onTextDelta(prefix);
4388
+ await generationGate;
4389
+ onTextDelta(tool);
4390
+ return { text: prefix + tool };
4391
+ }
4392
+ return { text: '```openchamber-final\n{"status":"complete","text":"这个会话目前为空。"}\n```' };
4393
+ },
4394
+ });
4395
+ const assistant = service.createAssistant(assistantInput);
4396
+ try {
4397
+ await service.send(assistant.id, { messageID: 'live_public', parts: [{ type: 'text', text: '读一下会话' }] });
4398
+ await vi.waitFor(() => expect(service.contactMessages(assistant.id).messages.some((m) => m.text === '我先读一下会话,确认目前的进展。')).toBe(true));
4399
+ expect(read).not.toHaveBeenCalled();
4400
+ expect(service.snapshot().assistants.find((a) => a.id === assistant.id).working).toBe(true);
4401
+ expect(events.some((e) => e.type === 'openchamber:contact-turn-end')).toBe(false);
4402
+ releaseGeneration();
4403
+ await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(1));
4404
+ // A fresh snapshot/history read while the actual tool is pending restores the same message.
4405
+ expect(service.contactMessages(assistant.id).messages.filter((m) => m.role === 'assistant').map((m) => m.text)).toEqual(['我先读一下会话,确认目前的进展。']);
4406
+ releaseTool();
4407
+ await service.whenContactTurnSettled('live_public');
4408
+ expect(service.contactMessages(assistant.id).messages.filter((m) => m.role === 'assistant').map((m) => m.text)).toEqual(['我先读一下会话,确认目前的进展。', '这个会话目前为空。']);
4409
+ const deltas = events.filter((e) => e.type === 'openchamber:contact-bubble-delta' && e.properties.done);
4410
+ expect(deltas.map((e) => e.properties.bubbleIndex)).toEqual([0, 1]);
4411
+ } finally {
4412
+ releaseGeneration(); releaseTool(); service.close();
4413
+ }
4414
+ });
4415
+
4344
4416
  it('real harness duplicate preambles keep both lines in SSE and SQLite', async () => {
4345
4417
  const directory = root();
4346
4418
  const events = [];
@@ -9,7 +9,7 @@ only model path the Assistant harness (`pi-agent-core` `streamFn`) talks to.
9
9
  from OpenCode `GET /provider` (`connected`) plus `GET /config/providers`.
10
10
  Plugin adapter formats stay inside OpenCode; this route does not parse them.
11
11
  - `POST /api/openchamber/llm/chat/completions` — `{ model, messages, stream?,
12
- providerID?, modelID? }` → OpenAI `chat.completion` JSON.
12
+ providerID?, modelID?, variant? }` → OpenAI `chat.completion` JSON.
13
13
  This **public** gateway is **non-streaming**. Bundled OpenCode 1.18.4 generate
14
14
  (`POST /generate` when present, otherwise throwaway `session.promptAsync`
15
15
  plus idle wait) returns a full assistant turn. `stream: true` is rejected with
@@ -19,6 +19,13 @@ only model path the Assistant harness (`pi-agent-core` `streamFn`) talks to.
19
19
 
20
20
  `model` may be `providerID/modelID` or a bare `modelID` paired with `providerID`.
21
21
 
22
+ `variant` is an optional OpenCode model variant string. Null, omitted, or empty
23
+ values preserve the provider default. Other types fail validation before a model
24
+ call. A selected variant is forwarded unchanged to sessionless generate and to
25
+ the throwaway `session.promptAsync` body. OpenCode owns provider-specific variant
26
+ semantics. Variant selection changes inference configuration only; completion
27
+ output continues to include text parts and keeps reasoning parts private.
28
+
22
29
  ## Internals (verified on bundled `@opencode-ai/sdk` 1.18.4)
23
30
 
24
31
  The 1.18.4 client exposes `GET /provider`, `GET /config/providers`, and
@@ -96,6 +96,10 @@ export async function createChatCompletion({
96
96
  const resolved = parseModelRef(body.model, body.providerID, body.modelID);
97
97
  if (!resolved) throw new LlmError('validation_error', 400, 'model or providerID/modelID is required');
98
98
  const messages = normalizeMessages(body.messages);
99
+ if (body.variant != null && typeof body.variant !== 'string') {
100
+ throw new LlmError('validation_error', 400, 'variant must be a string or null');
101
+ }
102
+ const variant = body.variant?.trim() || undefined;
99
103
 
100
104
  const client = clientFactory?.() ?? null;
101
105
  const catalog = await loadCatalog(client ?? {
@@ -118,6 +122,7 @@ export async function createChatCompletion({
118
122
  getOpenCodeAuthHeaders,
119
123
  providerID: resolved.providerID,
120
124
  modelID: resolved.modelID,
125
+ ...(variant ? { variant } : {}),
121
126
  messages,
122
127
  fetchImpl,
123
128
  clientFactory,
@@ -2,6 +2,25 @@ import { describe, expect, it, vi } from 'vitest'
2
2
  import { LlmError, createChatCompletion } from './completions.js'
3
3
 
4
4
  describe('createChatCompletion', () => {
5
+ it.each(['high', 'medium', null, ''])('forwards variant %s with default compatibility', async (variant) => {
6
+ const generateText = vi.fn(async () => ({ text: 'done' }));
7
+ await createChatCompletion({
8
+ generateText,
9
+ loadCatalog: async () => ({ models: [{ providerID: 'p', modelID: 'm' }] }),
10
+ body: { model: 'p/m', variant, messages: [{ role: 'user', content: 'hi' }] },
11
+ });
12
+ expect(generateText.mock.calls[0][0].variant).toBe(variant || undefined);
13
+ });
14
+
15
+ it('rejects malformed variants before a model call', async () => {
16
+ const generateText = vi.fn();
17
+ await expect(createChatCompletion({
18
+ generateText,
19
+ body: { model: 'p/m', variant: { high: true }, messages: [{ role: 'user', content: 'hi' }] },
20
+ })).rejects.toMatchObject({ code: 'validation_error', statusCode: 400 });
21
+ expect(generateText).not.toHaveBeenCalled();
22
+ });
23
+
5
24
  it('rejects when the requested model is not connected', async () => {
6
25
  const generateText = vi.fn()
7
26
  await expect(createChatCompletion({
@@ -350,12 +350,13 @@ const filesForPrompt = (files, forwardImageParts) => {
350
350
  return files.filter((part) => !String(part?.mime || '').startsWith('image/'));
351
351
  };
352
352
 
353
- async function generateViaSessionless({ fetchImpl, url, headers, providerID, modelID, messages, signal }) {
353
+ async function generateViaSessionless({ fetchImpl, url, headers, providerID, modelID, variant, messages, signal }) {
354
354
  const response = await fetchImpl(url, {
355
355
  method: 'POST',
356
356
  headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...headers },
357
357
  body: JSON.stringify({
358
358
  model: { providerID, modelID },
359
+ ...(variant ? { variant } : {}),
359
360
  messages,
360
361
  }),
361
362
  signal,
@@ -451,6 +452,7 @@ export async function generateOpenCodeText({
451
452
  providerID,
452
453
  modelID,
453
454
  messages,
455
+ variant,
454
456
  fetchImpl = globalThis.fetch.bind(globalThis),
455
457
  clientFactory,
456
458
  ensureTempDirectory,
@@ -508,6 +510,7 @@ export async function generateOpenCodeText({
508
510
  headers,
509
511
  providerID,
510
512
  modelID,
513
+ variant,
511
514
  messages: forwardImageParts
512
515
  ? messages
513
516
  : messages.map((message) => {
@@ -586,6 +589,7 @@ export async function generateOpenCodeText({
586
589
  directory: workingDirectory,
587
590
  agent: LLM_AGENT_NAME,
588
591
  model: { providerID, modelID },
592
+ ...(variant ? { variant } : {}),
589
593
  ...(flattened.system ? { system: flattened.system } : {}),
590
594
  tools,
591
595
  parts: [
@@ -113,6 +113,29 @@ describe('detectSessionlessGenerate', () => {
113
113
  })
114
114
 
115
115
  describe('generateOpenCodeText', () => {
116
+ it.each(['high', undefined])('passes variant %s to the upstream prompt and returns only text parts', async (variant) => {
117
+ const client = throwawayClient({
118
+ status: async () => ({ data: {} }),
119
+ messages: async () => ({ data: [{
120
+ ...completedAssistant('public reply'),
121
+ parts: [{ type: 'reasoning', text: 'private reasoning' }, { type: 'text', text: 'public reply' }],
122
+ }] }),
123
+ });
124
+ client.session.promptAsync = vi.fn(async () => ({ response: { status: 204 } }));
125
+ const result = await throwawayGenerate(client, { variant });
126
+ expect(client.session.promptAsync.mock.calls[0][0].variant).toBe(variant);
127
+ expect(result.text).toBe('public reply');
128
+ });
129
+
130
+ it('passes variant to sessionless generation', async () => {
131
+ const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ text: 'reply' })));
132
+ await throwawayGenerate({}, {
133
+ variant: 'high', fetchImpl,
134
+ detect: async () => ({ available: true, mode: 'http', url: 'http://localhost:1/generate' }),
135
+ });
136
+ expect(JSON.parse(fetchImpl.mock.calls[0][1].body).variant).toBe('high');
137
+ });
138
+
116
139
  it('uses promptAsync with model+parts, waits for idle messages, and never calls v2 session.prompt', async () => {
117
140
  const create = vi.fn(async () => ({ data: { id: 'ses_tmp' } }))
118
141
  const update = vi.fn(async () => ({ data: { id: 'ses_tmp' } }))