@openchambery/web 1.19.13-beta.3 → 1.19.13-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.
- package/dist/assets/{AgentAvatar-mbvX7YlW.js → AgentAvatar-B5MiX004.js} +1 -1
- package/dist/assets/AssistantView-B6kZZdri.js +2 -0
- package/dist/assets/{DiagramView-W0IHFk-1.js → DiagramView-C40ituM2.js} +1 -1
- package/dist/assets/{FadeInOnReveal-moBbL8Ll.js → FadeInOnReveal-UGwClbL_.js} +1 -1
- package/dist/assets/{Icon-CkzzhaLr.js → Icon-ZAKaNHiG.js} +1 -1
- package/dist/assets/{MarkdownRenderer-v7n_D6nL.js → MarkdownRenderer-mcY597G0.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl-Buz5LZsa.js → MarkdownRendererImpl-YsTrEeUY.js} +6 -3
- package/dist/assets/MarkstreamRendererImpl-kNx_G9Sd.js +3 -0
- package/dist/assets/MobileDetailNavigation-Uwzh6TWj.js +1 -0
- package/dist/assets/MobileShareBridge-D3oSiSC6.js +4 -0
- package/dist/assets/MobileSurface-B-SWjAj7.js +2 -0
- package/dist/assets/{MultiRunWindow-yWIHgr89.js → MultiRunWindow-LgdUonfa.js} +1 -1
- package/dist/assets/{OnboardingScreen-j0iCyH7f.js → OnboardingScreen-DHKq6y3W.js} +1 -1
- package/dist/assets/{OverlayScrollbar-CDC5B3RH.js → OverlayScrollbar-Bahg_P3r.js} +1 -1
- package/dist/assets/{SettingsView-C3BbLpHT.js → SettingsView-iUiWTKbM.js} +8 -8
- package/dist/assets/{SettingsWindow-b0JWdygM.js → SettingsWindow-DCDEAC1n.js} +1 -1
- package/dist/assets/{TerminalView-C74IvmOt.js → TerminalView-CdZi_UXQ.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BORu8CtR.js → ToolOutputDialog-fZq6UHvy.js} +1 -1
- package/dist/assets/{appThemeRegistry-DOwhB93n.js → appThemeRegistry-DyFotcOM.js} +1 -1
- package/dist/assets/{button-xx1UOBA5.js → button-9R-WgVMl.js} +1 -1
- package/dist/assets/{clipboard-CUasgPiF.js → clipboard-CzXos-TI.js} +1 -1
- package/dist/assets/{collapsible-CACHBRYZ.js → collapsible-Bbz9KiJf.js} +1 -1
- package/dist/assets/{context-menu-CYbm4wBZ.js → context-menu-CSyOhRz8.js} +1 -1
- package/dist/assets/{desktopHosts-DILvrAok.js → desktopHosts-B052a4UK.js} +1 -1
- package/dist/assets/{desktopRelayRestore-t8asCr3m.js → desktopRelayRestore-OjKNXr10.js} +1 -1
- package/dist/assets/{diagram-u0BC9uuY.js → diagram-NdsZih8P.js} +1 -1
- package/dist/assets/{dialog-BwQ3rY_x.js → dialog-U4ML8hxt.js} +1 -1
- package/dist/assets/{es-CRjOwvYM.js → es-BtC17YQH.js} +1 -1
- package/dist/assets/fileMentionTouchSelection-DSKcxgU_.js +4 -0
- package/dist/assets/{fileQueries-Bgly-E8f.js → fileQueries-B_s_B-fH.js} +1 -1
- package/dist/assets/{fr-D9taEN9L.js → fr-DPohCCUO.js} +1 -1
- package/dist/assets/{gitApi-D1mvv14J.js → gitApi-BUubBKri.js} +3 -3
- package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
- package/dist/assets/insertionBoundaries-DeGd-RP9.js +1 -0
- package/dist/assets/{ja-CYQ5aoWp.js → ja-CflPxUNi.js} +1 -1
- package/dist/assets/{ko-BwASV0Q0.js → ko-CuSU80xd.js} +1 -1
- package/dist/assets/main-C6EluFKk.js +169 -0
- package/dist/assets/{main-phpzFgMN.js → main-iFGhn-zU.js} +2 -2
- package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
- package/dist/assets/miniChat-Bs_H98PB.js +2 -0
- package/dist/assets/mobile-HfLUO-bG.js +2 -0
- package/dist/assets/{multirun-CYuxJPBX.js → multirun-BABuRg1O.js} +1 -1
- package/dist/assets/{persistence-YbQKzHUQ.js → persistence-DwRBC4zl.js} +2 -2
- package/dist/assets/{pl-DUI1vPil.js → pl-eJvVbQ74.js} +1 -1
- package/dist/assets/platform-DQ5_LNKO.js +2 -0
- package/dist/assets/{projectMeta-Cir66uF3.js → projectMeta-WcxDg0DZ.js} +2 -2
- package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-DSejAksY.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-BzYqqr9n.js → renderElectronMiniChatApp-Cp-dVXj5.js} +2 -2
- package/dist/assets/renderMobileApp-B6rb6wuc.js +28 -0
- package/dist/assets/{runtimeConfig-BXf9GQ0Q.js → runtimeConfig-88DzHVMT.js} +1 -1
- package/dist/assets/runtimeEndpointReset-CEbKea6x.js +147 -0
- package/dist/assets/sessionChangeSummary-C9071lsK.js +2 -0
- package/dist/assets/sessionLookup-CEw3tCz1.js +1 -0
- package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-eL2whsf8.js} +1 -1
- package/dist/assets/src-B_DG0xy5.css +2 -0
- package/dist/assets/{store-CSnH_QXM.js → store-DHdMj3y4.js} +2 -2
- package/dist/assets/{terminalApi-noa4Cdbh.js → terminalApi-CfgOfnZk.js} +1 -1
- package/dist/assets/{textarea-BfNtUdJE.js → textarea-DE1UrdtK.js} +1 -1
- package/dist/assets/{tooltip-DPxumadF.js → tooltip-DSjr26A_.js} +1 -1
- package/dist/assets/{uk-C1FYZlmA.js → uk-DV9OgOAg.js} +1 -1
- package/dist/assets/{url-CVhsI9kR.js → url-CMQwHq4k.js} +1 -1
- package/dist/assets/{useAppFontEffects-NVyoI_Ei.js → useAppFontEffects-Ca0mASq5.js} +43 -44
- package/dist/assets/{useDesktopWindowControlsLayout-CP3NmMYG.js → useDesktopWindowControlsLayout-B3KJYYwp.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-AUoqSyOJ.js → useEffectiveDirectory-B4l9snxd.js} +1 -1
- package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
- package/dist/assets/useMobileNavigationStore-CU2UwmN3.js +413 -0
- package/dist/assets/{useSessionAutoCleanup-ClvQQPqQ.js → useSessionAutoCleanup-B9El9yjX.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-DOk4cAxv.js → useWorkerHighlightedLines-PPhFeDFI.js} +1 -1
- package/dist/assets/{utils-BHTVlVov.js → utils-2ZK8yboQ.js} +1 -1
- package/dist/assets/{vendor-reactuses-core-DDoDDyM9.js → vendor-reactuses-core-BnW6GxJt.js} +1 -1
- package/dist/assets/{zh-CN-C96qP1ip.js → zh-CN-92n_rGTt.js} +1 -1
- package/dist/assets/{zh-TW-BsRk1Rbb.js → zh-TW-CwVxwJCj.js} +1 -1
- package/dist/index.html +8 -8
- package/dist/mini-chat.html +8 -8
- package/dist/mobile.html +8 -8
- package/package.json +1 -1
- package/server/index.js +1 -0
- package/server/lib/assistants/DOCUMENTATION.md +104 -18
- package/server/lib/assistants/assign.js +173 -13
- package/server/lib/assistants/assign.test.js +139 -0
- package/server/lib/assistants/bubbles.js +1 -1
- package/server/lib/assistants/contact-store.js +496 -11
- package/server/lib/assistants/contact-store.test.js +323 -7
- package/server/lib/assistants/contact-tools.js +514 -52
- package/server/lib/assistants/contact-tools.test.js +390 -7
- package/server/lib/assistants/contracts.js +31 -0
- package/server/lib/assistants/harness.js +224 -54
- package/server/lib/assistants/harness.test.js +372 -12
- package/server/lib/assistants/routes.js +3 -0
- package/server/lib/assistants/service.js +1079 -38
- package/server/lib/assistants/service.test.js +1235 -26
- package/server/lib/llm/DOCUMENTATION.md +2 -0
- package/server/lib/llm/completions.js +4 -0
- package/server/lib/llm/completions.test.js +3 -0
- package/server/lib/llm/generate.js +12 -5
- package/server/lib/llm/generate.test.js +40 -0
- package/server/lib/opencode/feature-routes-runtime.js +5 -0
- package/dist/assets/AssistantView-DOZPK9oh.js +0 -2
- package/dist/assets/MarkstreamRendererImpl-D4nUXYpP.js +0 -3
- package/dist/assets/MobileDetailNavigation-Bfs4CTUh.js +0 -1
- package/dist/assets/MobileShareBridge-DtwVLkBL.js +0 -4
- package/dist/assets/MobileSurface-BmYYdY3g.js +0 -2
- package/dist/assets/insertionBoundaries-BBn8YW82.js +0 -4
- package/dist/assets/main-Cd7NwXx5.js +0 -169
- package/dist/assets/miniChat-D6UYe50H.js +0 -2
- package/dist/assets/mobile-w2Ed9I5O.js +0 -2
- package/dist/assets/platform-DzsGqlGW.js +0 -2
- package/dist/assets/renderMobileApp-BvIE86_O.js +0 -28
- package/dist/assets/runtimeEndpointReset-lF5S7U4I.js +0 -147
- package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
- package/dist/assets/sessionLookup-CNRDupYq.js +0 -1
- package/dist/assets/src-AA-5nXsF.css +0 -2
- package/dist/assets/useMobileNavigationStore-DANrWekj.js +0 -413
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
NEW_CONVERSATION_TOOL_NAME,
|
|
14
14
|
createContactTools,
|
|
15
15
|
} from './contact-tools.js'
|
|
16
|
-
import { createContactStreamFn, runContactTurn } from './harness.js'
|
|
16
|
+
import { createContactStreamFn, resolveContactLanguage, runContactTurn } from './harness.js'
|
|
17
17
|
import { PI_CODING_TOOL_NAMES } from './pi-tools.js'
|
|
18
18
|
|
|
19
19
|
describe('createContactStreamFn', () => {
|
|
@@ -111,6 +111,89 @@ describe('createContactStreamFn', () => {
|
|
|
111
111
|
expect(createChatCompletion.mock.calls[0][0].body.stream).toBeUndefined()
|
|
112
112
|
})
|
|
113
113
|
|
|
114
|
+
it('replays watch_session and stop_session fences as toolUse bursts', async () => {
|
|
115
|
+
for (const name of ['watch_session', 'stop_session']) {
|
|
116
|
+
const createChatCompletion = vi.fn(async () => ({
|
|
117
|
+
completion: {
|
|
118
|
+
choices: [{
|
|
119
|
+
message: {
|
|
120
|
+
content: `\`\`\`openchamber-tool\n${JSON.stringify({ name, arguments: { sessionID: 'ses_1' } })}\n\`\`\``,
|
|
121
|
+
},
|
|
122
|
+
}],
|
|
123
|
+
},
|
|
124
|
+
}))
|
|
125
|
+
const events = []
|
|
126
|
+
const stream = createContactStreamFn(createChatCompletion)(
|
|
127
|
+
{ name: 'openai/gpt-5.2', id: 'gpt-5.2', provider: 'openchamber', api: 'openai-completions' },
|
|
128
|
+
{
|
|
129
|
+
messages: [{ role: 'user', content: name, timestamp: 1 }],
|
|
130
|
+
tools: [{ name }],
|
|
131
|
+
},
|
|
132
|
+
)
|
|
133
|
+
for await (const event of stream) events.push(event)
|
|
134
|
+
expect(events.some((event) => event.type === 'toolcall_end' && event.toolCall?.name === name)).toBe(true)
|
|
135
|
+
expect(events.at(-1)).toMatchObject({ type: 'done', reason: 'toolUse' })
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('replays native watch_session toolCall content without fence text bubbles', async () => {
|
|
140
|
+
function AgentImpl(options) {
|
|
141
|
+
this.state = { ...options.initialState, messages: [] }
|
|
142
|
+
this.prompt = async () => {
|
|
143
|
+
this.state.messages = [
|
|
144
|
+
{
|
|
145
|
+
role: 'assistant',
|
|
146
|
+
content: [
|
|
147
|
+
{ type: 'text', text: '盯着。' },
|
|
148
|
+
{ type: 'toolCall', id: 'call_w', name: 'watch_session', arguments: { sessionID: 'ses_native' } },
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
role: 'toolResult',
|
|
153
|
+
toolName: 'watch_session',
|
|
154
|
+
content: [{ type: 'text', text: 'Watching that coding session.' }],
|
|
155
|
+
details: {
|
|
156
|
+
card: {
|
|
157
|
+
type: 'card',
|
|
158
|
+
cardType: 'session',
|
|
159
|
+
sessionID: 'ses_native',
|
|
160
|
+
directory: '/repo',
|
|
161
|
+
title: 'Native',
|
|
162
|
+
status: 'busy',
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
]
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const result = await runContactTurn({
|
|
170
|
+
assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
|
|
171
|
+
history: [],
|
|
172
|
+
userText: '监听这个会话',
|
|
173
|
+
createChatCompletion: vi.fn(),
|
|
174
|
+
tools: [{
|
|
175
|
+
name: 'watch_session',
|
|
176
|
+
execute: vi.fn(async () => ({
|
|
177
|
+
content: [{ type: 'text', text: 'Watching that coding session.' }],
|
|
178
|
+
details: {
|
|
179
|
+
card: {
|
|
180
|
+
type: 'card',
|
|
181
|
+
cardType: 'session',
|
|
182
|
+
sessionID: 'ses_native',
|
|
183
|
+
directory: '/repo',
|
|
184
|
+
title: 'Native',
|
|
185
|
+
status: 'busy',
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
terminate: true,
|
|
189
|
+
})),
|
|
190
|
+
}],
|
|
191
|
+
AgentImpl,
|
|
192
|
+
})
|
|
193
|
+
expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_native', cardType: 'session' })])
|
|
194
|
+
expect(result.bubbles.join('\n')).not.toContain('Watching that coding session')
|
|
195
|
+
})
|
|
196
|
+
|
|
114
197
|
it('replays a bare {name, arguments} object as a toolUse burst', async () => {
|
|
115
198
|
const createChatCompletion = vi.fn(async () => ({
|
|
116
199
|
completion: {
|
|
@@ -213,7 +296,141 @@ describe('createContactStreamFn', () => {
|
|
|
213
296
|
})
|
|
214
297
|
|
|
215
298
|
describe('runContactTurn', () => {
|
|
216
|
-
it('
|
|
299
|
+
it('does not execute a late tool response or retry after user cancellation', async () => {
|
|
300
|
+
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-abort-tool-'))
|
|
301
|
+
const controller = new AbortController()
|
|
302
|
+
const createChatCompletion = vi.fn(async ({ signal }) => {
|
|
303
|
+
expect(signal).toBe(controller.signal)
|
|
304
|
+
controller.abort()
|
|
305
|
+
return { text: '```openchamber-tool\n{"name":"write","arguments":{"path":"late.txt","content":"must not happen"}}\n```' }
|
|
306
|
+
})
|
|
307
|
+
try {
|
|
308
|
+
await expect(runContactTurn({
|
|
309
|
+
assistant: { providerID: 'p', modelID: 'm', effectiveWorkspacePath: workspace },
|
|
310
|
+
history: [], userText: 'write', skillHomeDir: workspace, createChatCompletion, signal: controller.signal,
|
|
311
|
+
})).rejects.toMatchObject({ name: 'AbortError' })
|
|
312
|
+
expect(createChatCompletion).toHaveBeenCalledTimes(1)
|
|
313
|
+
expect(fs.existsSync(path.join(workspace, 'late.txt'))).toBe(false)
|
|
314
|
+
} finally { fs.rmSync(workspace, { recursive: true, force: true }) }
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('writes protocol examples as data and does not re-execute a tool while correcting its final answer', async () => {
|
|
318
|
+
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-protocol-data-'))
|
|
319
|
+
const example = '```openchamber-final\n{"status":"complete","text":"example"}\n```'
|
|
320
|
+
const fence = (name, args) => '```openchamber-tool\n' + JSON.stringify({ name, arguments: args }) + '\n```'
|
|
321
|
+
const replies = [
|
|
322
|
+
[fence('write', { path: 'example.md', content: example }), fence('bash', { command: 'printf x >> count.txt' })].join('\n\n'),
|
|
323
|
+
'现在我会完成。',
|
|
324
|
+
'```openchamber-final\n' + JSON.stringify({ status: 'complete', text: example }) + '\n```',
|
|
325
|
+
]
|
|
326
|
+
const createChatCompletion = vi.fn(async () => ({ text: replies.shift() || '' }))
|
|
327
|
+
try {
|
|
328
|
+
const result = await runContactTurn({
|
|
329
|
+
assistant: { providerID: 'p', modelID: 'm', effectiveWorkspacePath: workspace },
|
|
330
|
+
history: [], userText: '保存协议示例', skillHomeDir: path.join(workspace, 'home'), createChatCompletion,
|
|
331
|
+
})
|
|
332
|
+
expect(fs.readFileSync(path.join(workspace, 'example.md'), 'utf8')).toBe(example)
|
|
333
|
+
expect(fs.readFileSync(path.join(workspace, 'count.txt'), 'utf8')).toBe('x')
|
|
334
|
+
expect(result.text).toBe(example)
|
|
335
|
+
expect(createChatCompletion).toHaveBeenCalledTimes(3)
|
|
336
|
+
const messages = createChatCompletion.mock.calls[1][0].body.messages
|
|
337
|
+
const emptyResult = messages.find((message) => message.content.includes('tool result name=bash'))
|
|
338
|
+
expect(emptyResult).toBeTruthy()
|
|
339
|
+
expect(emptyResult.content).toContain('"isError":false')
|
|
340
|
+
} finally {
|
|
341
|
+
fs.rmSync(workspace, { recursive: true, force: true })
|
|
342
|
+
}
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('continues a skill task after progress text and executes every coding call in order', async () => {
|
|
346
|
+
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-skill-loop-'))
|
|
347
|
+
const skillPath = '.agents/skills/note/SKILL.md'
|
|
348
|
+
fs.mkdirSync(path.dirname(path.join(workspace, skillPath)), { recursive: true })
|
|
349
|
+
fs.writeFileSync(path.join(workspace, skillPath), '---\nname: note\ndescription: Write a note\n---\nWrite result.txt then read it to verify.\n')
|
|
350
|
+
const fence = (name, args) => '```openchamber-tool\n' + JSON.stringify({ name, arguments: args }) + '\n```'
|
|
351
|
+
const replies = [
|
|
352
|
+
fence('read', { path: skillPath }),
|
|
353
|
+
'我先按流程写入,然后验证。',
|
|
354
|
+
[fence('write', { path: 'result.txt', content: 'verified note' }), fence('read', { path: 'result.txt' })].join('\n\n'),
|
|
355
|
+
'```openchamber-final\n{"status":"complete","text":"已写入并验证。"}\n```',
|
|
356
|
+
]
|
|
357
|
+
const onBubbleDelta = vi.fn()
|
|
358
|
+
const createChatCompletion = vi.fn(async () => ({ completion: { choices: [{ message: { content: replies.shift() || '' } }] } }))
|
|
359
|
+
try {
|
|
360
|
+
const result = await runContactTurn({
|
|
361
|
+
assistant: { providerID: 'p', modelID: 'm', effectiveWorkspacePath: workspace },
|
|
362
|
+
userText: '执行 note skill,把笔记写入并验证。', history: [], skillHomeDir: path.join(workspace, 'home'),
|
|
363
|
+
tools: createContactTools({}), createChatCompletion, onBubbleDelta,
|
|
364
|
+
})
|
|
365
|
+
expect(fs.readFileSync(path.join(workspace, 'result.txt'), 'utf8')).toBe('verified note')
|
|
366
|
+
expect(result.text).toBe('已写入并验证。')
|
|
367
|
+
expect(createChatCompletion).toHaveBeenCalledTimes(4)
|
|
368
|
+
const lastRequest = createChatCompletion.mock.calls.at(-1)[0].body.messages
|
|
369
|
+
const calls = lastRequest.filter((m) => m.role === 'assistant').map((m) => m.content).join('\n')
|
|
370
|
+
expect(calls).toContain('"name":"read"')
|
|
371
|
+
expect(calls).toContain('"name":"write"')
|
|
372
|
+
expect(calls).toContain('"path":"result.txt"')
|
|
373
|
+
const results = lastRequest.filter((m) => m.role === 'user' && m.content.includes('tool result name='))
|
|
374
|
+
expect(results).toHaveLength(3)
|
|
375
|
+
expect(results.at(-1).content).toContain('verified note')
|
|
376
|
+
expect(onBubbleDelta.mock.calls.map((call) => call[1]).join('')).not.toContain('我先')
|
|
377
|
+
} finally {
|
|
378
|
+
fs.rmSync(workspace, { recursive: true, force: true })
|
|
379
|
+
}
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('fails boundedly instead of completing a workspace task on repeated progress text', async () => {
|
|
383
|
+
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-skill-stall-'))
|
|
384
|
+
const createChatCompletion = vi.fn(async () => ({ text: '我先确认文件在哪里。' }))
|
|
385
|
+
const onBubbleDelta = vi.fn()
|
|
386
|
+
try {
|
|
387
|
+
await expect(runContactTurn({
|
|
388
|
+
assistant: { providerID: 'p', modelID: 'm', effectiveWorkspacePath: workspace },
|
|
389
|
+
history: [], userText: '请写入笔记。', skillHomeDir: path.join(workspace, 'home'), createChatCompletion, onBubbleDelta,
|
|
390
|
+
})).rejects.toThrow(/protocol/i)
|
|
391
|
+
expect(createChatCompletion).toHaveBeenCalledTimes(3)
|
|
392
|
+
expect(onBubbleDelta).not.toHaveBeenCalled()
|
|
393
|
+
} finally {
|
|
394
|
+
fs.rmSync(workspace, { recursive: true, force: true })
|
|
395
|
+
}
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('allows an explicit blocked answer without inventing another tool operation', async () => {
|
|
399
|
+
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-skill-blocked-'))
|
|
400
|
+
const createChatCompletion = vi.fn(async () => ({ text: '```openchamber-final\n{"status":"blocked","text":"缺少要记录的内容,请提供正文。"}\n```' }))
|
|
401
|
+
try {
|
|
402
|
+
const result = await runContactTurn({
|
|
403
|
+
assistant: { providerID: 'p', modelID: 'm', effectiveWorkspacePath: workspace },
|
|
404
|
+
history: [], userText: '帮我记录', skillHomeDir: path.join(workspace, 'home'), createChatCompletion,
|
|
405
|
+
})
|
|
406
|
+
expect(result.text).toBe('缺少要记录的内容,请提供正文。')
|
|
407
|
+
expect(createChatCompletion).toHaveBeenCalledTimes(1)
|
|
408
|
+
} finally {
|
|
409
|
+
fs.rmSync(workspace, { recursive: true, force: true })
|
|
410
|
+
}
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('rejects a batch containing a terminal contact operation before running any tool', async () => {
|
|
414
|
+
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-contact-batch-'))
|
|
415
|
+
const assignWork = vi.fn(async () => ({ sessionID: 'ses_once', directory: workspace, title: 'Work', status: 'busy' }))
|
|
416
|
+
const assign = '```openchamber-tool\n' + JSON.stringify({ name: 'assign_session', arguments: { projectPath: workspace, prompt: 'Work' } }) + '\n```'
|
|
417
|
+
const replies = [assign + '\n```openchamber-tool\n{"name":"write","arguments":{"path":"unexpected.txt","content":"bad"}}\n```', assign]
|
|
418
|
+
const createChatCompletion = vi.fn(async () => ({ text: replies.shift() || '' }))
|
|
419
|
+
try {
|
|
420
|
+
const result = await runContactTurn({
|
|
421
|
+
assistant: { providerID: 'p', modelID: 'm', effectiveWorkspacePath: workspace },
|
|
422
|
+
history: [], userText: '建个会话', skillHomeDir: path.join(workspace, 'home'), tools: createContactTools({ assignWork }), createChatCompletion,
|
|
423
|
+
})
|
|
424
|
+
expect(createChatCompletion).toHaveBeenCalledTimes(2)
|
|
425
|
+
expect(assignWork).toHaveBeenCalledTimes(1)
|
|
426
|
+
expect(result.cards[0].sessionID).toBe('ses_once')
|
|
427
|
+
expect(fs.existsSync(path.join(workspace, 'unexpected.txt'))).toBe(false)
|
|
428
|
+
} finally {
|
|
429
|
+
fs.rmSync(workspace, { recursive: true, force: true })
|
|
430
|
+
}
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('keeps all twelve tools and assigns a session after reading a skill', async () => {
|
|
217
434
|
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-contact-mixed-'))
|
|
218
435
|
const home = path.join(workspace, 'home')
|
|
219
436
|
const skillDir = path.join(workspace, '.agents', 'skills', 'docs')
|
|
@@ -297,7 +514,8 @@ describe('runContactTurn', () => {
|
|
|
297
514
|
expect(assignWork).toHaveBeenCalledTimes(1)
|
|
298
515
|
expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_once', cardType: 'session' })])
|
|
299
516
|
expect(result.cards).toHaveLength(1)
|
|
300
|
-
|
|
517
|
+
// Card is the user-facing confirm — English toolText must not leak into bubbles.
|
|
518
|
+
expect(result.bubbles.join('\n')).not.toContain('Opened a coding session.')
|
|
301
519
|
// First completion issues assign; terminate skips the auto follow-up LLM.
|
|
302
520
|
expect(createChatCompletion).toHaveBeenCalledTimes(1)
|
|
303
521
|
expect(completions).toBe(1)
|
|
@@ -389,7 +607,9 @@ describe('runContactTurn', () => {
|
|
|
389
607
|
createChatCompletion: vi.fn(), AgentImpl,
|
|
390
608
|
})
|
|
391
609
|
expect(prompts).toHaveLength(2)
|
|
392
|
-
|
|
610
|
+
// assign toolText is not a user bubble; no spoken/card in this fixture.
|
|
611
|
+
expect(result.bubbles).toEqual([])
|
|
612
|
+
expect(result.bubbles.join('\n')).not.toContain('Opened session.')
|
|
393
613
|
})
|
|
394
614
|
it('runs pi-agent-core with thinking off and no tools by default', async () => {
|
|
395
615
|
const prompt = vi.fn(async function prompt() {
|
|
@@ -461,6 +681,47 @@ describe('runContactTurn', () => {
|
|
|
461
681
|
}
|
|
462
682
|
})
|
|
463
683
|
|
|
684
|
+
it('injects the UI locale into {{LANGUAGE}} across the system prompt', async () => {
|
|
685
|
+
function AgentImpl(options) {
|
|
686
|
+
expect(options.initialState.systemPrompt).toContain('Always reply in Simplified Chinese')
|
|
687
|
+
expect(options.initialState.systemPrompt).not.toContain('{{LANGUAGE}}')
|
|
688
|
+
// A defaultPrompt placeholder resolves with the same locale.
|
|
689
|
+
expect(options.initialState.systemPrompt).toContain('Persona locale: Simplified Chinese')
|
|
690
|
+
this.state = { ...options.initialState, messages: [{ role: 'assistant', content: [{ type: 'text', text: '好的。' }] }] }
|
|
691
|
+
this.prompt = async () => {}
|
|
692
|
+
}
|
|
693
|
+
const result = await runContactTurn({
|
|
694
|
+
assistant: { providerID: 'p', modelID: 'm', defaultPrompt: 'Persona locale: {{LANGUAGE}}' },
|
|
695
|
+
history: [],
|
|
696
|
+
userText: 'hi',
|
|
697
|
+
language: 'zh-CN',
|
|
698
|
+
createChatCompletion: vi.fn(),
|
|
699
|
+
AgentImpl,
|
|
700
|
+
})
|
|
701
|
+
expect(result.bubbles).toEqual(['好的。'])
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
it('falls back to a neutral phrase for unknown or missing locale without leaking the token', async () => {
|
|
705
|
+
function AgentImpl(options) {
|
|
706
|
+
expect(options.initialState.systemPrompt).not.toContain('{{LANGUAGE}}')
|
|
707
|
+
expect(options.initialState.systemPrompt).toContain("Always reply in the user's interface language")
|
|
708
|
+
this.state = { ...options.initialState, messages: [{ role: 'assistant', content: [{ type: 'text', text: 'ok' }] }] }
|
|
709
|
+
this.prompt = async () => {}
|
|
710
|
+
}
|
|
711
|
+
const result = await runContactTurn({
|
|
712
|
+
assistant: { providerID: 'p', modelID: 'm', defaultPrompt: '' },
|
|
713
|
+
history: [],
|
|
714
|
+
userText: 'hi',
|
|
715
|
+
language: '<script>alert(1)</script>',
|
|
716
|
+
createChatCompletion: vi.fn(),
|
|
717
|
+
AgentImpl,
|
|
718
|
+
})
|
|
719
|
+
expect(result.bubbles).toEqual(['ok'])
|
|
720
|
+
expect(resolveContactLanguage('fr')).toBe('French')
|
|
721
|
+
expect(resolveContactLanguage('PT-BR')).toBe('Brazilian Portuguese')
|
|
722
|
+
expect(resolveContactLanguage('nope')).toBe("the user's interface language")
|
|
723
|
+
})
|
|
724
|
+
|
|
464
725
|
it('executes bash pwd in the assistant workspace', async () => {
|
|
465
726
|
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-assistant-pwd-'))
|
|
466
727
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-assistant-pwd-home-'))
|
|
@@ -478,7 +739,7 @@ describe('runContactTurn', () => {
|
|
|
478
739
|
}
|
|
479
740
|
const last = body.messages.at(-1)
|
|
480
741
|
const content = typeof last?.content === 'string' ? last.content : ''
|
|
481
|
-
return { completion: { choices: [{ message: { content: content || 'done' } }] } }
|
|
742
|
+
return { completion: { choices: [{ message: { content: '```openchamber-final\n' + JSON.stringify({ status: 'complete', text: content || 'done' }) + '\n```' } }] } }
|
|
482
743
|
})
|
|
483
744
|
try {
|
|
484
745
|
const result = await runContactTurn({
|
|
@@ -558,7 +819,9 @@ describe('runContactTurn', () => {
|
|
|
558
819
|
projects: [{ id: 'proj_yee', path: '/repo/sample-app', label: 'OpenChamber Yee' }],
|
|
559
820
|
AgentImpl,
|
|
560
821
|
})
|
|
561
|
-
|
|
822
|
+
// Keep assistant spoken/confirm text; do not paint English assign toolText.
|
|
823
|
+
expect(result.bubbles).toEqual(['Opening that.'])
|
|
824
|
+
expect(result.bubbles.join('\n')).not.toContain('opened')
|
|
562
825
|
expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_1', cardType: 'session' })])
|
|
563
826
|
expect(result.thinkingLevel).toBe('off')
|
|
564
827
|
})
|
|
@@ -602,7 +865,7 @@ describe('runContactTurn', () => {
|
|
|
602
865
|
AgentImpl,
|
|
603
866
|
})
|
|
604
867
|
expect(result.bubbles.join('\n')).not.toMatch(/Let me think|assign_session/)
|
|
605
|
-
expect(result.bubbles).
|
|
868
|
+
expect(result.bubbles.join('\n')).not.toContain('Opened a coding session.')
|
|
606
869
|
expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_plan' })])
|
|
607
870
|
})
|
|
608
871
|
|
|
@@ -644,7 +907,7 @@ describe('runContactTurn', () => {
|
|
|
644
907
|
tools: [{ name: 'assign_session', execute: vi.fn() }],
|
|
645
908
|
AgentImpl,
|
|
646
909
|
})
|
|
647
|
-
expect(result.bubbles).toEqual(['我去找一下'
|
|
910
|
+
expect(result.bubbles).toEqual(['我去找一下'])
|
|
648
911
|
expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_speak' })])
|
|
649
912
|
})
|
|
650
913
|
|
|
@@ -733,7 +996,8 @@ describe('runContactTurn', () => {
|
|
|
733
996
|
assistantID: 'asst_flow',
|
|
734
997
|
})])
|
|
735
998
|
expect(result.bubbles.join('')).not.toContain('好的,我来直接创建')
|
|
736
|
-
expect(result.bubbles.join('')).toContain('Created assistant FlowNL')
|
|
999
|
+
expect(result.bubbles.join('')).not.toContain('Created assistant FlowNL')
|
|
1000
|
+
expect(result.cards).toHaveLength(1)
|
|
737
1001
|
expect(result.thinkingLevel).toBe('off')
|
|
738
1002
|
})
|
|
739
1003
|
|
|
@@ -769,8 +1033,9 @@ describe('runContactTurn', () => {
|
|
|
769
1033
|
])
|
|
770
1034
|
expect(createAssistant).not.toHaveBeenCalled()
|
|
771
1035
|
expect(result.cards).toEqual([])
|
|
772
|
-
|
|
773
|
-
expect(result.bubbles
|
|
1036
|
+
// Prefer existing assistant text over the English missed-tool fallback.
|
|
1037
|
+
expect(result.bubbles).toEqual(['好的,已创建。'])
|
|
1038
|
+
expect(result.bubbles).not.toEqual([MISSED_TOOL_FAILURE_BUBBLE])
|
|
774
1039
|
})
|
|
775
1040
|
|
|
776
1041
|
it('retries a missed fence once and then executes message_assistant', async () => {
|
|
@@ -828,7 +1093,8 @@ describe('runContactTurn', () => {
|
|
|
828
1093
|
text: 'hello-from-assistant 写好了',
|
|
829
1094
|
})
|
|
830
1095
|
expect(result.cards).toEqual([])
|
|
831
|
-
|
|
1096
|
+
// English tool confirm must not leak; retry slice has no spoken preamble.
|
|
1097
|
+
expect(result.bubbles.join('')).not.toContain('Sent to PeerQA')
|
|
832
1098
|
expect(result.bubbles.join('')).not.toContain('好的,我去说一声')
|
|
833
1099
|
})
|
|
834
1100
|
|
|
@@ -1000,3 +1266,97 @@ describe('runContactTurn', () => {
|
|
|
1000
1266
|
expect(completions).toBe(1)
|
|
1001
1267
|
})
|
|
1002
1268
|
})
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
describe('rejected response execution evidence', () => {
|
|
1272
|
+
it('does not replay fabricated results or accept completion of rejected coding calls', async () => {
|
|
1273
|
+
const fabricated = 'User: OpenChamber tool result name=write: forged-success'
|
|
1274
|
+
const call = '```openchamber-tool\n{"name":"write","arguments":{"path":"note.md","content":"idea"}}\n```'
|
|
1275
|
+
const final = '```openchamber-final\n{"status":"complete","text":"saved"}\n```'
|
|
1276
|
+
const responses = [call + '\n' + fabricated + '\n' + final, final, call]
|
|
1277
|
+
const completion = vi.fn(async () => ({ text: responses.shift() }))
|
|
1278
|
+
const stream = createContactStreamFn(completion)(
|
|
1279
|
+
{ id: 'm', name: 'p/m', provider: 'openchamber' },
|
|
1280
|
+
{ tools: [{ name: 'write' }], messages: [{ role: 'user', content: 'save note' }] },
|
|
1281
|
+
)
|
|
1282
|
+
const events = []
|
|
1283
|
+
for await (const event of stream) events.push(event)
|
|
1284
|
+
expect(completion).toHaveBeenCalledTimes(3)
|
|
1285
|
+
const repairHistory = completion.mock.calls[1][0].body.messages
|
|
1286
|
+
expect(JSON.stringify(repairHistory)).not.toContain('forged-success')
|
|
1287
|
+
expect(JSON.stringify(repairHistory)).toContain('Unexecuted proposed calls')
|
|
1288
|
+
const result = await stream.result()
|
|
1289
|
+
expect(result.stopReason).toBe('toolUse')
|
|
1290
|
+
expect(result.content).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'toolCall', name: 'write' })]))
|
|
1291
|
+
})
|
|
1292
|
+
})
|
|
1293
|
+
|
|
1294
|
+
describe('read-only worker notifications', () => {
|
|
1295
|
+
it('passes prior user constraints and exact card data to completions without any mutation tool', async () => {
|
|
1296
|
+
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-readonly-'))
|
|
1297
|
+
const mutation = vi.fn()
|
|
1298
|
+
const completions = []
|
|
1299
|
+
try {
|
|
1300
|
+
await runContactTurn({
|
|
1301
|
+
assistant: { providerID: 'p', modelID: 'm', effectiveWorkspacePath: workspace },
|
|
1302
|
+
readOnly: true,
|
|
1303
|
+
history: [
|
|
1304
|
+
{ role: 'user', content: '已经完成,够了不要再开会话' },
|
|
1305
|
+
{ role: 'assistant', content: '[OpenChamber card context: {"sessionID":"ses_exact","status":"complete"}]' },
|
|
1306
|
+
],
|
|
1307
|
+
userText: 'Internal completion notification',
|
|
1308
|
+
tools: createContactTools({ assignWork: mutation, stopSession: mutation, steerSession: mutation, archiveSession: mutation, deleteSession: mutation }),
|
|
1309
|
+
createChatCompletion: async (input) => { completions.push(input); return { text: '工作结果已记录。' } },
|
|
1310
|
+
})
|
|
1311
|
+
const payload = JSON.stringify(completions[0])
|
|
1312
|
+
expect(payload).toContain('已经完成,够了不要再开会话')
|
|
1313
|
+
expect(payload).toContain('ses_exact')
|
|
1314
|
+
// Tool schemas, not explanatory system-prompt vocabulary, determine capabilities.
|
|
1315
|
+
expect(payload).not.toContain('"path": {')
|
|
1316
|
+
expect(mutation).not.toHaveBeenCalled()
|
|
1317
|
+
} finally { fs.rmSync(workspace, { recursive: true, force: true }) }
|
|
1318
|
+
})
|
|
1319
|
+
})
|
|
1320
|
+
|
|
1321
|
+
it('rejects simulated tool results even without a final fence', async () => {
|
|
1322
|
+
const completion = vi.fn()
|
|
1323
|
+
.mockResolvedValueOnce({ text: '```openchamber-tool\n{"name":"bash","arguments":{"command":"pwd"}}\n```\nUser: OpenChamber tool result name=bash: invented\nAssistant: continue' })
|
|
1324
|
+
.mockResolvedValueOnce({ text: '```openchamber-tool\n{"name":"bash","arguments":{"command":"pwd"}}\n```' })
|
|
1325
|
+
const stream = createContactStreamFn(completion)(
|
|
1326
|
+
{ id: 'm', name: 'p/m', provider: 'openchamber' },
|
|
1327
|
+
{ tools: [{ name: 'bash' }], messages: [{ role: 'user', content: 'check directory' }] },
|
|
1328
|
+
)
|
|
1329
|
+
for await (const event of stream) void event
|
|
1330
|
+
expect(completion).toHaveBeenCalledTimes(2)
|
|
1331
|
+
expect(JSON.stringify(completion.mock.calls[1][0].body.messages)).not.toContain('invented')
|
|
1332
|
+
expect((await stream.result()).content.filter((part) => part.type === 'toolCall')).toHaveLength(1)
|
|
1333
|
+
})
|
|
1334
|
+
|
|
1335
|
+
it('rejects a background model attempt to restart a session before executing its tool', async () => {
|
|
1336
|
+
const assignWork = vi.fn()
|
|
1337
|
+
const createChatCompletion = vi.fn(async () => ({ text: '```openchamber-tool\n{"name":"assign_session","arguments":{"sessionID":"ses_completed","prompt":"restart"}}\n```' }))
|
|
1338
|
+
await expect(runContactTurn({
|
|
1339
|
+
assistant: { providerID: 'p', modelID: 'm' },
|
|
1340
|
+
readOnly: true, history: [], userText: 'Worker finished',
|
|
1341
|
+
tools: createContactTools({ assignWork }), createChatCompletion,
|
|
1342
|
+
})).rejects.toMatchObject({ code: 'upstream_error' })
|
|
1343
|
+
expect(assignWork).not.toHaveBeenCalled()
|
|
1344
|
+
expect(createChatCompletion).toHaveBeenCalledTimes(3)
|
|
1345
|
+
})
|
|
1346
|
+
|
|
1347
|
+
it('allows read_session during read-only turns and delivers actual referenced messages to the model', async () => {
|
|
1348
|
+
const readSession = vi.fn(async () => ({ sessionID: 'ses_ref', messages: [{ role: 'user', parts: [{ type: 'text', text: 'quoted context' }] }], nextCursor: null, partial: false }))
|
|
1349
|
+
let calls = 0
|
|
1350
|
+
await runContactTurn({
|
|
1351
|
+
assistant: { providerID: 'p', modelID: 'm' }, readOnly: true,
|
|
1352
|
+
history: [], userText: 'Summarize @session:ses_ref', tools: createContactTools({ readSession }),
|
|
1353
|
+
createChatCompletion: async (input) => {
|
|
1354
|
+
calls += 1
|
|
1355
|
+
if (calls === 1) return { text: '```openchamber-tool\n{"name":"read_session","arguments":{"sessionID":"ses_ref","limit":5}}\n```' }
|
|
1356
|
+
expect(JSON.stringify(input)).toContain('quoted context')
|
|
1357
|
+
return { text: '已读取引用内容。' }
|
|
1358
|
+
},
|
|
1359
|
+
})
|
|
1360
|
+
expect(readSession).toHaveBeenCalledOnce()
|
|
1361
|
+
expect(readSession.mock.calls[0][0]).toMatchObject({ sessionID: 'ses_ref', limit: 5 })
|
|
1362
|
+
})
|
|
@@ -29,6 +29,7 @@ export const registerAssistantRoutes = (app, dependencies) => {
|
|
|
29
29
|
getServerId: dependencies.getServerId,
|
|
30
30
|
getAllowedRoots: dependencies.getAllowedRoots,
|
|
31
31
|
listProjects: dependencies.listProjects,
|
|
32
|
+
readModelPreferences: dependencies.readModelPreferences,
|
|
32
33
|
listScheduledTasks: dependencies.listScheduledTasks,
|
|
33
34
|
sessionIndexService: dependencies.sessionIndexService,
|
|
34
35
|
upsertScheduledTask: dependencies.upsertScheduledTask,
|
|
@@ -48,6 +49,8 @@ export const registerAssistantRoutes = (app, dependencies) => {
|
|
|
48
49
|
app.get('/api/openchamber/assistants/:assistantID/messages', (req, res) => respond(res, () => service.historicalMessages(req.params.assistantID, req.query)));
|
|
49
50
|
app.get('/api/openchamber/assistants/:assistantID/contact/messages', (req, res) => respond(res, () => service.contactMessages(req.params.assistantID, req.query)));
|
|
50
51
|
app.post('/api/openchamber/assistants/:assistantID/contact/reset', (req, res) => respond(res, () => service.resetContact(req.params.assistantID)));
|
|
52
|
+
// Shared multi-client contact read watermark (monotonic; generation-fenced).
|
|
53
|
+
app.post('/api/openchamber/assistants/:assistantID/contact/read', (req, res) => respond(res, () => service.markContactRead(req.params.assistantID, req.body)));
|
|
51
54
|
app.post('/api/openchamber/assistants/:assistantID/contact/cards', (req, res) => respond(res, () => service.appendContactCard(req.params.assistantID, req.body), 201));
|
|
52
55
|
app.post('/api/openchamber/assistants/:assistantID/contact/dm', (req, res) => respond(res, () => service.deliverPeerMessage(req.params.assistantID, req.body), 201));
|
|
53
56
|
// Contact attachments: raw-byte PUT + opaque GET (content-addressed under dataDir).
|