@openchambery/web 1.19.7-beta.9 → 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-Ohj6UbWj.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-B4lzhAbO.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-CtS4kWoo.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-CDExXwgn.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 +148 -37
- package/server/lib/notifications/runtime.test.js +100 -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-DSxVAAb_.js +0 -1
- package/dist/assets/ContextPanel-DgwEIdqR.js +0 -285
- package/dist/assets/DiagramView-BKQO61y8.js +0 -1
- package/dist/assets/FadeInOnReveal-CV2s6YN8.js +0 -1
- package/dist/assets/Icon-CLpn1mIz.js +0 -1
- package/dist/assets/MarkdownRendererImpl-CTgmghSh.js +0 -7
- package/dist/assets/MarkstreamRendererImpl-BKsc-avF.js +0 -1
- package/dist/assets/MarkstreamRendererImpl-CxeWpDCK.css +0 -1
- package/dist/assets/MobileDetailNavigation-BcA6rd8Q.js +0 -1
- package/dist/assets/MobileShareBridge-w6T2ZrsW.js +0 -4
- package/dist/assets/MobileSurface-CG1n7v0h.js +0 -2
- package/dist/assets/OnboardingScreen-D6rtE7rj.js +0 -1
- package/dist/assets/OverlayScrollbar-DlNZho3q.js +0 -2
- package/dist/assets/SettingsView-CiF3r83e.js +0 -25
- package/dist/assets/TerminalView-Ctx_Jt_b.js +0 -8
- package/dist/assets/ToolOutputDialog-Dv9_fUYl.js +0 -10
- package/dist/assets/appThemeRegistry-DnwUIQB9.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-hK4mQj4W.js +0 -4
- package/dist/assets/main-BmbZFoO4.js +0 -29
- package/dist/assets/main-BwNhn8ml.js +0 -2
- package/dist/assets/markdown-worker-y3ewaqAK.js +0 -4
- package/dist/assets/markdownTheme-SPe4jOwX.js +0 -2
- package/dist/assets/miniChat-BN89siC3.js +0 -2
- package/dist/assets/mobile-DE9rVo5D.js +0 -2
- package/dist/assets/multirun-BiEuyDq3.js +0 -4
- package/dist/assets/persistence-w-AiX2i4.js +0 -3
- package/dist/assets/platform-4eCmsdol.js +0 -2
- package/dist/assets/projectMeta-DSLKcH1a.js +0 -3
- package/dist/assets/radio-HD5AB2Qf.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-f_UxpFjv.js +0 -2
- package/dist/assets/renderMobileApp-BAhCai6d.js +0 -28
- package/dist/assets/runtime-switch-BkpTTbEi.js +0 -1
- package/dist/assets/runtimeConfig-qG_oNBoa.js +0 -2
- package/dist/assets/runtimeEndpointReset-BDeKD3EM.js +0 -4
- package/dist/assets/shikiHighlight-BphLZnac.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--0dwVTSm.js +0 -7
- package/dist/assets/useAssistantUIStore-I0VWrB5V.js +0 -1
- package/dist/assets/useDesktopWindowControlsLayout-DsbeBLvv.js +0 -1
- package/dist/assets/useEffectiveDirectory-CHvvohcG.js +0 -1
- package/dist/assets/useFontPreferences-BLZ3y6mN.js +0 -1
- package/dist/assets/useMobileNavigationStore-peTDuC00.js +0 -412
- package/dist/assets/useSessionAutoCleanup-D_ZC9E5g.js +0 -1
- package/dist/assets/useUIStore-CNLmCoPU.js +0 -1
- package/dist/assets/useWorkerHighlightedLines-B1YzuMYR.js +0 -7
- package/dist/assets/utils-CTL16t2f.js +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
6
|
import { createAssistantsService } from './service.js';
|
|
7
7
|
import { assistantContractFixtures } from './contracts.js';
|
|
@@ -11,13 +11,19 @@ const root = () => fs.mkdtempSync(path.join(os.tmpdir(), 'assistants-'));
|
|
|
11
11
|
// Behavioral tests enable the global switch after boot; pass enabled:false to assert the fresh-install default.
|
|
12
12
|
const setup = (directory = root(), client = {}, options = {}) => {
|
|
13
13
|
const { enabled = true, ...serviceOptions } = options;
|
|
14
|
-
const service = createAssistantsService({ dbPath: path.join(directory, 'assistants.sqlite'), dataDir: directory, getAllowedRoots: () => [directory], buildOpenCodeUrl: () => 'http://127.0.0.1:1', getOpenCodeAuthHeaders: () => ({}), clientFactory: () => ({ session: { create: async () => ({ data: { id: crypto.randomUUID() } }), get: async () => ({ data: { id: 'present' } }), update: async () => ({ data: { id: 'archived' } }), promptAsync: async () => ({ data: { info: { id: 'msg_1' } } }), summarize: async () => ({ data: true }), ...client } }), ...serviceOptions });
|
|
14
|
+
const service = createAssistantsService({ dbPath: path.join(directory, 'assistants.sqlite'), dataDir: directory, getAllowedRoots: () => [directory], buildOpenCodeUrl: () => 'http://127.0.0.1:1', getOpenCodeAuthHeaders: () => ({}), clientFactory: () => ({ session: { create: async () => ({ data: { id: crypto.randomUUID() } }), get: async () => ({ data: { id: 'present' } }), update: async () => ({ data: { id: 'archived' } }), promptAsync: async () => ({ data: { info: { id: 'msg_1' } } }), summarize: async () => ({ data: true }), ...client } }), runContactTurn: serviceOptions.runContactTurn ?? (async ({ userText }) => ({ text: `reply:${userText}`, bubbles: [`reply:${userText}`] })), ...serviceOptions });
|
|
15
15
|
if (enabled) {
|
|
16
16
|
const snapshot = service.snapshot();
|
|
17
17
|
if (!snapshot.enabled) service.setEnabled({ enabled: true, expectedRevision: snapshot.revision });
|
|
18
18
|
}
|
|
19
19
|
return service;
|
|
20
20
|
};
|
|
21
|
+
/** Wait for the async contact turn after 202 admission (not part of the HTTP body). */
|
|
22
|
+
const settleSend = async (service, assistantID, body) => {
|
|
23
|
+
const sent = await service.send(assistantID, body);
|
|
24
|
+
const settled = await service.whenContactTurnSettled(sent.messageID);
|
|
25
|
+
return { ...sent, settled };
|
|
26
|
+
};
|
|
21
27
|
const assistantInput = { name: 'A', providerID: 'p', modelID: 'm' };
|
|
22
28
|
|
|
23
29
|
describe('assistants service', () => {
|
|
@@ -65,23 +71,200 @@ describe('assistants service', () => {
|
|
|
65
71
|
const service = setup(); const assistant = service.createAssistant(assistantInput); const current = await service.ensure(assistant.id); const next = await service.createNew(assistant.id); expect(next.sessionGeneration).toBe(current.sessionGeneration + 1); await expect(service.compact(assistant.id, current)).rejects.toMatchObject({ code: 'revision_conflict' }); expect(await service.compact(assistant.id, next)).toMatchObject({ binding: next, summarized: true }); service.close();
|
|
66
72
|
});
|
|
67
73
|
|
|
68
|
-
it('
|
|
69
|
-
|
|
74
|
+
it('stores composer turns in the OpenChamber contact transcript, not OpenCode session history', async () => {
|
|
75
|
+
const directory = root();
|
|
76
|
+
const service = setup(directory, {}, {
|
|
77
|
+
runContactTurn: async ({ userText }) => ({ text: `ok ${userText}`, bubbles: [`ok ${userText}`] }),
|
|
78
|
+
});
|
|
79
|
+
const assistant = service.createAssistant(assistantInput);
|
|
80
|
+
const current = await service.ensure(assistant.id);
|
|
81
|
+
const sent = await service.send(assistant.id, { ...current, messageID: 'client_1', parts: [{ type: 'text', text: 'hello' }] });
|
|
82
|
+
expect(sent).toEqual({ binding: current, messageID: 'client_1', admitted: true });
|
|
83
|
+
// Admission resolves before the async turn persists assistant bubbles.
|
|
84
|
+
expect(service.contactMessages(assistant.id, { limit: 50 }).messages.map((message) => ({ role: message.role, text: message.text }))).toEqual([
|
|
85
|
+
{ role: 'user', text: 'hello' },
|
|
86
|
+
]);
|
|
87
|
+
await service.whenContactTurnSettled('client_1');
|
|
88
|
+
const page = service.contactMessages(assistant.id, { limit: 50 });
|
|
89
|
+
expect(page.complete).toBe(true);
|
|
90
|
+
expect(page.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual([
|
|
91
|
+
{ role: 'user', text: 'hello' },
|
|
92
|
+
{ role: 'assistant', text: 'ok hello' },
|
|
93
|
+
]);
|
|
94
|
+
const db = new (require('better-sqlite3'))(path.join(directory, 'assistants.sqlite'));
|
|
95
|
+
expect(db.prepare('SELECT COUNT(*) AS count FROM assistant_turn').get().count).toBe(0);
|
|
96
|
+
db.close();
|
|
97
|
+
service.close();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('new_conversation clears contact history without calling OpenCode session/new', async () => {
|
|
101
|
+
const directory = root();
|
|
102
|
+
let creates = 0;
|
|
103
|
+
let lastHistory = null;
|
|
104
|
+
const service = setup(directory, {
|
|
105
|
+
create: async () => ({ data: { id: `ses_${++creates}` } }),
|
|
106
|
+
}, {
|
|
107
|
+
runContactTurn: async ({ history, userText, tools }) => {
|
|
108
|
+
lastHistory = history;
|
|
109
|
+
if (userText === '开新对话') {
|
|
110
|
+
const tool = tools.find((item) => item.name === 'new_conversation');
|
|
111
|
+
const result = await tool.execute('reset_1', {});
|
|
112
|
+
return { text: result.content[0].text, bubbles: [result.content[0].text] };
|
|
113
|
+
}
|
|
114
|
+
return { text: `reply:${userText}`, bubbles: [`reply:${userText}`] };
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
const assistant = service.createAssistant(assistantInput);
|
|
118
|
+
await service.ensure(assistant.id);
|
|
119
|
+
expect(creates).toBe(1);
|
|
120
|
+
await settleSend(service, assistant.id, { messageID: 'old_1', parts: [{ type: 'text', text: 'remember this secret' }] });
|
|
121
|
+
await settleSend(service, assistant.id, { messageID: 'old_2', parts: [{ type: 'text', text: 'and this too' }] });
|
|
122
|
+
expect(lastHistory.some((item) => item.content.includes('remember this secret'))).toBe(true);
|
|
123
|
+
const reset = await settleSend(service, assistant.id, { messageID: 'reset_1', parts: [{ type: 'text', text: '开新对话' }] });
|
|
124
|
+
expect(reset).toMatchObject({ admitted: true, messageID: 'reset_1' });
|
|
125
|
+
expect(creates).toBe(1);
|
|
126
|
+
const page = service.contactMessages(assistant.id, { limit: 50 });
|
|
127
|
+
expect(page.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual([
|
|
128
|
+
{ role: 'user', text: '开新对话' },
|
|
129
|
+
{ role: 'assistant', text: 'Started a new conversation. Previous contact messages are cleared.' },
|
|
130
|
+
]);
|
|
131
|
+
await settleSend(service, assistant.id, { messageID: 'fresh_1', parts: [{ type: 'text', text: 'what did I say before?' }] });
|
|
132
|
+
expect(lastHistory.map((item) => item.content)).toEqual([
|
|
133
|
+
'开新对话',
|
|
134
|
+
'Started a new conversation. Previous contact messages are cleared.',
|
|
135
|
+
]);
|
|
136
|
+
expect(lastHistory.some((item) => item.content.includes('remember this secret'))).toBe(false);
|
|
137
|
+
service.close();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('discards leftover pre-reset model text after new_conversation', async () => {
|
|
141
|
+
const directory = root();
|
|
142
|
+
const service = setup(directory, {}, {
|
|
143
|
+
runContactTurn: async ({ userText, tools }) => {
|
|
144
|
+
if (userText === '开新对话') {
|
|
145
|
+
await tools.find((item) => item.name === 'new_conversation').execute('reset_leftover', {});
|
|
146
|
+
return {
|
|
147
|
+
text: 'Started a new conversation. Previous contact messages are cleared.\n\nI still see your dot.png and note.txt.',
|
|
148
|
+
bubbles: [
|
|
149
|
+
'Started a new conversation. Previous contact messages are cleared.',
|
|
150
|
+
'I still see your dot.png and note.txt.',
|
|
151
|
+
'Those attachments are still in context.',
|
|
152
|
+
],
|
|
153
|
+
cards: [{ type: 'card', cardType: 'session', sessionID: 'ses_stale', directory: '/repo', title: 'Old', status: 'busy' }],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return { text: `reply:${userText}`, bubbles: [`reply:${userText}`] };
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
const assistant = service.createAssistant(assistantInput);
|
|
160
|
+
await settleSend(service, assistant.id, {
|
|
161
|
+
messageID: 'attach_1',
|
|
162
|
+
parts: [
|
|
163
|
+
{ type: 'text', text: 'look at these' },
|
|
164
|
+
{ type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'dot.png' },
|
|
165
|
+
{ type: 'file', mime: 'text/plain', url: 'data:text/plain;base64,eA==', filename: 'note.txt' },
|
|
166
|
+
],
|
|
167
|
+
});
|
|
168
|
+
await settleSend(service, assistant.id, { messageID: 'reset_leftover', parts: [{ type: 'text', text: '开新对话' }] });
|
|
169
|
+
const page = service.contactMessages(assistant.id, { limit: 50 });
|
|
170
|
+
expect(page.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual([
|
|
171
|
+
{ role: 'user', text: '开新对话' },
|
|
172
|
+
{ role: 'assistant', text: 'Started a new conversation. Previous contact messages are cleared.' },
|
|
173
|
+
]);
|
|
174
|
+
expect(page.messages.some((message) => message.text.includes('dot.png') || message.text.includes('note.txt'))).toBe(false);
|
|
175
|
+
expect(page.messages.some((message) => message.cards?.length > 0)).toBe(false);
|
|
176
|
+
service.close();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('resetContact empties the transcript for a fresh UI refetch without createNew', async () => {
|
|
180
|
+
const directory = root();
|
|
181
|
+
let creates = 0;
|
|
182
|
+
const service = setup(directory, {
|
|
183
|
+
create: async () => ({ data: { id: `ses_${++creates}` } }),
|
|
184
|
+
});
|
|
185
|
+
const assistant = service.createAssistant(assistantInput);
|
|
186
|
+
await service.ensure(assistant.id);
|
|
187
|
+
await settleSend(service, assistant.id, { messageID: 'keep_1', parts: [{ type: 'text', text: 'hello' }] });
|
|
188
|
+
expect(service.contactMessages(assistant.id, { limit: 50 }).messages.length).toBeGreaterThan(0);
|
|
189
|
+
expect(service.resetContact(assistant.id)).toEqual({ assistantID: assistant.id, reset: true });
|
|
190
|
+
expect(creates).toBe(1);
|
|
191
|
+
expect(service.contactMessages(assistant.id, { limit: 50 })).toMatchObject({
|
|
192
|
+
messages: [],
|
|
193
|
+
complete: true,
|
|
194
|
+
});
|
|
195
|
+
service.close();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('persists mixed text+image+file parts and forwards them to the contact harness', async () => {
|
|
199
|
+
const directory = root();
|
|
200
|
+
let harness;
|
|
201
|
+
const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' };
|
|
202
|
+
const file = { type: 'file', mime: 'text/plain', url: 'data:text/plain;base64,eA==', filename: 'notes.txt' };
|
|
203
|
+
const service = setup(directory, {}, {
|
|
204
|
+
runContactTurn: async (input) => {
|
|
205
|
+
harness = input;
|
|
206
|
+
return { text: 'saw it', bubbles: ['saw it'] };
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
const assistant = service.createAssistant(assistantInput);
|
|
210
|
+
await settleSend(service, assistant.id, {
|
|
211
|
+
messageID: 'mixed_1',
|
|
212
|
+
parts: [{ type: 'text', text: 'look' }, image, file],
|
|
213
|
+
});
|
|
214
|
+
expect(harness.userText).toBe('look');
|
|
215
|
+
expect(harness.userParts).toEqual([{ type: 'text', text: 'look' }, image, file]);
|
|
216
|
+
expect(service.contactMessages(assistant.id, { limit: 50 }).messages[0]).toMatchObject({
|
|
217
|
+
role: 'user',
|
|
218
|
+
text: 'look',
|
|
219
|
+
parts: [{ type: 'text', text: 'look' }, image, file],
|
|
220
|
+
});
|
|
221
|
+
service.close();
|
|
222
|
+
const restarted = setup(directory, {}, {
|
|
223
|
+
runContactTurn: async () => ({ text: 'again', bubbles: ['again'] }),
|
|
224
|
+
});
|
|
225
|
+
expect(restarted.contactMessages(assistant.id, { limit: 50 }).messages[0].parts).toEqual([
|
|
226
|
+
{ type: 'text', text: 'look' },
|
|
227
|
+
image,
|
|
228
|
+
file,
|
|
229
|
+
]);
|
|
230
|
+
restarted.close();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('admits a file-only contact send and stores the file part without a fake text row', async () => {
|
|
234
|
+
const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' };
|
|
235
|
+
let harness;
|
|
236
|
+
const service = setup(root(), {}, {
|
|
237
|
+
runContactTurn: async (input) => {
|
|
238
|
+
harness = input;
|
|
239
|
+
return { text: 'got the image', bubbles: ['got the image'] };
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
const assistant = service.createAssistant(assistantInput);
|
|
243
|
+
await settleSend(service, assistant.id, { messageID: 'file_only_1', parts: [image] });
|
|
244
|
+
expect(harness.userText).toBe('[attachment]');
|
|
245
|
+
expect(harness.userParts).toEqual([image]);
|
|
246
|
+
const user = service.contactMessages(assistant.id).messages.find((message) => message.role === 'user');
|
|
247
|
+
expect(user.parts).toEqual([image]);
|
|
248
|
+
expect(user.text).toBe('');
|
|
249
|
+
service.close();
|
|
70
250
|
});
|
|
71
251
|
|
|
72
252
|
it('returns the frozen compact and message admission DTO field sets', async () => {
|
|
73
253
|
const service = setup(root(), { promptAsync: async () => ({ response: { status: 204 } }) }); const assistant = service.createAssistant(assistantInput); const current = await service.ensure(assistant.id);
|
|
74
254
|
expect(await service.compact(assistant.id, current)).toEqual({ binding: current, summarized: true });
|
|
75
|
-
expect(await service
|
|
255
|
+
expect(await settleSend(service, assistant.id, { ...current, messageID: 'client_204', parts: [{ type: 'text', text: 'hello' }] })).toMatchObject({ binding: current, messageID: 'client_204', admitted: true });
|
|
76
256
|
expect(Object.keys(assistantContractFixtures.assistant)).toContain('managedWorkspacePath'); expect(Object.keys(assistantContractFixtures.assistant)).not.toContain('skillRoots'); expect(Object.keys(assistantContractFixtures.compactResponse).sort()).toEqual(['binding', 'summarized']); expect(Object.keys(assistantContractFixtures.messageAdmission).sort()).toEqual(['admitted', 'binding', 'messageID']); service.close();
|
|
77
257
|
});
|
|
78
258
|
|
|
79
259
|
it('admits 33-part direct messages and 129-part shares', async () => {
|
|
80
260
|
const prompts = []; const service = setup(root(), { promptAsync: async (input) => { prompts.push(input); return { response: { status: 204 } }; } }); const assistant = service.createAssistant(assistantInput); const binding = await service.ensure(assistant.id);
|
|
81
261
|
const directParts = Array.from({ length: 33 }, (_, index) => ({ type: 'text', text: String(index) })); const shareParts = Array.from({ length: 129 }, (_, index) => ({ type: 'text', text: String(index) }));
|
|
82
|
-
await service
|
|
262
|
+
await settleSend(service, assistant.id, { ...binding, messageID: 'parts-33', parts: directParts }); await service.share(assistant.id, { operationID: 'parts-129', payload: { messageID: 'share-parts-129', parts: shareParts } });
|
|
83
263
|
const deliveryTarget = service.captureQueueDeliveryTarget({ assistantID: assistant.id, scope: { sessionID: binding.sessionID, directory: binding.directory } }); await service.sendWithCapturedConfig({ deliveryTarget, messageID: 'delivery-parts-129', parts: shareParts });
|
|
84
|
-
|
|
264
|
+
// Composer send is the contact harness (no promptAsync). Share + queued
|
|
265
|
+
// delivery still use the legacy OpenCode path; assign uses promptAsync on
|
|
266
|
+
// a dedicated worker session instead.
|
|
267
|
+
expect(prompts.map((prompt) => prompt.parts.length)).toEqual([129, 129]); service.close();
|
|
85
268
|
});
|
|
86
269
|
|
|
87
270
|
it('rejects 130-part direct messages and shares before claim', async () => {
|
|
@@ -166,8 +349,9 @@ describe('assistants service', () => {
|
|
|
166
349
|
const assistant = service.createAssistant({ ...assistantInput, variant: 'fast' });
|
|
167
350
|
expect(assistant.variant).toBe('fast');
|
|
168
351
|
const binding = await service.ensure(assistant.id);
|
|
169
|
-
await service
|
|
352
|
+
await settleSend(service, assistant.id, { ...binding, messageID: 'variant-message', parts: [{ type: 'text', text: 'message' }] });
|
|
170
353
|
await service.share(assistant.id, { operationID: 'variant-share', payload: { messageID: 'variant-share-message', parts: [{ type: 'text', text: 'share' }] } });
|
|
354
|
+
// Composer send is the contact harness. Share still captures the OpenCode variant.
|
|
171
355
|
expect(prompts).toEqual(expect.arrayContaining([expect.objectContaining({ variant: 'fast' })]));
|
|
172
356
|
expect(await service.updateAssistant(assistant.id, { expectedRevision: 1, variant: null })).toMatchObject({ variant: null });
|
|
173
357
|
service.close();
|
|
@@ -211,8 +395,8 @@ describe('assistants service', () => {
|
|
|
211
395
|
});
|
|
212
396
|
|
|
213
397
|
it('uses the workspace directory for OpenCode skill discovery without catalog injection', async () => {
|
|
214
|
-
const directory = root(); const workspace = path.join(directory, 'workspace'); const skill = path.join(workspace, '.agents', 'skills', 'project-skill'); fs.mkdirSync(skill, { recursive: true }); fs.writeFileSync(path.join(skill, 'SKILL.md'), '---\nname: project-skill\ndescription: Project skill\n---\nInstructions'); let created; let
|
|
215
|
-
await service
|
|
398
|
+
const directory = root(); const workspace = path.join(directory, 'workspace'); const skill = path.join(workspace, '.agents', 'skills', 'project-skill'); fs.mkdirSync(skill, { recursive: true }); fs.writeFileSync(path.join(skill, 'SKILL.md'), '---\nname: project-skill\ndescription: Project skill\n---\nInstructions'); let created; let harness; const service = setup(directory, { create: async (input) => { created = input; return { data: { id: 'ses_workspace' } }; }, promptAsync: async () => ({ response: { status: 204 } }) }, { runContactTurn: async (input) => { harness = input; return { text: 'ok', bubbles: ['ok'] }; } }); const assistant = service.createAssistant({ ...assistantInput, workspacePath: workspace, defaultPrompt: 'Base prompt' }); const current = await service.ensure(assistant.id);
|
|
399
|
+
await settleSend(service, assistant.id, { ...current, messageID: 'client_skill', parts: [{ type: 'text', text: 'hello' }] }); expect(created.directory).toBe(fs.realpathSync(workspace)); expect(harness.assistant.defaultPrompt).toBe('Base prompt'); expect(harness.assistant.defaultPrompt).not.toContain('project-skill'); service.close();
|
|
216
400
|
});
|
|
217
401
|
|
|
218
402
|
it('rejects retired skillRoots input', async () => {
|
|
@@ -242,46 +426,46 @@ describe('assistants service', () => {
|
|
|
242
426
|
service.close();
|
|
243
427
|
});
|
|
244
428
|
|
|
245
|
-
it('
|
|
246
|
-
let creates = 0;
|
|
247
|
-
const service = setup(root(), { create: async () => ({ data: { id: `ses_${++creates}` } })
|
|
429
|
+
it('keeps contact composer sends off the OpenCode session binding', async () => {
|
|
430
|
+
let creates = 0;
|
|
431
|
+
const service = setup(root(), { create: async () => ({ data: { id: `ses_${++creates}` } }) });
|
|
248
432
|
const assistant = service.createAssistant({ ...assistantInput, mode: 'stateless' });
|
|
249
433
|
const first = await service.ensure(assistant.id);
|
|
250
|
-
const sent = await service
|
|
251
|
-
expect(sent.binding.sessionID).
|
|
252
|
-
|
|
253
|
-
expect(
|
|
254
|
-
|
|
255
|
-
expect(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
expect(service.snapshot().assistants[0].historySessionIDs).toEqual([first.sessionID, sent.binding.sessionID]);
|
|
434
|
+
const sent = await settleSend(service, assistant.id, { ...first, messageID: 'stateless-1', parts: [{ type: 'text', text: 'one' }] });
|
|
435
|
+
expect(sent.binding.sessionID).toBe(first.sessionID);
|
|
436
|
+
const second = await settleSend(service, assistant.id, { ...sent.binding, messageID: 'stateless-2', parts: [{ type: 'text', text: 'two' }] });
|
|
437
|
+
expect(second.binding.sessionID).toBe(first.sessionID);
|
|
438
|
+
expect(creates).toBe(1);
|
|
439
|
+
expect(service.contactMessages(assistant.id).messages.map((message) => message.text)).toEqual([
|
|
440
|
+
'one', 'reply:one', 'two', 'reply:two',
|
|
441
|
+
]);
|
|
259
442
|
service.close();
|
|
260
443
|
});
|
|
261
444
|
|
|
262
|
-
it('persists every
|
|
263
|
-
|
|
264
|
-
const service = setup(directory, {
|
|
265
|
-
create: async () => ({ data: { id: `ses_${++creates}` } }),
|
|
266
|
-
promptAsync: async () => ({ response: { status: 204 } }),
|
|
267
|
-
}, { onRevisionTip: (tip) => tips.push(tip) });
|
|
445
|
+
it('persists every contact admission in Assistant SQLite before a restart', async () => {
|
|
446
|
+
const directory = root(); const tips = [];
|
|
447
|
+
const service = setup(directory, {}, { onRevisionTip: (tip) => tips.push(tip) });
|
|
268
448
|
const assistant = service.createAssistant({ ...assistantInput, mode: 'stateless' });
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
service.
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
[
|
|
276
|
-
[
|
|
449
|
+
await settleSend(service, assistant.id, { messageID: 'msg_stateless_1', parts: [{ type: 'text', text: 'one' }] });
|
|
450
|
+
await settleSend(service, assistant.id, { messageID: 'msg_stateless_2', parts: [{ type: 'text', text: 'two' }] });
|
|
451
|
+
const page = service.contactMessages(assistant.id, { limit: 10 });
|
|
452
|
+
expect(page.messages.map((message) => [message.messageID, message.text])).toEqual([
|
|
453
|
+
['msg_stateless_1', 'one'],
|
|
454
|
+
['msg_stateless_1:bubble:1', 'reply:one'],
|
|
455
|
+
['msg_stateless_2', 'two'],
|
|
456
|
+
['msg_stateless_2:bubble:1', 'reply:two'],
|
|
277
457
|
]);
|
|
278
|
-
expect(page.entries.every((entry) => entry.info.role === 'user')).toBe(true);
|
|
279
458
|
await new Promise((resolve) => setImmediate(resolve));
|
|
280
459
|
expect(tips.at(-1)?.revision).toBe(service.snapshot().revision);
|
|
281
460
|
service.close();
|
|
282
461
|
|
|
283
462
|
const restarted = setup(directory);
|
|
284
|
-
expect(
|
|
463
|
+
expect(restarted.contactMessages(assistant.id, { limit: 10 }).messages.map((message) => message.messageID)).toEqual([
|
|
464
|
+
'msg_stateless_1',
|
|
465
|
+
'msg_stateless_1:bubble:1',
|
|
466
|
+
'msg_stateless_2',
|
|
467
|
+
'msg_stateless_2:bubble:1',
|
|
468
|
+
]);
|
|
285
469
|
restarted.close();
|
|
286
470
|
});
|
|
287
471
|
|
|
@@ -325,14 +509,14 @@ describe('assistants service', () => {
|
|
|
325
509
|
});
|
|
326
510
|
|
|
327
511
|
it('keeps continuous composer sends on the same binding', async () => {
|
|
328
|
-
let creates = 0;
|
|
329
|
-
const service = setup(root(), { create: async () => ({ data: { id: `ses_${++creates}` } })
|
|
512
|
+
let creates = 0;
|
|
513
|
+
const service = setup(root(), { create: async () => ({ data: { id: `ses_${++creates}` } }) });
|
|
330
514
|
const assistant = service.createAssistant({ ...assistantInput, mode: 'continuous' });
|
|
331
515
|
const binding = await service.ensure(assistant.id);
|
|
332
|
-
const sent = await service
|
|
516
|
+
const sent = await settleSend(service, assistant.id, { ...binding, messageID: 'continuous-1', parts: [{ type: 'text', text: 'hello' }] });
|
|
333
517
|
expect(sent.binding).toEqual(binding);
|
|
334
|
-
expect(prompts[0]?.sessionID).toBe(binding.sessionID);
|
|
335
518
|
expect(creates).toBe(1);
|
|
519
|
+
expect(service.contactMessages(assistant.id).messages.map((message) => message.text)).toEqual(['hello', 'reply:hello']);
|
|
336
520
|
service.close();
|
|
337
521
|
});
|
|
338
522
|
|
|
@@ -574,13 +758,23 @@ describe('assistants service', () => {
|
|
|
574
758
|
const assistant = service.createAssistant({ ...assistantInput, mode: 'stateless' });
|
|
575
759
|
const initial = await service.ensure(assistant.id);
|
|
576
760
|
expect(initial.sessionID).toBe('ses_1');
|
|
577
|
-
//
|
|
578
|
-
|
|
761
|
+
// Contact send no longer replaces the OpenCode binding. Queued/share
|
|
762
|
+
// delivery still creates a fresh stateless execution session.
|
|
763
|
+
const scope = { sessionID: `assistant:${assistant.id}`, directory: initial.directory };
|
|
764
|
+
const first = await service.sendWithCapturedConfig({
|
|
765
|
+
deliveryTarget: service.captureQueueDeliveryTarget({ assistantID: assistant.id, scope }),
|
|
766
|
+
messageID: 'msg_user_1',
|
|
767
|
+
parts: [{ type: 'text', text: 'one' }],
|
|
768
|
+
});
|
|
579
769
|
expect(first.binding.sessionID).toBe('ses_2');
|
|
580
770
|
service.processEvent({ type: 'message.updated', properties: { info: { id: 'msg_user_1', sessionID: first.binding.sessionID, role: 'user', time: { created: 10 } } } });
|
|
581
771
|
service.processEvent({ type: 'message.updated', properties: { info: { id: 'msg_reply_1', sessionID: first.binding.sessionID, role: 'assistant', time: { created: 20 } } } });
|
|
582
772
|
service.processEvent({ type: 'message.part.updated', properties: { sessionID: first.binding.sessionID, part: { id: 'part_reply_1', sessionID: first.binding.sessionID, messageID: 'msg_reply_1', type: 'text', text: 'reply-one' } } });
|
|
583
|
-
await service.
|
|
773
|
+
await service.sendWithCapturedConfig({
|
|
774
|
+
deliveryTarget: service.captureQueueDeliveryTarget({ assistantID: assistant.id, scope }),
|
|
775
|
+
messageID: 'msg_user_2',
|
|
776
|
+
parts: [{ type: 'text', text: 'two' }],
|
|
777
|
+
});
|
|
584
778
|
const page = await service.historicalMessages(assistant.id, { limit: 10 });
|
|
585
779
|
expect(page.entries.map((entry) => [entry.sessionID, entry.info.id, entry.info.role])).toEqual(expect.arrayContaining([
|
|
586
780
|
[first.binding.sessionID, 'msg_user_1', 'user'],
|
|
@@ -645,6 +839,930 @@ describe('assistants service', () => {
|
|
|
645
839
|
service.close();
|
|
646
840
|
});
|
|
647
841
|
|
|
842
|
+
it('assigns a registered project session and persists the session card', async () => {
|
|
843
|
+
const directory = root();
|
|
844
|
+
const project = path.join(directory, 'app');
|
|
845
|
+
fs.mkdirSync(project, { recursive: true });
|
|
846
|
+
const creates = [];
|
|
847
|
+
const prompts = [];
|
|
848
|
+
const archives = [];
|
|
849
|
+
const service = setup(directory, {
|
|
850
|
+
create: async (input) => {
|
|
851
|
+
creates.push(input);
|
|
852
|
+
return { data: { id: `ses_${creates.length}` } };
|
|
853
|
+
},
|
|
854
|
+
promptAsync: async (input) => {
|
|
855
|
+
prompts.push(input);
|
|
856
|
+
return { response: { status: 204 } };
|
|
857
|
+
},
|
|
858
|
+
update: async (input) => {
|
|
859
|
+
archives.push(input);
|
|
860
|
+
return { data: { id: input.sessionID } };
|
|
861
|
+
},
|
|
862
|
+
}, {
|
|
863
|
+
runContactTurn: async ({ tools, userText }) => {
|
|
864
|
+
const assign = tools.find((tool) => tool.name === 'assign_session');
|
|
865
|
+
const result = await assign.execute('call_1', { prompt: userText, projectPath: project, title: 'Login' });
|
|
866
|
+
return {
|
|
867
|
+
text: 'Opened the login session.',
|
|
868
|
+
bubbles: ['Opened the login session.'],
|
|
869
|
+
cards: result.details.card ? [result.details.card] : [],
|
|
870
|
+
tools,
|
|
871
|
+
};
|
|
872
|
+
},
|
|
873
|
+
});
|
|
874
|
+
const assistant = service.createAssistant(assistantInput);
|
|
875
|
+
const sent = await settleSend(service, assistant.id, {
|
|
876
|
+
messageID: 'client_assign',
|
|
877
|
+
parts: [{ type: 'text', text: 'Fix login' }],
|
|
878
|
+
});
|
|
879
|
+
expect(sent.admitted).toBe(true);
|
|
880
|
+
expect(creates).toEqual([expect.objectContaining({
|
|
881
|
+
directory: fs.realpathSync(project),
|
|
882
|
+
title: 'Login',
|
|
883
|
+
})]);
|
|
884
|
+
expect(creates[0].title).not.toMatch(/^\[Assistant\]/);
|
|
885
|
+
expect(prompts).toEqual([expect.objectContaining({
|
|
886
|
+
sessionID: 'ses_1',
|
|
887
|
+
directory: fs.realpathSync(project),
|
|
888
|
+
parts: [{ type: 'text', text: 'Fix login' }],
|
|
889
|
+
})]);
|
|
890
|
+
expect(archives).toEqual([]);
|
|
891
|
+
const page = service.contactMessages(assistant.id);
|
|
892
|
+
expect(page.messages.map((message) => message.role)).toEqual(['user', 'assistant', 'assistant']);
|
|
893
|
+
expect(page.messages.some((message) => message.role === 'peer')).toBe(false);
|
|
894
|
+
expect(page.messages.at(-1).parts[0]).toMatchObject({
|
|
895
|
+
type: 'card',
|
|
896
|
+
cardType: 'session',
|
|
897
|
+
sessionID: 'ses_1',
|
|
898
|
+
directory: fs.realpathSync(project),
|
|
899
|
+
title: 'Login',
|
|
900
|
+
});
|
|
901
|
+
service.close();
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
it('creates another assistant from create_assistant and persists the assistant card', async () => {
|
|
905
|
+
const directory = root();
|
|
906
|
+
const service = setup(directory, {}, {
|
|
907
|
+
runContactTurn: async ({ tools }) => {
|
|
908
|
+
const create = tools.find((tool) => tool.name === 'create_assistant');
|
|
909
|
+
const result = await create.execute('call_1', { name: 'FlowQA', model: 'opencode-go/deepseek-v4-flash' });
|
|
910
|
+
return {
|
|
911
|
+
text: 'Created FlowQA.',
|
|
912
|
+
bubbles: ['Created FlowQA.'],
|
|
913
|
+
cards: result.details.card ? [result.details.card] : [],
|
|
914
|
+
tools,
|
|
915
|
+
};
|
|
916
|
+
},
|
|
917
|
+
});
|
|
918
|
+
const host = service.createAssistant(assistantInput);
|
|
919
|
+
await settleSend(service, host.id, {
|
|
920
|
+
messageID: 'client_create_assistant',
|
|
921
|
+
parts: [{ type: 'text', text: '建一个助理叫 FlowQA,模型 opencode-go/deepseek-v4-flash' }],
|
|
922
|
+
});
|
|
923
|
+
const created = service.snapshot().assistants.find((item) => item.name === 'FlowQA');
|
|
924
|
+
expect(created).toMatchObject({
|
|
925
|
+
name: 'FlowQA',
|
|
926
|
+
providerID: 'opencode-go',
|
|
927
|
+
modelID: 'deepseek-v4-flash',
|
|
928
|
+
mode: 'continuous',
|
|
929
|
+
});
|
|
930
|
+
const page = service.contactMessages(host.id);
|
|
931
|
+
expect(page.messages.some((message) => message.parts.some((part) => (
|
|
932
|
+
part.type === 'card' && part.cardType === 'assistant' && part.name === 'FlowQA' && part.assistantID === created.id
|
|
933
|
+
)))).toBe(true);
|
|
934
|
+
service.close();
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
it('creates a scheduled task from schedule_task and persists the schedule card', async () => {
|
|
938
|
+
const directory = root();
|
|
939
|
+
const upserts = [];
|
|
940
|
+
const service = setup(directory, {}, {
|
|
941
|
+
listProjects: async () => [{ id: 'proj_app', path: directory }],
|
|
942
|
+
upsertScheduledTask: async (projectID, task) => {
|
|
943
|
+
upserts.push({ projectID, task });
|
|
944
|
+
return {
|
|
945
|
+
created: true,
|
|
946
|
+
task: {
|
|
947
|
+
id: 'task_ping',
|
|
948
|
+
name: task.name,
|
|
949
|
+
schedule: task.schedule,
|
|
950
|
+
execution: task.execution,
|
|
951
|
+
},
|
|
952
|
+
tasks: [],
|
|
953
|
+
};
|
|
954
|
+
},
|
|
955
|
+
syncScheduledTaskProject: async () => true,
|
|
956
|
+
runContactTurn: async ({ tools }) => {
|
|
957
|
+
const schedule = tools.find((tool) => tool.name === 'schedule_task');
|
|
958
|
+
const result = await schedule.execute('call_1', {
|
|
959
|
+
name: 'Daily ping',
|
|
960
|
+
prompt: 'ping',
|
|
961
|
+
time: '18:00',
|
|
962
|
+
timezone: 'Asia/Shanghai',
|
|
963
|
+
});
|
|
964
|
+
return {
|
|
965
|
+
text: 'Scheduled the ping.',
|
|
966
|
+
bubbles: ['Scheduled the ping.'],
|
|
967
|
+
cards: result.details.card ? [result.details.card] : [],
|
|
968
|
+
tools,
|
|
969
|
+
};
|
|
970
|
+
},
|
|
971
|
+
});
|
|
972
|
+
const host = service.createAssistant(assistantInput);
|
|
973
|
+
await settleSend(service, host.id, {
|
|
974
|
+
messageID: 'client_schedule',
|
|
975
|
+
parts: [{ type: 'text', text: '每天 18:00 Asia/Shanghai 排一个 ping 定时任务' }],
|
|
976
|
+
});
|
|
977
|
+
expect(upserts).toEqual([expect.objectContaining({
|
|
978
|
+
projectID: 'proj_app',
|
|
979
|
+
task: expect.objectContaining({
|
|
980
|
+
name: 'Daily ping',
|
|
981
|
+
enabled: true,
|
|
982
|
+
schedule: expect.objectContaining({
|
|
983
|
+
kind: 'daily',
|
|
984
|
+
time: '18:00',
|
|
985
|
+
timezone: 'Asia/Shanghai',
|
|
986
|
+
}),
|
|
987
|
+
execution: expect.objectContaining({
|
|
988
|
+
prompt: 'ping',
|
|
989
|
+
providerID: 'p',
|
|
990
|
+
modelID: 'm',
|
|
991
|
+
}),
|
|
992
|
+
}),
|
|
993
|
+
})]);
|
|
994
|
+
const page = service.contactMessages(host.id);
|
|
995
|
+
expect(page.messages.some((message) => message.parts.some((part) => (
|
|
996
|
+
part.type === 'card' && part.cardType === 'schedule' && part.taskID === 'task_ping' && part.time === '18:00'
|
|
997
|
+
)))).toBe(true);
|
|
998
|
+
expect(service.snapshot().assistants[0].assignedSessionIDs).toEqual([]);
|
|
999
|
+
const owned = await service.listAssistantScheduledTasks(host.id);
|
|
1000
|
+
expect(owned.tasks).toEqual([expect.objectContaining({
|
|
1001
|
+
assistantID: host.id,
|
|
1002
|
+
projectID: 'proj_app',
|
|
1003
|
+
taskID: 'task_ping',
|
|
1004
|
+
task: null,
|
|
1005
|
+
})]);
|
|
1006
|
+
service.close();
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
it('injects registered projects into the contact turn and assigns after a name match', async () => {
|
|
1010
|
+
const directory = root();
|
|
1011
|
+
const project = path.join(directory, 'sample-app');
|
|
1012
|
+
fs.mkdirSync(project, { recursive: true });
|
|
1013
|
+
const creates = [];
|
|
1014
|
+
const harness = [];
|
|
1015
|
+
const service = setup(directory, {
|
|
1016
|
+
create: async (input) => {
|
|
1017
|
+
creates.push(input);
|
|
1018
|
+
return { data: { id: 'ses_named' } };
|
|
1019
|
+
},
|
|
1020
|
+
promptAsync: async () => ({ response: { status: 204 } }),
|
|
1021
|
+
}, {
|
|
1022
|
+
listProjects: async () => [{ id: 'proj_yee', path: project, label: 'OpenChamber Yee' }],
|
|
1023
|
+
getAllowedRoots: () => [project, directory],
|
|
1024
|
+
runContactTurn: async (input) => {
|
|
1025
|
+
harness.push(input);
|
|
1026
|
+
expect(input.projects).toEqual([
|
|
1027
|
+
{ id: 'proj_yee', path: project, label: 'OpenChamber Yee' },
|
|
1028
|
+
]);
|
|
1029
|
+
expect(input.tools.map((tool) => tool.name)).toEqual(expect.arrayContaining([
|
|
1030
|
+
'list_projects',
|
|
1031
|
+
'list_sessions',
|
|
1032
|
+
'assign_session',
|
|
1033
|
+
]));
|
|
1034
|
+
const listed = await input.tools.find((tool) => tool.name === 'list_projects').execute('call_p', {
|
|
1035
|
+
query: 'openchamer yee',
|
|
1036
|
+
});
|
|
1037
|
+
expect(listed.details.projects).toEqual([
|
|
1038
|
+
{ id: 'proj_yee', path: project, label: 'OpenChamber Yee' },
|
|
1039
|
+
]);
|
|
1040
|
+
const assign = input.tools.find((tool) => tool.name === 'assign_session');
|
|
1041
|
+
const result = await assign.execute('call_a', {
|
|
1042
|
+
prompt: 'Start coding',
|
|
1043
|
+
projectPath: listed.details.projects[0].path,
|
|
1044
|
+
title: 'Yee work',
|
|
1045
|
+
});
|
|
1046
|
+
return {
|
|
1047
|
+
text: 'Opened on OpenChamber Yee.',
|
|
1048
|
+
bubbles: ['Opened on OpenChamber Yee.'],
|
|
1049
|
+
cards: result.details.card ? [result.details.card] : [],
|
|
1050
|
+
};
|
|
1051
|
+
},
|
|
1052
|
+
});
|
|
1053
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1054
|
+
await settleSend(service, assistant.id, {
|
|
1055
|
+
messageID: 'client_named_project',
|
|
1056
|
+
parts: [{ type: 'text', text: '你看看 openchamer yee 项目,在那里开个新会话' }],
|
|
1057
|
+
});
|
|
1058
|
+
expect(harness).toHaveLength(1);
|
|
1059
|
+
expect(creates[0]).toMatchObject({
|
|
1060
|
+
directory: fs.realpathSync(project),
|
|
1061
|
+
title: 'Yee work',
|
|
1062
|
+
});
|
|
1063
|
+
service.close();
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
it('list_sessions failure is not an empty success', async () => {
|
|
1067
|
+
const directory = root();
|
|
1068
|
+
const service = setup(directory, {}, {
|
|
1069
|
+
listProjects: async () => [{ id: 'proj_app', path: directory, label: 'App' }],
|
|
1070
|
+
sessionIndexService: {
|
|
1071
|
+
snapshot: () => {
|
|
1072
|
+
throw new Error('index down');
|
|
1073
|
+
},
|
|
1074
|
+
},
|
|
1075
|
+
runContactTurn: async ({ tools }) => {
|
|
1076
|
+
const list = tools.find((tool) => tool.name === 'list_sessions');
|
|
1077
|
+
const result = await list.execute('call_s', { projectPath: directory });
|
|
1078
|
+
return {
|
|
1079
|
+
text: result.content[0].text,
|
|
1080
|
+
bubbles: [result.content[0].text],
|
|
1081
|
+
cards: [],
|
|
1082
|
+
details: result.details,
|
|
1083
|
+
};
|
|
1084
|
+
},
|
|
1085
|
+
});
|
|
1086
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1087
|
+
await settleSend(service, assistant.id, {
|
|
1088
|
+
messageID: 'client_list_sessions_fail',
|
|
1089
|
+
parts: [{ type: 'text', text: '现有对话' }],
|
|
1090
|
+
});
|
|
1091
|
+
const page = service.contactMessages(assistant.id);
|
|
1092
|
+
expect(page.messages.at(-1).text).toContain('index down');
|
|
1093
|
+
service.close();
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
it('lists sessions from the session index for a project', async () => {
|
|
1097
|
+
const directory = root();
|
|
1098
|
+
const service = setup(directory, {}, {
|
|
1099
|
+
listProjects: async () => [{ id: 'proj_app', path: directory, label: 'App' }],
|
|
1100
|
+
sessionIndexService: {
|
|
1101
|
+
snapshot: () => ({
|
|
1102
|
+
directories: [
|
|
1103
|
+
{
|
|
1104
|
+
directory,
|
|
1105
|
+
sessions: [
|
|
1106
|
+
{ id: 'ses_old', title: 'Old', time: { updated: 1 } },
|
|
1107
|
+
{ id: 'ses_new', title: 'Login work', time: { updated: 9 } },
|
|
1108
|
+
],
|
|
1109
|
+
},
|
|
1110
|
+
{
|
|
1111
|
+
directory: path.join(directory, 'other'),
|
|
1112
|
+
sessions: [{ id: 'ses_other', title: 'Other', time: { updated: 20 } }],
|
|
1113
|
+
},
|
|
1114
|
+
],
|
|
1115
|
+
}),
|
|
1116
|
+
},
|
|
1117
|
+
runContactTurn: async ({ tools }) => {
|
|
1118
|
+
const list = tools.find((tool) => tool.name === 'list_sessions');
|
|
1119
|
+
const result = await list.execute('call_s', { projectPath: directory, query: 'login' });
|
|
1120
|
+
return {
|
|
1121
|
+
text: result.content[0].text,
|
|
1122
|
+
bubbles: [result.content[0].text],
|
|
1123
|
+
cards: [],
|
|
1124
|
+
details: result.details,
|
|
1125
|
+
};
|
|
1126
|
+
},
|
|
1127
|
+
});
|
|
1128
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1129
|
+
await settleSend(service, assistant.id, {
|
|
1130
|
+
messageID: 'client_list_sessions_ok',
|
|
1131
|
+
parts: [{ type: 'text', text: '现有对话 login' }],
|
|
1132
|
+
});
|
|
1133
|
+
const page = service.contactMessages(assistant.id);
|
|
1134
|
+
expect(page.messages.at(-1).text).toContain('ses_new');
|
|
1135
|
+
expect(page.messages.at(-1).text).not.toContain('ses_other');
|
|
1136
|
+
service.close();
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
it('keeps assistant scheduled-task mapping when live project lookup fails', async () => {
|
|
1140
|
+
const directory = root();
|
|
1141
|
+
let failLive = false;
|
|
1142
|
+
const service = setup(directory, {}, {
|
|
1143
|
+
listProjects: async () => {
|
|
1144
|
+
if (failLive) throw new Error('projects unavailable');
|
|
1145
|
+
return [{ id: 'proj_app', path: directory, label: 'App' }];
|
|
1146
|
+
},
|
|
1147
|
+
listScheduledTasks: async () => {
|
|
1148
|
+
if (failLive) throw new Error('tasks unavailable');
|
|
1149
|
+
return [{ id: 'task_ping', name: 'Daily ping', enabled: true }];
|
|
1150
|
+
},
|
|
1151
|
+
upsertScheduledTask: async (_projectID, task) => ({
|
|
1152
|
+
created: true,
|
|
1153
|
+
task: { id: 'task_ping', name: task.name, schedule: task.schedule, execution: task.execution },
|
|
1154
|
+
tasks: [],
|
|
1155
|
+
}),
|
|
1156
|
+
runContactTurn: async ({ tools }) => {
|
|
1157
|
+
const schedule = tools.find((tool) => tool.name === 'schedule_task');
|
|
1158
|
+
const result = await schedule.execute('call_1', {
|
|
1159
|
+
name: 'Daily ping',
|
|
1160
|
+
prompt: 'ping',
|
|
1161
|
+
time: '18:00',
|
|
1162
|
+
timezone: 'Asia/Shanghai',
|
|
1163
|
+
});
|
|
1164
|
+
return {
|
|
1165
|
+
text: 'Scheduled.',
|
|
1166
|
+
bubbles: ['Scheduled.'],
|
|
1167
|
+
cards: result.details.card ? [result.details.card] : [],
|
|
1168
|
+
};
|
|
1169
|
+
},
|
|
1170
|
+
});
|
|
1171
|
+
const host = service.createAssistant(assistantInput);
|
|
1172
|
+
await settleSend(service, host.id, {
|
|
1173
|
+
messageID: 'client_schedule_map',
|
|
1174
|
+
parts: [{ type: 'text', text: '排定时任务' }],
|
|
1175
|
+
});
|
|
1176
|
+
const live = await service.listAssistantScheduledTasks(host.id);
|
|
1177
|
+
expect(live.tasks[0]).toMatchObject({
|
|
1178
|
+
taskID: 'task_ping',
|
|
1179
|
+
projectLabel: 'App',
|
|
1180
|
+
task: expect.objectContaining({ id: 'task_ping', name: 'Daily ping' }),
|
|
1181
|
+
});
|
|
1182
|
+
failLive = true;
|
|
1183
|
+
const kept = await service.listAssistantScheduledTasks(host.id);
|
|
1184
|
+
expect(kept.tasks).toEqual([expect.objectContaining({
|
|
1185
|
+
taskID: 'task_ping',
|
|
1186
|
+
projectID: 'proj_app',
|
|
1187
|
+
projectPath: null,
|
|
1188
|
+
projectLabel: null,
|
|
1189
|
+
task: null,
|
|
1190
|
+
})]);
|
|
1191
|
+
service.close();
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
it('appends assistant and schedule cards without creating session watches', () => {
|
|
1195
|
+
const directory = root();
|
|
1196
|
+
const service = setup(directory);
|
|
1197
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1198
|
+
service.appendContactCard(assistant.id, {
|
|
1199
|
+
cardType: 'assistant',
|
|
1200
|
+
assistantID: 'asst_flow',
|
|
1201
|
+
name: 'FlowQA',
|
|
1202
|
+
providerID: 'opencode-go',
|
|
1203
|
+
modelID: 'deepseek-v4-flash',
|
|
1204
|
+
mode: 'continuous',
|
|
1205
|
+
});
|
|
1206
|
+
service.appendContactCard(assistant.id, {
|
|
1207
|
+
cardType: 'schedule',
|
|
1208
|
+
taskID: 'task_ping',
|
|
1209
|
+
projectID: 'proj_app',
|
|
1210
|
+
name: 'Daily ping',
|
|
1211
|
+
kind: 'daily',
|
|
1212
|
+
time: '18:00',
|
|
1213
|
+
timezone: 'Asia/Shanghai',
|
|
1214
|
+
prompt: 'ping',
|
|
1215
|
+
});
|
|
1216
|
+
expect(service.snapshot().assistants[0].assignedSessionIDs).toEqual([]);
|
|
1217
|
+
expect(service.snapshot().assistants[0].working).toBe(false);
|
|
1218
|
+
const page = service.contactMessages(assistant.id);
|
|
1219
|
+
expect(page.messages.map((message) => message.parts[0]?.cardType)).toEqual(['assistant', 'schedule']);
|
|
1220
|
+
service.close();
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
it('fails assign clearly when no project is registered and does not use assistant-workspaces', async () => {
|
|
1224
|
+
const directory = root();
|
|
1225
|
+
const creates = [];
|
|
1226
|
+
const prompts = [];
|
|
1227
|
+
const service = setup(directory, {
|
|
1228
|
+
create: async (input) => {
|
|
1229
|
+
creates.push(input);
|
|
1230
|
+
return { data: { id: 'ses_should_not' } };
|
|
1231
|
+
},
|
|
1232
|
+
promptAsync: async (input) => {
|
|
1233
|
+
prompts.push(input);
|
|
1234
|
+
return { response: { status: 204 } };
|
|
1235
|
+
},
|
|
1236
|
+
}, {
|
|
1237
|
+
getAllowedRoots: () => [],
|
|
1238
|
+
runContactTurn: async ({ tools }) => {
|
|
1239
|
+
const assign = tools.find((tool) => tool.name === 'assign_session');
|
|
1240
|
+
const result = await assign.execute('call_1', { prompt: 'Fix login' });
|
|
1241
|
+
return {
|
|
1242
|
+
text: result.content[0].text,
|
|
1243
|
+
bubbles: [result.content[0].text],
|
|
1244
|
+
cards: result.details.card ? [result.details.card] : [],
|
|
1245
|
+
tools,
|
|
1246
|
+
};
|
|
1247
|
+
},
|
|
1248
|
+
});
|
|
1249
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1250
|
+
await settleSend(service, assistant.id, {
|
|
1251
|
+
messageID: 'client_no_project',
|
|
1252
|
+
parts: [{ type: 'text', text: 'Fix login' }],
|
|
1253
|
+
});
|
|
1254
|
+
expect(creates).toEqual([]);
|
|
1255
|
+
expect(prompts).toEqual([]);
|
|
1256
|
+
const page = service.contactMessages(assistant.id);
|
|
1257
|
+
expect(page.messages.some((message) => message.parts.some((part) => part.type === 'card'))).toBe(false);
|
|
1258
|
+
expect(page.messages.at(-1).text).toContain('Add a project in Settings');
|
|
1259
|
+
expect(page.messages.at(-1).text).toContain('assistant-workspaces');
|
|
1260
|
+
expect(page.messages.some((message) => message.role === 'peer')).toBe(false);
|
|
1261
|
+
service.close();
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
it('subscribes assigned sessions so idle updates the card, appends a settle message, and clears working', () => {
|
|
1265
|
+
const directory = root();
|
|
1266
|
+
const service = setup(directory);
|
|
1267
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1268
|
+
service.appendContactCard(assistant.id, {
|
|
1269
|
+
cardType: 'session',
|
|
1270
|
+
sessionID: 'ses_work',
|
|
1271
|
+
directory,
|
|
1272
|
+
title: 'Login',
|
|
1273
|
+
status: 'busy',
|
|
1274
|
+
});
|
|
1275
|
+
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
1276
|
+
assignedSessionIDs: ['ses_work'],
|
|
1277
|
+
working: true,
|
|
1278
|
+
});
|
|
1279
|
+
expect(service.processEvent({ type: 'session.idle', properties: { sessionID: 'ses_work' } })).toBe(true);
|
|
1280
|
+
const page = service.contactMessages(assistant.id);
|
|
1281
|
+
expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0]).toMatchObject({
|
|
1282
|
+
type: 'card',
|
|
1283
|
+
cardType: 'session',
|
|
1284
|
+
sessionID: 'ses_work',
|
|
1285
|
+
status: 'complete',
|
|
1286
|
+
});
|
|
1287
|
+
expect(page.messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(1);
|
|
1288
|
+
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
1289
|
+
assignedSessionIDs: [],
|
|
1290
|
+
working: false,
|
|
1291
|
+
});
|
|
1292
|
+
expect(service.processEvent({ type: 'session.idle', properties: { sessionID: 'ses_work' } })).toBe(false);
|
|
1293
|
+
expect(service.contactMessages(assistant.id).messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(1);
|
|
1294
|
+
service.close();
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
it('reconciles a missed assigned-session idle on boot and the 60s timer', async () => {
|
|
1298
|
+
const directory = root();
|
|
1299
|
+
const first = setup(directory);
|
|
1300
|
+
const assistant = first.createAssistant(assistantInput);
|
|
1301
|
+
first.appendContactCard(assistant.id, {
|
|
1302
|
+
cardType: 'session',
|
|
1303
|
+
sessionID: 'ses_missed',
|
|
1304
|
+
directory,
|
|
1305
|
+
title: 'Login',
|
|
1306
|
+
status: 'busy',
|
|
1307
|
+
});
|
|
1308
|
+
expect(first.snapshot().assistants[0]).toMatchObject({
|
|
1309
|
+
assignedSessionIDs: ['ses_missed'],
|
|
1310
|
+
working: true,
|
|
1311
|
+
});
|
|
1312
|
+
first.close();
|
|
1313
|
+
|
|
1314
|
+
let tick;
|
|
1315
|
+
const service = setup(directory, {
|
|
1316
|
+
get: async ({ sessionID }) => (
|
|
1317
|
+
sessionID === 'ses_missed' || sessionID === 'ses_timer'
|
|
1318
|
+
? { data: { id: sessionID, status: { type: 'idle' } } }
|
|
1319
|
+
: { data: { id: sessionID } }
|
|
1320
|
+
),
|
|
1321
|
+
messages: async ({ sessionID }) => (
|
|
1322
|
+
sessionID === 'ses_missed' || sessionID === 'ses_timer'
|
|
1323
|
+
? { data: [{ info: { id: `msg_${sessionID}`, role: 'assistant', time: { completed: 1 } } }] }
|
|
1324
|
+
: { data: [] }
|
|
1325
|
+
),
|
|
1326
|
+
}, {
|
|
1327
|
+
setIntervalFn: (fn) => {
|
|
1328
|
+
tick = fn;
|
|
1329
|
+
return 1;
|
|
1330
|
+
},
|
|
1331
|
+
});
|
|
1332
|
+
await service.reconcile();
|
|
1333
|
+
let page = service.contactMessages(assistant.id);
|
|
1334
|
+
expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0].status).toBe('complete');
|
|
1335
|
+
expect(page.messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(1);
|
|
1336
|
+
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
1337
|
+
assignedSessionIDs: [],
|
|
1338
|
+
working: false,
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
service.appendContactCard(assistant.id, {
|
|
1342
|
+
cardType: 'session',
|
|
1343
|
+
sessionID: 'ses_timer',
|
|
1344
|
+
directory,
|
|
1345
|
+
title: 'Follow-up',
|
|
1346
|
+
status: 'busy',
|
|
1347
|
+
});
|
|
1348
|
+
expect(service.snapshot().assistants[0].working).toBe(true);
|
|
1349
|
+
await tick();
|
|
1350
|
+
page = service.contactMessages(assistant.id);
|
|
1351
|
+
expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card' && part.sessionID === 'ses_timer'))?.parts[0].status).toBe('complete');
|
|
1352
|
+
expect(page.messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(2);
|
|
1353
|
+
expect(service.snapshot().assistants[0].working).toBe(false);
|
|
1354
|
+
service.close();
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
it('reconciles a missing assigned session as complete and an assistant error as failed', async () => {
|
|
1358
|
+
const directory = root();
|
|
1359
|
+
const service = setup(directory, {
|
|
1360
|
+
get: async ({ sessionID }) => {
|
|
1361
|
+
if (sessionID === 'ses_gone') return { error: { status: 404 } };
|
|
1362
|
+
if (sessionID === 'ses_fail') return { data: { id: 'ses_fail' } };
|
|
1363
|
+
return { data: { id: sessionID } };
|
|
1364
|
+
},
|
|
1365
|
+
messages: async ({ sessionID }) => {
|
|
1366
|
+
if (sessionID === 'ses_fail') return { data: [{ info: { id: 'msg_fail', role: 'assistant', error: { message: 'boom' } } }] };
|
|
1367
|
+
return { data: [] };
|
|
1368
|
+
},
|
|
1369
|
+
});
|
|
1370
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1371
|
+
service.appendContactCard(assistant.id, {
|
|
1372
|
+
cardType: 'session',
|
|
1373
|
+
sessionID: 'ses_gone',
|
|
1374
|
+
directory,
|
|
1375
|
+
title: 'Gone',
|
|
1376
|
+
status: 'busy',
|
|
1377
|
+
});
|
|
1378
|
+
service.appendContactCard(assistant.id, {
|
|
1379
|
+
cardType: 'session',
|
|
1380
|
+
sessionID: 'ses_fail',
|
|
1381
|
+
directory,
|
|
1382
|
+
title: 'Fail',
|
|
1383
|
+
status: 'busy',
|
|
1384
|
+
});
|
|
1385
|
+
await service.reconcile();
|
|
1386
|
+
const cards = service.contactMessages(assistant.id).messages
|
|
1387
|
+
.filter((message) => message.parts.some((part) => part.type === 'card'))
|
|
1388
|
+
.map((message) => message.parts[0]);
|
|
1389
|
+
expect(cards.find((card) => card.sessionID === 'ses_gone')?.status).toBe('complete');
|
|
1390
|
+
expect(cards.find((card) => card.sessionID === 'ses_fail')?.status).toBe('error');
|
|
1391
|
+
const texts = service.contactMessages(assistant.id).messages.map((message) => message.text);
|
|
1392
|
+
expect(texts.filter((text) => text === 'oc.settle.complete')).toHaveLength(1);
|
|
1393
|
+
expect(texts.filter((text) => text === 'oc.settle.error')).toHaveLength(1);
|
|
1394
|
+
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
1395
|
+
assignedSessionIDs: [],
|
|
1396
|
+
working: false,
|
|
1397
|
+
});
|
|
1398
|
+
await service.reconcile();
|
|
1399
|
+
expect(service.contactMessages(assistant.id).messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(1);
|
|
1400
|
+
expect(service.contactMessages(assistant.id).messages.filter((message) => message.text === 'oc.settle.error')).toHaveLength(1);
|
|
1401
|
+
service.close();
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
it('does not settle in-flight watches on fetch failure or while the session is still running', async () => {
|
|
1405
|
+
const directory = root();
|
|
1406
|
+
const service = setup(directory, {
|
|
1407
|
+
get: async ({ sessionID }) => {
|
|
1408
|
+
if (sessionID === 'ses_down') throw new Error('network');
|
|
1409
|
+
if (sessionID === 'ses_busy') return { data: { id: 'ses_busy', status: { type: 'busy' } } };
|
|
1410
|
+
if (sessionID === 'ses_idle') return { data: { id: 'ses_idle', status: { type: 'idle' } } };
|
|
1411
|
+
return { data: { id: sessionID } };
|
|
1412
|
+
},
|
|
1413
|
+
messages: async ({ sessionID }) => {
|
|
1414
|
+
if (sessionID === 'ses_idle') return { data: [{ info: { id: 'msg_idle', role: 'assistant', time: { completed: 1 } } }] };
|
|
1415
|
+
if (sessionID === 'ses_busy') return { data: [{ info: { id: 'msg_busy', role: 'assistant' } }] };
|
|
1416
|
+
throw new Error('should not settle from messages after get failure');
|
|
1417
|
+
},
|
|
1418
|
+
});
|
|
1419
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1420
|
+
service.appendContactCard(assistant.id, {
|
|
1421
|
+
cardType: 'session',
|
|
1422
|
+
sessionID: 'ses_down',
|
|
1423
|
+
directory,
|
|
1424
|
+
title: 'Down',
|
|
1425
|
+
status: 'busy',
|
|
1426
|
+
});
|
|
1427
|
+
service.appendContactCard(assistant.id, {
|
|
1428
|
+
cardType: 'session',
|
|
1429
|
+
sessionID: 'ses_busy',
|
|
1430
|
+
directory,
|
|
1431
|
+
title: 'Busy',
|
|
1432
|
+
status: 'busy',
|
|
1433
|
+
});
|
|
1434
|
+
service.appendContactCard(assistant.id, {
|
|
1435
|
+
cardType: 'session',
|
|
1436
|
+
sessionID: 'ses_idle',
|
|
1437
|
+
directory,
|
|
1438
|
+
title: 'Idle',
|
|
1439
|
+
status: 'busy',
|
|
1440
|
+
});
|
|
1441
|
+
await service.reconcile();
|
|
1442
|
+
const cards = Object.fromEntries(service.contactMessages(assistant.id).messages
|
|
1443
|
+
.filter((message) => message.parts.some((part) => part.type === 'card'))
|
|
1444
|
+
.map((message) => [message.parts[0].sessionID, message.parts[0].status]));
|
|
1445
|
+
expect(cards).toMatchObject({
|
|
1446
|
+
ses_down: 'busy',
|
|
1447
|
+
ses_busy: 'busy',
|
|
1448
|
+
ses_idle: 'complete',
|
|
1449
|
+
});
|
|
1450
|
+
expect(service.contactMessages(assistant.id).messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(1);
|
|
1451
|
+
expect(service.snapshot().assistants[0].assignedSessionIDs.sort()).toEqual(['ses_busy', 'ses_down']);
|
|
1452
|
+
expect(service.snapshot().assistants[0].working).toBe(true);
|
|
1453
|
+
service.close();
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
it('does not rewrite a reconciled session.error as complete on a later idle poll', async () => {
|
|
1457
|
+
const directory = root();
|
|
1458
|
+
let idle = false;
|
|
1459
|
+
const service = setup(directory, {
|
|
1460
|
+
get: async ({ sessionID }) => (
|
|
1461
|
+
sessionID === 'ses_err'
|
|
1462
|
+
? { data: idle ? { id: 'ses_err', status: { type: 'idle' } } : { id: 'ses_err', error: { message: 'boom' } } }
|
|
1463
|
+
: { data: { id: sessionID } }
|
|
1464
|
+
),
|
|
1465
|
+
messages: async () => ({ data: [{ info: { id: 'msg_err', role: 'assistant', time: { completed: 1 } } }] }),
|
|
1466
|
+
});
|
|
1467
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1468
|
+
service.appendContactCard(assistant.id, {
|
|
1469
|
+
cardType: 'session',
|
|
1470
|
+
sessionID: 'ses_err',
|
|
1471
|
+
directory,
|
|
1472
|
+
title: 'Err',
|
|
1473
|
+
status: 'busy',
|
|
1474
|
+
});
|
|
1475
|
+
await service.reconcile();
|
|
1476
|
+
expect(service.contactMessages(assistant.id).messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0].status).toBe('error');
|
|
1477
|
+
expect(service.contactMessages(assistant.id).messages.some((message) => message.text === 'oc.settle.error')).toBe(true);
|
|
1478
|
+
idle = true;
|
|
1479
|
+
await service.reconcile();
|
|
1480
|
+
expect(service.contactMessages(assistant.id).messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0].status).toBe('error');
|
|
1481
|
+
expect(service.contactMessages(assistant.id).messages.some((message) => message.text === 'oc.settle.complete')).toBe(false);
|
|
1482
|
+
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
1483
|
+
assignedSessionIDs: [],
|
|
1484
|
+
working: false,
|
|
1485
|
+
});
|
|
1486
|
+
service.close();
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
it('accepts a session-goal settle into the same card without mutating the worker', () => {
|
|
1490
|
+
const directory = root();
|
|
1491
|
+
const service = setup(directory);
|
|
1492
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1493
|
+
service.appendContactCard(assistant.id, {
|
|
1494
|
+
cardType: 'session',
|
|
1495
|
+
sessionID: 'ses_goal',
|
|
1496
|
+
directory,
|
|
1497
|
+
title: 'Login',
|
|
1498
|
+
status: 'busy',
|
|
1499
|
+
});
|
|
1500
|
+
expect(service.reportAssignedSessionSettle('ses_goal', 'complete')).toBe(true);
|
|
1501
|
+
const page = service.contactMessages(assistant.id);
|
|
1502
|
+
expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0].status).toBe('complete');
|
|
1503
|
+
expect(page.messages.some((message) => message.text === 'oc.settle.complete')).toBe(true);
|
|
1504
|
+
expect(service.snapshot().assistants[0].working).toBe(false);
|
|
1505
|
+
service.close();
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
it('maps question and error onto the same card and does not rewrite error as complete', () => {
|
|
1509
|
+
const directory = root();
|
|
1510
|
+
const service = setup(directory);
|
|
1511
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1512
|
+
service.appendContactCard(assistant.id, {
|
|
1513
|
+
cardType: 'session',
|
|
1514
|
+
sessionID: 'ses_ask',
|
|
1515
|
+
directory,
|
|
1516
|
+
title: 'Login',
|
|
1517
|
+
status: 'busy',
|
|
1518
|
+
});
|
|
1519
|
+
expect(service.processEvent({ type: 'question.asked', properties: { sessionID: 'ses_ask' } })).toBe(true);
|
|
1520
|
+
let page = service.contactMessages(assistant.id);
|
|
1521
|
+
expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0].status).toBe('question');
|
|
1522
|
+
expect(page.messages.some((message) => message.text === 'oc.settle.question')).toBe(true);
|
|
1523
|
+
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
1524
|
+
assignedSessionIDs: ['ses_ask'],
|
|
1525
|
+
working: false,
|
|
1526
|
+
});
|
|
1527
|
+
expect(service.processEvent({ type: 'session.error', properties: { sessionID: 'ses_ask' } })).toBe(true);
|
|
1528
|
+
page = service.contactMessages(assistant.id);
|
|
1529
|
+
expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0].status).toBe('error');
|
|
1530
|
+
expect(page.messages.some((message) => message.text === 'oc.settle.error')).toBe(true);
|
|
1531
|
+
expect(service.processEvent({ type: 'session.idle', properties: { sessionID: 'ses_ask' } })).toBe(false);
|
|
1532
|
+
expect(service.processEvent({ type: 'session.status', properties: { sessionID: 'ses_ask', status: { type: 'idle' } } })).toBe(false);
|
|
1533
|
+
page = service.contactMessages(assistant.id);
|
|
1534
|
+
expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0].status).toBe('error');
|
|
1535
|
+
expect(page.messages.some((message) => message.text === 'oc.settle.complete')).toBe(false);
|
|
1536
|
+
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
1537
|
+
assignedSessionIDs: [],
|
|
1538
|
+
working: false,
|
|
1539
|
+
});
|
|
1540
|
+
service.close();
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
it('persists a session card in the contact transcript across reload', () => {
|
|
1544
|
+
const directory = root();
|
|
1545
|
+
const service = setup(directory);
|
|
1546
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1547
|
+
const inserted = service.appendContactCard(assistant.id, {
|
|
1548
|
+
cardType: 'session',
|
|
1549
|
+
sessionID: 'ses_real',
|
|
1550
|
+
directory,
|
|
1551
|
+
title: 'Fix login',
|
|
1552
|
+
status: 'idle',
|
|
1553
|
+
});
|
|
1554
|
+
expect(inserted.card).toMatchObject({
|
|
1555
|
+
type: 'card',
|
|
1556
|
+
cardType: 'session',
|
|
1557
|
+
sessionID: 'ses_real',
|
|
1558
|
+
title: 'Fix login',
|
|
1559
|
+
});
|
|
1560
|
+
const page = service.contactMessages(assistant.id);
|
|
1561
|
+
expect(page.messages).toHaveLength(1);
|
|
1562
|
+
expect(page.messages[0].parts[0]).toMatchObject({
|
|
1563
|
+
type: 'card',
|
|
1564
|
+
cardType: 'session',
|
|
1565
|
+
sessionID: 'ses_real',
|
|
1566
|
+
title: 'Fix login',
|
|
1567
|
+
});
|
|
1568
|
+
service.close();
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
it('sends a read-only peer message from message_assistant without promptAsync', async () => {
|
|
1572
|
+
const prompts = [];
|
|
1573
|
+
const directory = root();
|
|
1574
|
+
const service = setup(directory, { promptAsync: async (input) => { prompts.push(input); return { response: { status: 204 } }; } }, {
|
|
1575
|
+
runContactTurn: async ({ tools }) => {
|
|
1576
|
+
const message = tools.find((tool) => tool.name === 'message_assistant');
|
|
1577
|
+
const result = await message.execute('call_1', { to: 'PeerQA', text: 'hello-from-assistant 写好了' });
|
|
1578
|
+
return {
|
|
1579
|
+
text: result.content[0].text,
|
|
1580
|
+
bubbles: [result.content[0].text],
|
|
1581
|
+
cards: [],
|
|
1582
|
+
tools,
|
|
1583
|
+
};
|
|
1584
|
+
},
|
|
1585
|
+
});
|
|
1586
|
+
const sender = service.createAssistant({ ...assistantInput, name: 'DeepSeekQA' });
|
|
1587
|
+
const recipient = service.createAssistant({ ...assistantInput, name: 'PeerQA' });
|
|
1588
|
+
await settleSend(service, sender.id, {
|
|
1589
|
+
messageID: 'client_message_assistant',
|
|
1590
|
+
parts: [{ type: 'text', text: '给 PeerQA 说一声 hello-from-assistant 写好了' }],
|
|
1591
|
+
});
|
|
1592
|
+
expect(prompts).toEqual([]);
|
|
1593
|
+
expect(service.contactMessages(recipient.id).messages).toEqual([expect.objectContaining({
|
|
1594
|
+
role: 'peer',
|
|
1595
|
+
fromAssistantID: sender.id,
|
|
1596
|
+
fromAssistantName: 'DeepSeekQA',
|
|
1597
|
+
text: 'hello-from-assistant 写好了',
|
|
1598
|
+
})]);
|
|
1599
|
+
const senderPage = service.contactMessages(sender.id);
|
|
1600
|
+
expect(senderPage.messages.some((message) => message.role === 'user')).toBe(true);
|
|
1601
|
+
expect(senderPage.messages.some((message) => message.text.includes('Sent to PeerQA'))).toBe(true);
|
|
1602
|
+
expect(senderPage.messages.some((message) => message.parts.some((part) => part.type === 'card'))).toBe(false);
|
|
1603
|
+
service.close();
|
|
1604
|
+
});
|
|
1605
|
+
|
|
1606
|
+
it('delivers a read-only peer DM into the recipient contact transcript without OpenCode', async () => {
|
|
1607
|
+
const prompts = [];
|
|
1608
|
+
const directory = root();
|
|
1609
|
+
const service = setup(directory, { promptAsync: async (input) => { prompts.push(input); return { response: { status: 204 } }; } });
|
|
1610
|
+
const sender = service.createAssistant({ ...assistantInput, name: 'Sender' });
|
|
1611
|
+
const recipient = service.createAssistant({ ...assistantInput, name: 'Recipient' });
|
|
1612
|
+
const delivered = service.deliverPeerMessage(sender.id, {
|
|
1613
|
+
toAssistantID: recipient.id,
|
|
1614
|
+
text: 'Can you watch the login session?',
|
|
1615
|
+
});
|
|
1616
|
+
expect(delivered).toMatchObject({
|
|
1617
|
+
admitted: true,
|
|
1618
|
+
role: 'peer',
|
|
1619
|
+
fromAssistantID: sender.id,
|
|
1620
|
+
fromAssistantName: 'Sender',
|
|
1621
|
+
toAssistantID: recipient.id,
|
|
1622
|
+
});
|
|
1623
|
+
expect(prompts).toEqual([]);
|
|
1624
|
+
const page = service.contactMessages(recipient.id);
|
|
1625
|
+
expect(page.messages).toHaveLength(1);
|
|
1626
|
+
expect(page.messages[0]).toMatchObject({
|
|
1627
|
+
role: 'peer',
|
|
1628
|
+
fromAssistantID: sender.id,
|
|
1629
|
+
fromAssistantName: 'Sender',
|
|
1630
|
+
text: 'Can you watch the login session?',
|
|
1631
|
+
});
|
|
1632
|
+
expect(service.contactMessages(sender.id).messages).toEqual([]);
|
|
1633
|
+
service.close();
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
it('rejects self-DMs and never treats peer inbox rows as composer turns', async () => {
|
|
1637
|
+
const service = setup();
|
|
1638
|
+
const sender = service.createAssistant({ ...assistantInput, name: 'Sender' });
|
|
1639
|
+
const recipient = service.createAssistant({ ...assistantInput, name: 'Recipient' });
|
|
1640
|
+
expect(() => service.deliverPeerMessage(sender.id, {
|
|
1641
|
+
toAssistantID: sender.id,
|
|
1642
|
+
text: 'loop',
|
|
1643
|
+
})).toThrow('validation_error');
|
|
1644
|
+
service.deliverPeerMessage(sender.id, { toAssistantID: recipient.id, text: 'ping' });
|
|
1645
|
+
const sent = await settleSend(service, recipient.id, {
|
|
1646
|
+
messageID: 'after-peer',
|
|
1647
|
+
parts: [{ type: 'text', text: 'hello' }],
|
|
1648
|
+
});
|
|
1649
|
+
expect(sent.admitted).toBe(true);
|
|
1650
|
+
const roles = service.contactMessages(recipient.id).messages.map((message) => message.role);
|
|
1651
|
+
expect(roles).toEqual(['peer', 'user', 'assistant']);
|
|
1652
|
+
service.close();
|
|
1653
|
+
});
|
|
1654
|
+
|
|
1655
|
+
it('notifies like a contact SMS when a contact turn completes', async () => {
|
|
1656
|
+
const onContactTurnComplete = vi.fn();
|
|
1657
|
+
const service = setup(root(), {}, {
|
|
1658
|
+
onContactTurnComplete,
|
|
1659
|
+
runContactTurn: async () => ({ text: '我去找一下', bubbles: ['我去找一下', 'Opened a coding session.'] }),
|
|
1660
|
+
});
|
|
1661
|
+
const assistant = service.createAssistant({ ...assistantInput, name: '大小白' });
|
|
1662
|
+
await settleSend(service, assistant.id, {
|
|
1663
|
+
messageID: 'notify_1',
|
|
1664
|
+
parts: [{ type: 'text', text: 'hi' }],
|
|
1665
|
+
});
|
|
1666
|
+
expect(onContactTurnComplete).toHaveBeenCalledWith({
|
|
1667
|
+
assistantID: assistant.id,
|
|
1668
|
+
name: '大小白',
|
|
1669
|
+
turnID: 'notify_1',
|
|
1670
|
+
status: 'complete',
|
|
1671
|
+
body: '我去找一下\nOpened a coding session.',
|
|
1672
|
+
});
|
|
1673
|
+
service.close();
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1676
|
+
it('broadcasts contact-turn-start/end and bubble-delta without re-inserting the user row', async () => {
|
|
1677
|
+
const events = [];
|
|
1678
|
+
const directory = root();
|
|
1679
|
+
const service = setup(directory, {}, {
|
|
1680
|
+
onContactTurnEvent: (event) => events.push(event),
|
|
1681
|
+
runContactTurn: async ({ userText, onBubbleDelta }) => {
|
|
1682
|
+
if (typeof onBubbleDelta === 'function') {
|
|
1683
|
+
onBubbleDelta(0, `stream:${userText}`, false);
|
|
1684
|
+
onBubbleDelta(0, '', true);
|
|
1685
|
+
}
|
|
1686
|
+
return { text: `stream:${userText}`, bubbles: [`stream:${userText}`] };
|
|
1687
|
+
},
|
|
1688
|
+
});
|
|
1689
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1690
|
+
const sent = await service.send(assistant.id, {
|
|
1691
|
+
messageID: 'turn_stream_1',
|
|
1692
|
+
parts: [{ type: 'text', text: 'hi' }],
|
|
1693
|
+
});
|
|
1694
|
+
expect(sent).toEqual({
|
|
1695
|
+
binding: expect.objectContaining({ sessionID: null, sessionGeneration: 0 }),
|
|
1696
|
+
messageID: 'turn_stream_1',
|
|
1697
|
+
admitted: true,
|
|
1698
|
+
});
|
|
1699
|
+
expect(sent).not.toHaveProperty('settled');
|
|
1700
|
+
const settled = await service.whenContactTurnSettled('turn_stream_1');
|
|
1701
|
+
expect(settled).toMatchObject({ status: 'complete' });
|
|
1702
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1703
|
+
const types = events.map((event) => event.type);
|
|
1704
|
+
expect(types[0]).toBe('openchamber:contact-turn-start');
|
|
1705
|
+
expect(events[0].properties).toMatchObject({
|
|
1706
|
+
assistantID: assistant.id,
|
|
1707
|
+
turnID: 'turn_stream_1',
|
|
1708
|
+
messageID: 'turn_stream_1',
|
|
1709
|
+
occurredAt: expect.any(Number),
|
|
1710
|
+
});
|
|
1711
|
+
expect(events.some((event) => (
|
|
1712
|
+
event.type === 'openchamber:contact-bubble-delta'
|
|
1713
|
+
&& event.properties?.bubbleIndex === 0
|
|
1714
|
+
&& event.properties?.delta === 'stream:hi'
|
|
1715
|
+
&& event.properties?.done === false
|
|
1716
|
+
))).toBe(true);
|
|
1717
|
+
expect(types.at(-1)).toBe('openchamber:contact-turn-end');
|
|
1718
|
+
expect(events.at(-1).properties).toMatchObject({
|
|
1719
|
+
assistantID: assistant.id,
|
|
1720
|
+
turnID: 'turn_stream_1',
|
|
1721
|
+
status: 'complete',
|
|
1722
|
+
occurredAt: expect.any(Number),
|
|
1723
|
+
});
|
|
1724
|
+
const page = service.contactMessages(assistant.id);
|
|
1725
|
+
expect(page.messages.map((message) => message.messageID)).toEqual([
|
|
1726
|
+
'turn_stream_1',
|
|
1727
|
+
'turn_stream_1:bubble:1',
|
|
1728
|
+
]);
|
|
1729
|
+
const db = new (require('better-sqlite3'))(path.join(directory, 'assistants.sqlite'));
|
|
1730
|
+
expect(db.prepare('SELECT COUNT(*) AS count FROM assistant_contact_message WHERE message_id=?').get('turn_stream_1').count).toBe(1);
|
|
1731
|
+
db.close();
|
|
1732
|
+
service.close();
|
|
1733
|
+
});
|
|
1734
|
+
|
|
1735
|
+
it('admits the user then broadcasts turn-end error when the harness fails', async () => {
|
|
1736
|
+
const events = [];
|
|
1737
|
+
const service = setup(root(), {}, {
|
|
1738
|
+
onContactTurnEvent: (event) => events.push(event),
|
|
1739
|
+
runContactTurn: async () => {
|
|
1740
|
+
const error = new Error('No connected model');
|
|
1741
|
+
error.code = 'no_provider';
|
|
1742
|
+
throw error;
|
|
1743
|
+
},
|
|
1744
|
+
});
|
|
1745
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1746
|
+
const sent = await service.send(assistant.id, {
|
|
1747
|
+
messageID: 'client_no_provider',
|
|
1748
|
+
parts: [{ type: 'text', text: 'hello' }],
|
|
1749
|
+
});
|
|
1750
|
+
expect(sent).toMatchObject({ admitted: true, messageID: 'client_no_provider' });
|
|
1751
|
+
expect(service.contactMessages(assistant.id).messages.map((message) => message.role)).toEqual(['user']);
|
|
1752
|
+
const settled = await service.whenContactTurnSettled('client_no_provider');
|
|
1753
|
+
expect(settled).toMatchObject({ status: 'error', code: 'no_provider' });
|
|
1754
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1755
|
+
expect(events.some((event) => event.type === 'openchamber:contact-turn-start')).toBe(true);
|
|
1756
|
+
expect(events.some((event) => (
|
|
1757
|
+
event.type === 'openchamber:contact-turn-end'
|
|
1758
|
+
&& event.properties?.status === 'error'
|
|
1759
|
+
&& event.properties?.turnID === 'client_no_provider'
|
|
1760
|
+
))).toBe(true);
|
|
1761
|
+
// User row stays; no assistant bubble on failure.
|
|
1762
|
+
expect(service.contactMessages(assistant.id).messages.map((message) => message.role)).toEqual(['user']);
|
|
1763
|
+
service.close();
|
|
1764
|
+
});
|
|
1765
|
+
|
|
648
1766
|
it('deletes message/part/backfill mirrors when an assistant is removed', async () => {
|
|
649
1767
|
const directory = root(); let creates = 0;
|
|
650
1768
|
const service = setup(directory, {
|