@openchambery/web 1.19.7-beta.8 → 1.19.7
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-CX0a1rKE.js +1 -0
- package/dist/assets/AssistantView-nVF0kdFh.js +2 -0
- package/dist/assets/DiagramView-B2UtQ8mw.js +1 -0
- package/dist/assets/FadeInOnReveal-CK4asqVR.js +1 -0
- package/dist/assets/Icon-BYM02QyU.js +1 -0
- package/dist/assets/MarkdownLoadingSkeleton-DWs4Jg7b.js +3 -0
- package/dist/assets/MarkdownRenderer-DT_GN1Ex.js +2 -0
- package/dist/assets/MarkdownRendererImpl-Cslh4XN_.js +7 -0
- package/dist/assets/MarkstreamRendererImpl-CsNwy1T5.css +1 -0
- package/dist/assets/MarkstreamRendererImpl-Ymm12x6J.js +1 -0
- package/dist/assets/MobileDetailNavigation-DD29ENgU.js +1 -0
- package/dist/assets/MobileShareBridge-CrTe_QND.js +4 -0
- package/dist/assets/MobileSurface-DjxchpUC.js +2 -0
- package/dist/assets/{MultiRunWindow-D_jo8cqz.js → MultiRunWindow-jG5dCEMH.js} +1 -1
- package/dist/assets/OnboardingScreen-C3-wzeSX.js +1 -0
- package/dist/assets/OverlayScrollbar-KUpvYHdv.js +2 -0
- package/dist/assets/SettingsView-UKlEewdq.js +25 -0
- package/dist/assets/{SettingsWindow-CzMoz2fi.js → SettingsWindow-DO1aAplT.js} +1 -1
- package/dist/assets/TerminalView-BG1UPLsz.js +8 -0
- package/dist/assets/ToolOutputDialog-CWr5eVTK.js +10 -0
- package/dist/assets/appThemeRegistry-BI5drVpC.js +1 -0
- package/dist/assets/{button-z0GOBtWi.js → button-C0cth9tl.js} +1 -1
- package/dist/assets/clipboard-DJ4nQEuY.js +1 -0
- package/dist/assets/{collapsible-D9zJRpeB.js → collapsible-DFT9cE80.js} +1 -1
- package/dist/assets/context-menu-CAp74NfV.js +1 -0
- package/dist/assets/desktopHosts-CWxex1c3.js +1 -0
- package/dist/assets/desktopRelayRestore-9EbBYQ9s.js +1 -0
- package/dist/assets/{diagram-CoEqmReY.js → diagram-BryzMuta.js} +1 -1
- package/dist/assets/{dialog-x2hirWBd.js → dialog-CgbV5ljg.js} +1 -1
- package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +1 -0
- package/dist/assets/{es-C8Sqjsia.js → es-BHqnSjoZ.js} +1 -1
- package/dist/assets/fileQueries-BY-9OWzp.js +1 -0
- package/dist/assets/fileReferenceActions-BJJtX_S5.js +1 -0
- package/dist/assets/{fr-p-Zwm_j2.js → fr-BfrlJY9i.js} +1 -1
- package/dist/assets/{gitApi-BE0vA4zm.js → gitApi-D9hFlE3T.js} +3 -3
- package/dist/assets/{input-BG3VWTIF.js → input-DFLpqjGR.js} +1 -1
- package/dist/assets/insertionBoundaries-D2L7D0Oe.js +4 -0
- package/dist/assets/{ja-B6REu1qP.js → ja-DP-7qt-x.js} +1 -1
- package/dist/assets/{ko-CJBNH9IZ.js → ko-E2B5vGQP.js} +1 -1
- package/dist/assets/main-BRedOs_V.js +169 -0
- package/dist/assets/main-DlXpJ0Vw.js +2 -0
- package/dist/assets/markdown-worker-BuveaH3O.js +1 -0
- package/dist/assets/markdownTheme-CzZm3riB.js +2 -0
- package/dist/assets/miniChat-CBLbFpfy.js +2 -0
- package/dist/assets/mobile-CoZsWdWk.js +2 -0
- package/dist/assets/multirun-BF-I0r2_.js +4 -0
- package/dist/assets/outsideFileGrants-DnplRxr-.js +1 -0
- package/dist/assets/path-utils-jGozTg9d.js +1 -0
- package/dist/assets/persistence-DdUac14I.js +3 -0
- package/dist/assets/{pl-B2cBI_7K.js → pl-r8X5gRM5.js} +1 -1
- package/dist/assets/platform-B4KKLnZ5.js +2 -0
- package/dist/assets/projectMeta-2t4L5_4a.js +3 -0
- package/dist/assets/{pt-BR-CrCc9hmt.js → pt-BR-DcpdXCf5.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-fl7O3iHy.js +2 -0
- package/dist/assets/renderMobileApp-CKYDwf4U.js +28 -0
- package/dist/assets/runtimeConfig-B4eb4ZiU.js +2 -0
- package/dist/assets/runtimeEndpointReset-BHuTYyzc.js +147 -0
- package/dist/assets/sessionChangeSummary-DOq8EN_H.js +1 -0
- package/dist/assets/sessionLookup-DNc0ZeR3.js +1 -0
- package/dist/assets/sortable-tabs-strip-C8_9kHqu.js +1 -0
- package/dist/assets/src-DasvfkEg.css +2 -0
- package/dist/assets/{store-CL3WP48T.js → store-BDFdnk6l.js} +2 -2
- package/dist/assets/terminalApi-947l7Ra3.js +1 -0
- package/dist/assets/textarea-Cgo_nWUU.js +1 -0
- package/dist/assets/toolHelpers-DEvVP9U0.js +2 -0
- package/dist/assets/{tooltip-CtH61mQh.js → tooltip-BifLJ8o0.js} +1 -1
- package/dist/assets/{uk-C8YKAz0x.js → uk-Di0vl9V-.js} +1 -1
- package/dist/assets/url-Pk6tQ1EZ.js +1 -0
- package/dist/assets/{ChatContainer-D3onaUkL.js → useAppFontEffects-Dle8xs5h.js} +44 -39
- package/dist/assets/useAssistantUIStore-me6s63b2.js +1 -0
- package/dist/assets/useDesktopWindowControlsLayout-_QAwKcQn.js +1 -0
- package/dist/assets/useEffectiveDirectory-BSyuAgz6.js +1 -0
- package/dist/assets/useFontPreferences-BCOBlaAd.js +1 -0
- package/dist/assets/useMobileNavigationStore-f8xHJk7C.js +413 -0
- package/dist/assets/useSessionAutoCleanup-BHiXaW0J.js +1 -0
- package/dist/assets/useUIStore-CuaA8-Hp.js +1 -0
- package/dist/assets/useWorkerHighlightedLines-DuvXTSm_.js +7 -0
- package/dist/assets/utils-BiK3tBGM.js +1 -0
- package/dist/assets/{vendor-markstream-react-CwvbEg9R.js → vendor-markstream-react-C3K8YH0d.js} +4 -4
- package/dist/assets/{zh-CN-CCYwUnmB.js → zh-CN-coFCfeQE.js} +1 -1
- package/dist/assets/{zh-TW-D8Y6py08.js → zh-TW-oDRZ1-yb.js} +1 -1
- package/dist/index.html +9 -11
- package/dist/mini-chat.html +9 -11
- package/dist/mobile.html +15 -17
- package/package.json +2 -1
- package/server/index.js +2 -0
- package/server/lib/assistant-delivery-parts.js +3 -1
- package/server/lib/assistant-delivery-parts.test.js +11 -0
- package/server/lib/assistants/DOCUMENTATION.md +76 -3
- package/server/lib/assistants/assign.js +225 -0
- package/server/lib/assistants/assign.test.js +118 -0
- package/server/lib/assistants/bubbles.js +45 -0
- package/server/lib/assistants/bubbles.test.js +36 -0
- package/server/lib/assistants/cards.js +148 -0
- package/server/lib/assistants/cards.test.js +116 -0
- package/server/lib/assistants/contact-store.js +284 -0
- package/server/lib/assistants/contact-store.test.js +131 -0
- package/server/lib/assistants/contact-tools.js +761 -0
- package/server/lib/assistants/contact-tools.test.js +328 -0
- package/server/lib/assistants/contracts.js +20 -1
- package/server/lib/assistants/harness.js +551 -0
- package/server/lib/assistants/harness.test.js +607 -0
- package/server/lib/assistants/routes.js +44 -4
- package/server/lib/assistants/service.js +930 -17
- package/server/lib/assistants/service.test.js +1163 -45
- package/server/lib/event-stream/DOCUMENTATION.md +12 -1
- package/server/lib/event-stream/directory-ws-bridge.js +11 -2
- package/server/lib/event-stream/global-ws-bridge.js +19 -3
- package/server/lib/event-stream/protocol.js +11 -1
- package/server/lib/event-stream/reasoning-projection.js +425 -0
- package/server/lib/event-stream/reasoning-projection.test.js +300 -0
- package/server/lib/event-stream/runtime.js +10 -0
- package/server/lib/event-stream/runtime.test.js +53 -0
- package/server/lib/llm/DOCUMENTATION.md +84 -0
- package/server/lib/llm/catalog.js +100 -0
- package/server/lib/llm/catalog.test.js +78 -0
- package/server/lib/llm/completions.js +151 -0
- package/server/lib/llm/completions.test.js +127 -0
- package/server/lib/llm/generate.js +570 -0
- package/server/lib/llm/generate.test.js +459 -0
- package/server/lib/llm/routes.js +47 -0
- package/server/lib/llm/temp-directory.js +37 -0
- package/server/lib/notifications/APNS.md +19 -13
- package/server/lib/notifications/DOCUMENTATION.md +4 -3
- package/server/lib/notifications/runtime.js +153 -38
- package/server/lib/notifications/runtime.test.js +122 -0
- package/server/lib/opencode/DOCUMENTATION.md +2 -1
- package/server/lib/opencode/feature-routes-runtime.js +30 -0
- package/server/lib/opencode/feature-routes-runtime.test.js +20 -0
- package/server/lib/opencode/proxy.js +103 -9
- package/server/lib/opencode/proxy.test.js +177 -1
- package/server/lib/opencode/settings-runtime.js +2 -2
- package/server/lib/relay/reasoning-projection.e2e.test.ts +529 -0
- package/server/lib/session-goal/DOCUMENTATION.md +8 -1
- package/server/lib/session-goal/runtime.js +20 -0
- package/server/lib/session-index/DOCUMENTATION.md +3 -1
- package/server/lib/session-index/service.js +10 -4
- package/server/lib/session-index/service.test.js +37 -0
- package/server/lib/session-title/runtime.js +1 -0
- package/server/lib/session-title/runtime.test.js +11 -0
- package/server/lib/session-turn-pages/DOCUMENTATION.md +2 -1
- package/server/lib/session-turn-pages/routes.js +22 -3
- package/server/lib/session-turn-pages/routes.test.js +43 -0
- package/server/lib/transcript-cache/DOCUMENTATION.md +2 -2
- package/server/lib/transcript-cache/routes.js +9 -2
- package/server/lib/transcript-cache/routes.test.js +32 -0
- package/dist/assets/AgentAvatar-Di8F4QJh.js +0 -1
- package/dist/assets/AssistantView-DYdXsGDh.js +0 -1
- package/dist/assets/ContextPanel-Cj6RVUAe.js +0 -285
- package/dist/assets/DiagramView-BoRTG67Z.js +0 -1
- package/dist/assets/FadeInOnReveal-CV2s6YN8.js +0 -1
- package/dist/assets/Icon-CLpn1mIz.js +0 -1
- package/dist/assets/MarkdownRendererImpl-DwUKY9k4.js +0 -7
- package/dist/assets/MarkstreamRendererImpl-BRvprELo.js +0 -1
- package/dist/assets/MarkstreamRendererImpl-Ca7RATbs.css +0 -1
- package/dist/assets/MobileDetailNavigation-C0Z8beRL.js +0 -1
- package/dist/assets/MobileShareBridge-DTvmm9vz.js +0 -4
- package/dist/assets/MobileSurface-CfrI-5uf.js +0 -2
- package/dist/assets/OnboardingScreen-D6rtE7rj.js +0 -1
- package/dist/assets/OverlayScrollbar-DlNZho3q.js +0 -2
- package/dist/assets/SettingsView-BOT158gD.js +0 -25
- package/dist/assets/TerminalView-sf_LfP6Z.js +0 -8
- package/dist/assets/ToolOutputDialog-BI5vwuaT.js +0 -10
- package/dist/assets/appThemeRegistry-G_zwgqAH.js +0 -2
- package/dist/assets/clipboard-D35XSwID.js +0 -1
- package/dist/assets/desktop-CfLlmafK.js +0 -1
- package/dist/assets/desktopHosts-C1Ns3DUu.js +0 -1
- package/dist/assets/desktopRelayRestore-8pvI_dSc.js +0 -1
- package/dist/assets/fileQueries-DGHZqZHc.js +0 -1
- package/dist/assets/insertionBoundaries-CJksyugt.js +0 -4
- package/dist/assets/main-BTeHOAU_.js +0 -29
- package/dist/assets/main-BUBx-y1E.js +0 -2
- package/dist/assets/markdown-worker-y3ewaqAK.js +0 -4
- package/dist/assets/markdownTheme-SPe4jOwX.js +0 -2
- package/dist/assets/miniChat-Btnf42TH.js +0 -2
- package/dist/assets/mobile-CAjwV6ly.js +0 -2
- package/dist/assets/multirun-Rpz1_-fz.js +0 -4
- package/dist/assets/persistence-w-AiX2i4.js +0 -3
- package/dist/assets/platform-4eCmsdol.js +0 -2
- package/dist/assets/projectMeta-CvUuy-j5.js +0 -3
- package/dist/assets/radio-D_a28OlM.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-DCzBQk2w.js +0 -2
- package/dist/assets/renderMobileApp-U5sclQRp.js +0 -28
- package/dist/assets/runtime-switch-BkpTTbEi.js +0 -1
- package/dist/assets/runtimeConfig-6inIl99b.js +0 -2
- package/dist/assets/runtimeEndpointReset-B_rUFDT5.js +0 -4
- package/dist/assets/shikiHighlight-CMpSAuTl.js +0 -1
- package/dist/assets/sortable-tabs-strip-CNDgyX0B.js +0 -1
- package/dist/assets/src-DZt1nWf5.css +0 -2
- package/dist/assets/terminalApi-CGtOtc8I.js +0 -1
- package/dist/assets/url-KeihbE0d.js +0 -1
- package/dist/assets/useAppFontEffects-3xHRVJcf.js +0 -7
- package/dist/assets/useAssistantUIStore-I0VWrB5V.js +0 -1
- package/dist/assets/useDesktopWindowControlsLayout-QJlddPRn.js +0 -1
- package/dist/assets/useEffectiveDirectory-Bcj1wP95.js +0 -1
- package/dist/assets/useFontPreferences-BLZ3y6mN.js +0 -1
- package/dist/assets/useMobileNavigationStore-CD0GRjaV.js +0 -412
- package/dist/assets/useSessionAutoCleanup-CN_6ABvx.js +0 -1
- package/dist/assets/useUIStore-CNLmCoPU.js +0 -1
- package/dist/assets/useWorkerHighlightedLines-DX9znDoW.js +0 -7
- package/dist/assets/utils-CTL16t2f.js +0 -1
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
CREATE_ASSISTANT_TOOL_NAME,
|
|
4
|
+
MESSAGE_ASSISTANT_TOOL_NAME,
|
|
5
|
+
MISSED_FENCE_RETRY_USER_TEXT,
|
|
6
|
+
MISSED_TOOL_FAILURE_BUBBLE,
|
|
7
|
+
NEW_CONVERSATION_CONFIRM_BUBBLE,
|
|
8
|
+
NEW_CONVERSATION_TOOL_NAME,
|
|
9
|
+
createContactTools,
|
|
10
|
+
} from './contact-tools.js'
|
|
11
|
+
import { createContactStreamFn, runContactTurn } from './harness.js'
|
|
12
|
+
|
|
13
|
+
describe('createContactStreamFn', () => {
|
|
14
|
+
it('forwards completion text as pi-ai text events', async () => {
|
|
15
|
+
const createChatCompletion = vi.fn(async () => ({
|
|
16
|
+
completion: { choices: [{ message: { content: 'Hi' } }] },
|
|
17
|
+
}))
|
|
18
|
+
const streamFn = createContactStreamFn(createChatCompletion)
|
|
19
|
+
const events = []
|
|
20
|
+
const stream = streamFn(
|
|
21
|
+
{ name: 'openai/gpt-5.2', id: 'gpt-5.2', provider: 'openchamber', api: 'openai-completions' },
|
|
22
|
+
{ messages: [{ role: 'user', content: 'hello', timestamp: 1 }] },
|
|
23
|
+
)
|
|
24
|
+
for await (const event of stream) events.push(event)
|
|
25
|
+
expect(createChatCompletion).toHaveBeenCalledWith(expect.objectContaining({
|
|
26
|
+
body: expect.objectContaining({
|
|
27
|
+
model: expect.any(String),
|
|
28
|
+
messages: expect.any(Array),
|
|
29
|
+
}),
|
|
30
|
+
}))
|
|
31
|
+
expect(createChatCompletion.mock.calls[0][0].body.stream).toBeUndefined()
|
|
32
|
+
expect(events.some((event) => event.type === 'text_delta' && event.delta === 'Hi')).toBe(true)
|
|
33
|
+
expect(events.at(-1).type).toBe('done')
|
|
34
|
+
expect(typeof stream.result).toBe('function')
|
|
35
|
+
await expect(stream.result()).resolves.toMatchObject({ stopReason: 'stop' })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('forwards contact file parts on the last user completion message', async () => {
|
|
39
|
+
const createChatCompletion = vi.fn(async () => ({
|
|
40
|
+
completion: { choices: [{ message: { content: 'saw the image' } }] },
|
|
41
|
+
}))
|
|
42
|
+
const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
|
|
43
|
+
const file = { type: 'file', mime: 'text/plain', url: 'data:text/plain;base64,eA==', filename: 'notes.txt' }
|
|
44
|
+
const streamFn = createContactStreamFn(createChatCompletion, { pendingFileParts: [image, file] })
|
|
45
|
+
const stream = streamFn(
|
|
46
|
+
{ name: 'openai/gpt-5.2', id: 'gpt-5.2', provider: 'openchamber', api: 'openai-completions' },
|
|
47
|
+
{ messages: [{ role: 'user', content: 'look', timestamp: 1 }] },
|
|
48
|
+
)
|
|
49
|
+
for await (const event of stream) void event
|
|
50
|
+
expect(createChatCompletion.mock.calls[0][0].body.messages.at(-1)).toEqual({
|
|
51
|
+
role: 'user',
|
|
52
|
+
content: 'look',
|
|
53
|
+
parts: [image, file],
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('keeps thinking off and never installs bash/edit/read/write when sending attachments', async () => {
|
|
58
|
+
const prompt = vi.fn(async function prompt() {
|
|
59
|
+
this.state.messages = [{
|
|
60
|
+
role: 'assistant',
|
|
61
|
+
content: [{ type: 'text', text: 'Got it.' }],
|
|
62
|
+
}]
|
|
63
|
+
})
|
|
64
|
+
function AgentImpl(options) {
|
|
65
|
+
expect(options.initialState.thinkingLevel).toBe('off')
|
|
66
|
+
expect(options.initialState.tools).toEqual([])
|
|
67
|
+
this.state = { ...options.initialState, messages: [] }
|
|
68
|
+
this.prompt = prompt
|
|
69
|
+
}
|
|
70
|
+
const result = await runContactTurn({
|
|
71
|
+
assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
|
|
72
|
+
history: [],
|
|
73
|
+
userText: '[attachment]',
|
|
74
|
+
userParts: [{ type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }],
|
|
75
|
+
createChatCompletion: vi.fn(),
|
|
76
|
+
AgentImpl,
|
|
77
|
+
})
|
|
78
|
+
expect(result.thinkingLevel).toBe('off')
|
|
79
|
+
expect(result.tools).toEqual([])
|
|
80
|
+
expect(prompt).toHaveBeenCalledWith('[attachment]')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('replays an assign_session fence as a toolUse burst, not token SSE', async () => {
|
|
84
|
+
const createChatCompletion = vi.fn(async () => ({
|
|
85
|
+
completion: {
|
|
86
|
+
choices: [{
|
|
87
|
+
message: {
|
|
88
|
+
content: 'On it.\n\n```openchamber-tool\n{"name":"assign_session","arguments":{"prompt":"Fix login"}}\n```',
|
|
89
|
+
},
|
|
90
|
+
}],
|
|
91
|
+
},
|
|
92
|
+
}))
|
|
93
|
+
const streamFn = createContactStreamFn(createChatCompletion)
|
|
94
|
+
const events = []
|
|
95
|
+
const stream = streamFn(
|
|
96
|
+
{ name: 'openai/gpt-5.2', id: 'gpt-5.2', provider: 'openchamber', api: 'openai-completions' },
|
|
97
|
+
{
|
|
98
|
+
messages: [{ role: 'user', content: 'assign login', timestamp: 1 }],
|
|
99
|
+
tools: [{ name: 'assign_session' }],
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
for await (const event of stream) events.push(event)
|
|
103
|
+
expect(events.some((event) => event.type === 'toolcall_end' && event.toolCall?.name === 'assign_session')).toBe(true)
|
|
104
|
+
expect(events.at(-1)).toMatchObject({ type: 'done', reason: 'toolUse' })
|
|
105
|
+
expect(createChatCompletion.mock.calls[0][0].body.stream).toBeUndefined()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('replays a bare {name, arguments} object as a toolUse burst', async () => {
|
|
109
|
+
const createChatCompletion = vi.fn(async () => ({
|
|
110
|
+
completion: {
|
|
111
|
+
choices: [{
|
|
112
|
+
message: {
|
|
113
|
+
content: '好的 {"name":"create_assistant","arguments":{"name":"FlowNL"}}',
|
|
114
|
+
},
|
|
115
|
+
}],
|
|
116
|
+
},
|
|
117
|
+
}))
|
|
118
|
+
const streamFn = createContactStreamFn(createChatCompletion)
|
|
119
|
+
const events = []
|
|
120
|
+
const stream = streamFn(
|
|
121
|
+
{ name: 'opencode-go/deepseek-v4-flash', id: 'deepseek-v4-flash', provider: 'openchamber', api: 'openai-completions' },
|
|
122
|
+
{
|
|
123
|
+
messages: [{ role: 'user', content: '新建助理 FlowNL', timestamp: 1 }],
|
|
124
|
+
tools: [{ name: 'create_assistant' }],
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
for await (const event of stream) events.push(event)
|
|
128
|
+
expect(events.some((event) => event.type === 'toolcall_end' && event.toolCall?.name === 'create_assistant')).toBe(true)
|
|
129
|
+
expect(events.at(-1)).toMatchObject({ type: 'done', reason: 'toolUse' })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('passes onTextDelta and globalEventHub into createChatCompletion and forwards real deltas', async () => {
|
|
133
|
+
const deltas = []
|
|
134
|
+
const bubbleDeltas = []
|
|
135
|
+
const hub = { subscribeEvent: vi.fn() }
|
|
136
|
+
const createChatCompletion = vi.fn(async ({ onTextDelta }) => {
|
|
137
|
+
onTextDelta?.('Hello')
|
|
138
|
+
onTextDelta?.(' world')
|
|
139
|
+
return { completion: { choices: [{ message: { content: 'Hello world' } }] } }
|
|
140
|
+
})
|
|
141
|
+
const streamFn = createContactStreamFn(createChatCompletion, {
|
|
142
|
+
onTextDelta: (delta) => deltas.push(delta),
|
|
143
|
+
onBubbleDelta: (index, delta, done) => bubbleDeltas.push({ index, delta, done }),
|
|
144
|
+
globalEventHub: hub,
|
|
145
|
+
})
|
|
146
|
+
const stream = streamFn(
|
|
147
|
+
{ name: 'openai/gpt-5.2', id: 'gpt-5.2', provider: 'openchamber', api: 'openai-completions' },
|
|
148
|
+
{ messages: [{ role: 'user', content: 'hi', timestamp: 1 }] },
|
|
149
|
+
)
|
|
150
|
+
for await (const event of stream) void event
|
|
151
|
+
expect(createChatCompletion.mock.calls[0][0].globalEventHub).toBe(hub)
|
|
152
|
+
expect(typeof createChatCompletion.mock.calls[0][0].onTextDelta).toBe('function')
|
|
153
|
+
expect(deltas).toEqual(['Hello', ' world'])
|
|
154
|
+
expect(bubbleDeltas.some((item) => item.done === false)).toBe(false)
|
|
155
|
+
expect(bubbleDeltas).toEqual([{ index: 0, delta: 'Hello world', done: true }])
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('stops bubble deltas at a fence start and does not leak tool JSON', async () => {
|
|
159
|
+
const bubbleDeltas = []
|
|
160
|
+
const createChatCompletion = vi.fn(async ({ onTextDelta }) => {
|
|
161
|
+
onTextDelta?.('On it.\n\n')
|
|
162
|
+
onTextDelta?.('```openchamber-tool\n{"name":"assign_session","arguments":{"prompt":"x"}}\n```')
|
|
163
|
+
return {
|
|
164
|
+
completion: {
|
|
165
|
+
choices: [{
|
|
166
|
+
message: {
|
|
167
|
+
content: 'On it.\n\n```openchamber-tool\n{"name":"assign_session","arguments":{"prompt":"x"}}\n```',
|
|
168
|
+
},
|
|
169
|
+
}],
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
const streamFn = createContactStreamFn(createChatCompletion, {
|
|
174
|
+
onBubbleDelta: (index, delta, done) => bubbleDeltas.push({ index, delta, done }),
|
|
175
|
+
})
|
|
176
|
+
const stream = streamFn(
|
|
177
|
+
{ name: 'openai/gpt-5.2', id: 'gpt-5.2', provider: 'openchamber', api: 'openai-completions' },
|
|
178
|
+
{
|
|
179
|
+
messages: [{ role: 'user', content: 'assign', timestamp: 1 }],
|
|
180
|
+
tools: [{ name: 'assign_session' }],
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
for await (const event of stream) void event
|
|
184
|
+
const leaked = bubbleDeltas.some((item) => item.delta.includes('openchamber-tool') || item.delta.includes('assign_session'))
|
|
185
|
+
expect(leaked).toBe(false)
|
|
186
|
+
expect(bubbleDeltas).toEqual([{ index: 0, delta: 'On it.', done: true }])
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('emits final stripped bubbles as done:true once when no live deltas arrive', async () => {
|
|
190
|
+
const bubbleDeltas = []
|
|
191
|
+
const createChatCompletion = vi.fn(async () => ({
|
|
192
|
+
completion: { choices: [{ message: { content: 'One.\n\nTwo.' } }] },
|
|
193
|
+
}))
|
|
194
|
+
const streamFn = createContactStreamFn(createChatCompletion, {
|
|
195
|
+
onBubbleDelta: (index, delta, done) => bubbleDeltas.push({ index, delta, done }),
|
|
196
|
+
})
|
|
197
|
+
const stream = streamFn(
|
|
198
|
+
{ name: 'openai/gpt-5.2', id: 'gpt-5.2', provider: 'openchamber', api: 'openai-completions' },
|
|
199
|
+
{ messages: [{ role: 'user', content: 'hi', timestamp: 1 }] },
|
|
200
|
+
)
|
|
201
|
+
for await (const event of stream) void event
|
|
202
|
+
expect(bubbleDeltas).toEqual([
|
|
203
|
+
{ index: 0, delta: 'One.', done: true },
|
|
204
|
+
{ index: 1, delta: 'Two.', done: true },
|
|
205
|
+
])
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
describe('runContactTurn', () => {
|
|
210
|
+
it('runs pi-agent-core with thinking off and no tools by default', async () => {
|
|
211
|
+
const prompt = vi.fn(async function prompt() {
|
|
212
|
+
this.state.messages = [{
|
|
213
|
+
role: 'assistant',
|
|
214
|
+
content: [{ type: 'text', text: 'Hey.\n\nI can open that session.' }],
|
|
215
|
+
}]
|
|
216
|
+
})
|
|
217
|
+
function AgentImpl(options) {
|
|
218
|
+
expect(options.initialState.thinkingLevel).toBe('off')
|
|
219
|
+
expect(options.initialState.tools).toEqual([])
|
|
220
|
+
this.state = { ...options.initialState, messages: [] }
|
|
221
|
+
this.prompt = prompt
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const result = await runContactTurn({
|
|
225
|
+
assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
|
|
226
|
+
history: [],
|
|
227
|
+
userText: 'open login',
|
|
228
|
+
createChatCompletion: vi.fn(),
|
|
229
|
+
AgentImpl,
|
|
230
|
+
})
|
|
231
|
+
expect(result.bubbles).toEqual(['Hey.', 'I can open that session.'])
|
|
232
|
+
expect(prompt).toHaveBeenCalled()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('attaches OpenChamber tools only and collects session cards from tool results', async () => {
|
|
236
|
+
const assignTool = {
|
|
237
|
+
name: 'assign_session',
|
|
238
|
+
label: 'Assign session',
|
|
239
|
+
description: 'Open a worker session',
|
|
240
|
+
parameters: {},
|
|
241
|
+
execute: vi.fn(),
|
|
242
|
+
}
|
|
243
|
+
function AgentImpl(options) {
|
|
244
|
+
expect(options.initialState.thinkingLevel).toBe('off')
|
|
245
|
+
expect(options.initialState.tools.map((tool) => tool.name)).toEqual(['assign_session'])
|
|
246
|
+
expect(options.initialState.tools.some((tool) => ['bash', 'edit', 'read', 'write'].includes(tool.name))).toBe(false)
|
|
247
|
+
expect(options.initialState.systemPrompt).toContain('assign_session')
|
|
248
|
+
expect(options.initialState.systemPrompt).toContain('create_assistant')
|
|
249
|
+
expect(options.initialState.systemPrompt).toContain('message_assistant')
|
|
250
|
+
expect(options.initialState.systemPrompt).toContain('new_conversation')
|
|
251
|
+
expect(options.initialState.systemPrompt).toContain('list_projects')
|
|
252
|
+
expect(options.initialState.systemPrompt).toContain('list_sessions')
|
|
253
|
+
expect(options.initialState.systemPrompt).toContain('建助理')
|
|
254
|
+
expect(options.initialState.systemPrompt).toContain('开新对话')
|
|
255
|
+
expect(options.initialState.systemPrompt).toContain('说一声')
|
|
256
|
+
expect(options.initialState.systemPrompt).toContain('A reply without the tool call does nothing')
|
|
257
|
+
expect(options.initialState.systemPrompt).toContain('已创建')
|
|
258
|
+
expect(options.initialState.systemPrompt).toContain('cannot see the registered project list')
|
|
259
|
+
this.state = { ...options.initialState, messages: [] }
|
|
260
|
+
this.prompt = async () => {
|
|
261
|
+
this.state.messages = [
|
|
262
|
+
{ role: 'assistant', content: [{ type: 'text', text: 'Opening that.' }] },
|
|
263
|
+
{
|
|
264
|
+
role: 'toolResult',
|
|
265
|
+
toolName: 'assign_session',
|
|
266
|
+
content: [{ type: 'text', text: 'opened' }],
|
|
267
|
+
details: {
|
|
268
|
+
card: {
|
|
269
|
+
type: 'card',
|
|
270
|
+
cardType: 'session',
|
|
271
|
+
sessionID: 'ses_1',
|
|
272
|
+
directory: '/repo',
|
|
273
|
+
title: 'Login',
|
|
274
|
+
status: 'busy',
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
]
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const result = await runContactTurn({
|
|
283
|
+
assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
|
|
284
|
+
history: [],
|
|
285
|
+
userText: 'assign login',
|
|
286
|
+
createChatCompletion: vi.fn(),
|
|
287
|
+
tools: [assignTool, { name: 'bash', execute: vi.fn() }],
|
|
288
|
+
projects: [{ id: 'proj_yee', path: '/repo/sample-app', label: 'OpenChamber Yee' }],
|
|
289
|
+
AgentImpl,
|
|
290
|
+
})
|
|
291
|
+
expect(result.bubbles).toEqual(['opened'])
|
|
292
|
+
expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_1', cardType: 'session' })])
|
|
293
|
+
expect(result.thinkingLevel).toBe('off')
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('does not persist pre-tool planning as contact bubbles', async () => {
|
|
297
|
+
function AgentImpl(options) {
|
|
298
|
+
this.state = { ...options.initialState, messages: [] }
|
|
299
|
+
this.prompt = async () => {
|
|
300
|
+
this.state.messages = [
|
|
301
|
+
{
|
|
302
|
+
role: 'assistant',
|
|
303
|
+
content: [
|
|
304
|
+
{ type: 'text', text: 'Let me think — I should match openchamber yee and call assign_session.' },
|
|
305
|
+
{ type: 'toolCall', id: 'call_1', name: 'assign_session', arguments: { prompt: 'x', projectPath: '/repo' } },
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
role: 'toolResult',
|
|
310
|
+
toolName: 'assign_session',
|
|
311
|
+
content: [{ type: 'text', text: 'Opened a coding session.' }],
|
|
312
|
+
details: {
|
|
313
|
+
card: {
|
|
314
|
+
type: 'card',
|
|
315
|
+
cardType: 'session',
|
|
316
|
+
sessionID: 'ses_plan',
|
|
317
|
+
directory: '/repo',
|
|
318
|
+
title: 'Work',
|
|
319
|
+
status: 'busy',
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
]
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const result = await runContactTurn({
|
|
327
|
+
assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
|
|
328
|
+
history: [],
|
|
329
|
+
userText: 'assign login',
|
|
330
|
+
createChatCompletion: vi.fn(),
|
|
331
|
+
tools: [{ name: 'assign_session', execute: vi.fn() }],
|
|
332
|
+
AgentImpl,
|
|
333
|
+
})
|
|
334
|
+
expect(result.bubbles.join('\n')).not.toMatch(/Let me think|assign_session/)
|
|
335
|
+
expect(result.bubbles).toEqual(['Opened a coding session.'])
|
|
336
|
+
expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_plan' })])
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('keeps a short spoken preamble then the tool confirm', async () => {
|
|
340
|
+
function AgentImpl(options) {
|
|
341
|
+
this.state = { ...options.initialState, messages: [] }
|
|
342
|
+
this.prompt = async () => {
|
|
343
|
+
this.state.messages = [
|
|
344
|
+
{
|
|
345
|
+
role: 'assistant',
|
|
346
|
+
content: [
|
|
347
|
+
{ type: 'text', text: '我去找一下' },
|
|
348
|
+
{ type: 'toolCall', id: 'call_1', name: 'assign_session', arguments: { prompt: 'x' } },
|
|
349
|
+
],
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
role: 'toolResult',
|
|
353
|
+
toolName: 'assign_session',
|
|
354
|
+
content: [{ type: 'text', text: 'Opened a coding session.' }],
|
|
355
|
+
details: {
|
|
356
|
+
card: {
|
|
357
|
+
type: 'card',
|
|
358
|
+
cardType: 'session',
|
|
359
|
+
sessionID: 'ses_speak',
|
|
360
|
+
directory: '/repo',
|
|
361
|
+
title: 'Work',
|
|
362
|
+
status: 'busy',
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
]
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const result = await runContactTurn({
|
|
370
|
+
assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
|
|
371
|
+
history: [],
|
|
372
|
+
userText: 'assign login',
|
|
373
|
+
createChatCompletion: vi.fn(),
|
|
374
|
+
tools: [{ name: 'assign_session', execute: vi.fn() }],
|
|
375
|
+
AgentImpl,
|
|
376
|
+
})
|
|
377
|
+
expect(result.bubbles).toEqual(['我去找一下', 'Opened a coding session.'])
|
|
378
|
+
expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_speak' })])
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('injects the registered projects catalog into the system prompt every turn', async () => {
|
|
382
|
+
function AgentImpl(options) {
|
|
383
|
+
expect(options.initialState.systemPrompt).toContain('Registered projects')
|
|
384
|
+
expect(options.initialState.systemPrompt).toContain('OpenChamber Yee')
|
|
385
|
+
expect(options.initialState.systemPrompt).toContain('/repo/sample-app')
|
|
386
|
+
expect(options.initialState.systemPrompt).toContain('never say you cannot see registered projects')
|
|
387
|
+
this.state = { ...options.initialState, messages: [{ role: 'assistant', content: [{ type: 'text', text: 'Found it.' }] }] }
|
|
388
|
+
this.prompt = async () => {}
|
|
389
|
+
}
|
|
390
|
+
const result = await runContactTurn({
|
|
391
|
+
assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
|
|
392
|
+
history: [],
|
|
393
|
+
userText: '找 openchamber yee',
|
|
394
|
+
createChatCompletion: vi.fn(),
|
|
395
|
+
projects: [{ id: 'proj_yee', path: '/repo/sample-app', label: 'OpenChamber Yee' }],
|
|
396
|
+
AgentImpl,
|
|
397
|
+
})
|
|
398
|
+
expect(result.bubbles).toEqual(['Found it.'])
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('retries a missed fence once and then executes create_assistant', async () => {
|
|
402
|
+
const createAssistant = vi.fn(async (input) => ({
|
|
403
|
+
id: 'asst_flow',
|
|
404
|
+
name: input.name,
|
|
405
|
+
providerID: input.providerID,
|
|
406
|
+
modelID: input.modelID,
|
|
407
|
+
mode: 'continuous',
|
|
408
|
+
}))
|
|
409
|
+
const tools = createContactTools({
|
|
410
|
+
createAssistant,
|
|
411
|
+
currentAssistant: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash' },
|
|
412
|
+
})
|
|
413
|
+
const prompts = []
|
|
414
|
+
function AgentImpl(options) {
|
|
415
|
+
expect(options.initialState.thinkingLevel).toBe('off')
|
|
416
|
+
expect(options.initialState.tools.some((tool) => ['bash', 'edit', 'read', 'write'].includes(tool.name))).toBe(false)
|
|
417
|
+
this.state = { ...options.initialState, messages: [] }
|
|
418
|
+
this.prompt = async (text) => {
|
|
419
|
+
prompts.push(text)
|
|
420
|
+
this.state.messages.push({ role: 'user', content: text, timestamp: Date.now() })
|
|
421
|
+
if (text === MISSED_FENCE_RETRY_USER_TEXT) {
|
|
422
|
+
const tool = this.state.tools.find((item) => item.name === CREATE_ASSISTANT_TOOL_NAME)
|
|
423
|
+
const result = await tool.execute('call_retry', { name: 'FlowNL', model: 'opencode-go/deepseek-v4-flash' })
|
|
424
|
+
this.state.messages.push(
|
|
425
|
+
{ role: 'assistant', content: [{ type: 'text', text: '' }] },
|
|
426
|
+
{
|
|
427
|
+
role: 'toolResult',
|
|
428
|
+
toolName: CREATE_ASSISTANT_TOOL_NAME,
|
|
429
|
+
content: result.content,
|
|
430
|
+
details: result.details,
|
|
431
|
+
},
|
|
432
|
+
)
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
this.state.messages.push({
|
|
436
|
+
role: 'assistant',
|
|
437
|
+
content: [{ type: 'text', text: '好的,我来直接创建这个助理,不开编码会话。' }],
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const result = await runContactTurn({
|
|
443
|
+
assistant: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash', defaultPrompt: '' },
|
|
444
|
+
history: [],
|
|
445
|
+
userText: '帮我新建一个助理,名叫 FlowNL,不要开编码 session',
|
|
446
|
+
createChatCompletion: vi.fn(),
|
|
447
|
+
tools,
|
|
448
|
+
AgentImpl,
|
|
449
|
+
})
|
|
450
|
+
expect(prompts).toEqual([
|
|
451
|
+
'帮我新建一个助理,名叫 FlowNL,不要开编码 session',
|
|
452
|
+
MISSED_FENCE_RETRY_USER_TEXT,
|
|
453
|
+
])
|
|
454
|
+
expect(createAssistant).toHaveBeenCalledWith({
|
|
455
|
+
name: 'FlowNL',
|
|
456
|
+
providerID: 'opencode-go',
|
|
457
|
+
modelID: 'deepseek-v4-flash',
|
|
458
|
+
mode: 'continuous',
|
|
459
|
+
})
|
|
460
|
+
expect(result.cards).toEqual([expect.objectContaining({
|
|
461
|
+
cardType: 'assistant',
|
|
462
|
+
name: 'FlowNL',
|
|
463
|
+
assistantID: 'asst_flow',
|
|
464
|
+
})])
|
|
465
|
+
expect(result.bubbles.join('')).not.toContain('好的,我来直接创建')
|
|
466
|
+
expect(result.bubbles.join('')).toContain('Created assistant FlowNL')
|
|
467
|
+
expect(result.thinkingLevel).toBe('off')
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it('does not fake a card when the missed-fence retry still has no tool', async () => {
|
|
471
|
+
const createAssistant = vi.fn()
|
|
472
|
+
const tools = createContactTools({
|
|
473
|
+
createAssistant,
|
|
474
|
+
currentAssistant: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash' },
|
|
475
|
+
})
|
|
476
|
+
const prompts = []
|
|
477
|
+
function AgentImpl(options) {
|
|
478
|
+
this.state = { ...options.initialState, messages: [] }
|
|
479
|
+
this.prompt = async (text) => {
|
|
480
|
+
prompts.push(text)
|
|
481
|
+
this.state.messages.push(
|
|
482
|
+
{ role: 'user', content: text, timestamp: Date.now() },
|
|
483
|
+
{ role: 'assistant', content: [{ type: 'text', text: '好的,已创建。' }] },
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const result = await runContactTurn({
|
|
489
|
+
assistant: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash', defaultPrompt: '' },
|
|
490
|
+
history: [],
|
|
491
|
+
userText: '帮我新建一个助理,名叫 FlowNL',
|
|
492
|
+
createChatCompletion: vi.fn(),
|
|
493
|
+
tools,
|
|
494
|
+
AgentImpl,
|
|
495
|
+
})
|
|
496
|
+
expect(prompts).toEqual([
|
|
497
|
+
'帮我新建一个助理,名叫 FlowNL',
|
|
498
|
+
MISSED_FENCE_RETRY_USER_TEXT,
|
|
499
|
+
])
|
|
500
|
+
expect(createAssistant).not.toHaveBeenCalled()
|
|
501
|
+
expect(result.cards).toEqual([])
|
|
502
|
+
expect(result.bubbles).toEqual([MISSED_TOOL_FAILURE_BUBBLE])
|
|
503
|
+
expect(result.bubbles.join('')).not.toContain('已创建')
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('retries a missed fence once and then executes message_assistant', async () => {
|
|
507
|
+
const deliverPeerMessage = vi.fn(async (input) => ({
|
|
508
|
+
admitted: true,
|
|
509
|
+
role: 'peer',
|
|
510
|
+
toAssistantID: input.toAssistantID,
|
|
511
|
+
}))
|
|
512
|
+
const tools = createContactTools({
|
|
513
|
+
deliverPeerMessage,
|
|
514
|
+
listAssistants: async () => [{ id: 'asst_peer', name: 'PeerQA' }],
|
|
515
|
+
currentAssistant: { id: 'asst_host', name: 'DeepSeekQA', providerID: 'opencode-go', modelID: 'deepseek-v4-flash' },
|
|
516
|
+
})
|
|
517
|
+
const prompts = []
|
|
518
|
+
function AgentImpl(options) {
|
|
519
|
+
this.state = { ...options.initialState, messages: [] }
|
|
520
|
+
this.prompt = async (text) => {
|
|
521
|
+
prompts.push(text)
|
|
522
|
+
this.state.messages.push({ role: 'user', content: text, timestamp: Date.now() })
|
|
523
|
+
if (text === MISSED_FENCE_RETRY_USER_TEXT) {
|
|
524
|
+
const tool = this.state.tools.find((item) => item.name === MESSAGE_ASSISTANT_TOOL_NAME)
|
|
525
|
+
const result = await tool.execute('call_retry', { to: 'PeerQA', text: 'hello-from-assistant 写好了' })
|
|
526
|
+
this.state.messages.push(
|
|
527
|
+
{ role: 'assistant', content: [{ type: 'text', text: '' }] },
|
|
528
|
+
{
|
|
529
|
+
role: 'toolResult',
|
|
530
|
+
toolName: MESSAGE_ASSISTANT_TOOL_NAME,
|
|
531
|
+
content: result.content,
|
|
532
|
+
details: result.details,
|
|
533
|
+
},
|
|
534
|
+
)
|
|
535
|
+
return
|
|
536
|
+
}
|
|
537
|
+
this.state.messages.push({
|
|
538
|
+
role: 'assistant',
|
|
539
|
+
content: [{ type: 'text', text: '好的,我去说一声。' }],
|
|
540
|
+
})
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const result = await runContactTurn({
|
|
545
|
+
assistant: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash', defaultPrompt: '' },
|
|
546
|
+
history: [],
|
|
547
|
+
userText: '给 PeerQA 说一声 hello-from-assistant 写好了',
|
|
548
|
+
createChatCompletion: vi.fn(),
|
|
549
|
+
tools,
|
|
550
|
+
AgentImpl,
|
|
551
|
+
})
|
|
552
|
+
expect(prompts).toEqual([
|
|
553
|
+
'给 PeerQA 说一声 hello-from-assistant 写好了',
|
|
554
|
+
MISSED_FENCE_RETRY_USER_TEXT,
|
|
555
|
+
])
|
|
556
|
+
expect(deliverPeerMessage).toHaveBeenCalledWith({
|
|
557
|
+
toAssistantID: 'asst_peer',
|
|
558
|
+
text: 'hello-from-assistant 写好了',
|
|
559
|
+
})
|
|
560
|
+
expect(result.cards).toEqual([])
|
|
561
|
+
expect(result.bubbles.join('')).toContain('Sent to PeerQA')
|
|
562
|
+
expect(result.bubbles.join('')).not.toContain('好的,我去说一声')
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
it('keeps only the new_conversation confirm and drops leftover attachment text', async () => {
|
|
566
|
+
const resetContact = vi.fn(async () => ({ reset: true }))
|
|
567
|
+
const tools = createContactTools({ resetContact })
|
|
568
|
+
function AgentImpl(options) {
|
|
569
|
+
this.state = { ...options.initialState, messages: options.initialState.messages }
|
|
570
|
+
this.prompt = async () => {
|
|
571
|
+
const tool = this.state.tools.find((item) => item.name === NEW_CONVERSATION_TOOL_NAME)
|
|
572
|
+
const result = await tool.execute('call_reset', {})
|
|
573
|
+
this.state.messages = [
|
|
574
|
+
...this.state.messages,
|
|
575
|
+
{
|
|
576
|
+
role: 'assistant',
|
|
577
|
+
content: [{ type: 'text', text: 'I still see your dot.png and note.txt from earlier.' }],
|
|
578
|
+
},
|
|
579
|
+
{
|
|
580
|
+
role: 'toolResult',
|
|
581
|
+
toolName: NEW_CONVERSATION_TOOL_NAME,
|
|
582
|
+
content: result.content,
|
|
583
|
+
details: result.details,
|
|
584
|
+
},
|
|
585
|
+
]
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const result = await runContactTurn({
|
|
590
|
+
assistant: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash', defaultPrompt: '' },
|
|
591
|
+
history: [
|
|
592
|
+
{ role: 'user', content: 'look', parts: [{ type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'dot.png' }] },
|
|
593
|
+
{ role: 'assistant', content: 'got the image' },
|
|
594
|
+
],
|
|
595
|
+
userText: '开新对话',
|
|
596
|
+
createChatCompletion: vi.fn(),
|
|
597
|
+
tools,
|
|
598
|
+
AgentImpl,
|
|
599
|
+
})
|
|
600
|
+
expect(resetContact).toHaveBeenCalledTimes(1)
|
|
601
|
+
expect(result.reset).toBe(true)
|
|
602
|
+
expect(result.cards).toEqual([])
|
|
603
|
+
expect(result.bubbles).toEqual([NEW_CONVERSATION_CONFIRM_BUBBLE])
|
|
604
|
+
expect(result.bubbles.join('')).not.toContain('dot.png')
|
|
605
|
+
expect(result.bubbles.join('')).not.toContain('note.txt')
|
|
606
|
+
})
|
|
607
|
+
})
|
|
@@ -1,17 +1,57 @@
|
|
|
1
1
|
import { AssistantError, createAssistantsService } from './service.js';
|
|
2
|
+
import { createChatCompletion } from '../llm/completions.js';
|
|
3
|
+
import { ensureLlmTempDirectory } from '../llm/temp-directory.js';
|
|
4
|
+
import { setAssignedSessionSettleHandler } from '../session-goal/runtime.js';
|
|
5
|
+
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
|
2
6
|
|
|
3
|
-
const respond = (res, work, success = 200) => Promise.resolve().then(work).then((body) => res.status(success).json(body)).catch((error) => { const code = error instanceof AssistantError ? error.code : 'internal_error'; const status = code === 'not_found' ? 404 : ['revision_conflict', 'idempotency_conflict'].includes(code) ? 409 : code === 'assistant_disabled' ? 403 : code === 'upstream_error' ? 502 : 400; res.status(status).json({ ok: false, error: code }); });
|
|
7
|
+
const respond = (res, work, success = 200) => Promise.resolve().then(work).then((body) => res.status(success).json(body)).catch((error) => { const code = error instanceof AssistantError ? error.code : error?.code || 'internal_error'; const status = code === 'not_found' ? 404 : ['revision_conflict', 'idempotency_conflict'].includes(code) ? 409 : code === 'assistant_disabled' ? 403 : code === 'no_provider' ? 400 : code === 'upstream_error' ? 502 : 400; res.status(status).json({ ok: false, error: code, message: typeof error?.message === 'string' && error.message.trim() ? error.message : code }); });
|
|
4
8
|
const gone = (_req, res) => res.status(410).json({ ok: false, error: 'assistant_topics_retired' });
|
|
5
9
|
|
|
6
10
|
export const registerAssistantRoutes = (app, dependencies) => {
|
|
7
|
-
const
|
|
11
|
+
const boundCompletion = (input) => createChatCompletion({
|
|
12
|
+
...input,
|
|
13
|
+
buildOpenCodeUrl: dependencies.buildOpenCodeUrl,
|
|
14
|
+
getOpenCodeAuthHeaders: dependencies.getOpenCodeAuthHeaders,
|
|
15
|
+
clientFactory: () => createOpencodeClient({
|
|
16
|
+
baseUrl: dependencies.buildOpenCodeUrl('/', '').replace(/\/$/, ''),
|
|
17
|
+
headers: dependencies.getOpenCodeAuthHeaders(),
|
|
18
|
+
}),
|
|
19
|
+
ensureTempDirectory: ensureLlmTempDirectory,
|
|
20
|
+
// Throwaway generate may subscribe for real token deltas (contact harness).
|
|
21
|
+
globalEventHub: input?.globalEventHub ?? dependencies.globalEventHub,
|
|
22
|
+
});
|
|
23
|
+
const service = createAssistantsService({
|
|
24
|
+
dbPath: dependencies.dbPath,
|
|
25
|
+
dataDir: dependencies.openchamberDataDir,
|
|
26
|
+
buildOpenCodeUrl: dependencies.buildOpenCodeUrl,
|
|
27
|
+
getOpenCodeAuthHeaders: dependencies.getOpenCodeAuthHeaders,
|
|
28
|
+
getServerId: dependencies.getServerId,
|
|
29
|
+
getAllowedRoots: dependencies.getAllowedRoots,
|
|
30
|
+
listProjects: dependencies.listProjects,
|
|
31
|
+
listScheduledTasks: dependencies.listScheduledTasks,
|
|
32
|
+
sessionIndexService: dependencies.sessionIndexService,
|
|
33
|
+
upsertScheduledTask: dependencies.upsertScheduledTask,
|
|
34
|
+
syncScheduledTaskProject: dependencies.syncScheduledTaskProject,
|
|
35
|
+
globalEventHub: dependencies.globalEventHub,
|
|
36
|
+
onRevisionTip: dependencies.onRevisionTip,
|
|
37
|
+
onContactTurnEvent: dependencies.onContactTurnEvent,
|
|
38
|
+
onContactTurnComplete: dependencies.onContactTurnComplete,
|
|
39
|
+
createChatCompletion: boundCompletion,
|
|
40
|
+
});
|
|
41
|
+
setAssignedSessionSettleHandler(({ sessionId, status }) => service.reportAssignedSessionSettle(sessionId, status));
|
|
8
42
|
app.use('/api/openchamber/assistants', (req, res, next) => Promise.resolve(dependencies.refreshAllowedRoots?.()).then(next).catch((error) => respond(res, () => { throw error; })));
|
|
9
43
|
app.get('/api/openchamber/assistants/capability', (_req, res) => respond(res, () => service.capability()));
|
|
10
44
|
app.get('/api/openchamber/assistants', (_req, res) => respond(res, () => service.snapshot())); app.get('/api/openchamber/assistants/snapshot', (_req, res) => respond(res, () => service.snapshot())); app.put('/api/openchamber/assistants/settings', (req, res) => respond(res, () => service.setEnabled(req.body)));
|
|
11
45
|
app.post('/api/openchamber/assistants', (req, res) => respond(res, () => service.createAssistant(req.body), 201)); app.patch('/api/openchamber/assistants/:assistantID', (req, res) => respond(res, () => service.updateAssistant(req.params.assistantID, req.body))); app.delete('/api/openchamber/assistants/:assistantID', (req, res) => respond(res, () => service.removeAssistant(req.params.assistantID, req.body?.expectedRevision)));
|
|
12
46
|
app.post('/api/openchamber/assistants/:assistantID/session/ensure', (req, res) => respond(res, () => service.ensure(req.params.assistantID))); app.post('/api/openchamber/assistants/:assistantID/session/new', (req, res) => respond(res, () => service.createNew(req.params.assistantID))); app.post('/api/openchamber/assistants/:assistantID/session/compact', (req, res) => respond(res, () => service.compact(req.params.assistantID, req.body))); app.post('/api/openchamber/assistants/:assistantID/session/abort', (req, res) => respond(res, () => service.abort(req.params.assistantID, req.body)));
|
|
13
47
|
app.get('/api/openchamber/assistants/:assistantID/messages', (req, res) => respond(res, () => service.historicalMessages(req.params.assistantID, req.query)));
|
|
14
|
-
app.
|
|
48
|
+
app.get('/api/openchamber/assistants/:assistantID/contact/messages', (req, res) => respond(res, () => service.contactMessages(req.params.assistantID, req.query)));
|
|
49
|
+
app.post('/api/openchamber/assistants/:assistantID/contact/reset', (req, res) => respond(res, () => service.resetContact(req.params.assistantID)));
|
|
50
|
+
app.post('/api/openchamber/assistants/:assistantID/contact/cards', (req, res) => respond(res, () => service.appendContactCard(req.params.assistantID, req.body), 201));
|
|
51
|
+
app.post('/api/openchamber/assistants/:assistantID/contact/dm', (req, res) => respond(res, () => service.deliverPeerMessage(req.params.assistantID, req.body), 201));
|
|
52
|
+
app.get('/api/openchamber/assistants/:assistantID/scheduled-tasks', (req, res) => respond(res, () => service.listAssistantScheduledTasks(req.params.assistantID)));
|
|
53
|
+
// Contact send admits the user message at 202; assistant bubbles stream on SSE.
|
|
54
|
+
app.post('/api/openchamber/assistants/:assistantID/messages', (req, res) => respond(res, () => service.send(req.params.assistantID, req.body), 202)); app.post('/api/openchamber/assistants/:assistantID/share', (req, res) => respond(res, () => service.share(req.params.assistantID, req.body), 202)); app.get('/api/openchamber/assistants/share-operations/:operationID', (req, res) => respond(res, () => { const operation = service.shareOperation(req.params.operationID); if (!operation) throw new AssistantError('not_found'); return operation; }));
|
|
15
55
|
app.use('/api/openchamber/assistants/topics', gone); app.use('/api/openchamber/assistants/:assistantID/topics', gone);
|
|
16
|
-
return { service, close: () => service.close() };
|
|
56
|
+
return { service, close: () => { setAssignedSessionSettleHandler(null); service.close(); } };
|
|
17
57
|
};
|