@openchambery/web 1.19.12 → 1.19.13-beta.2
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 → AgentAvatar-mbvX7YlW.js} +1 -1
- package/dist/assets/AssistantView-D5c557Oe.js +2 -0
- package/dist/assets/DiagramView-CjNUrHsU.js +1 -0
- package/dist/assets/FadeInOnReveal-moBbL8Ll.js +1 -0
- package/dist/assets/{Icon-BYM02QyU.js → Icon-CkzzhaLr.js} +1 -1
- package/dist/assets/MarkdownRenderer-BHfbAwVO.js +2 -0
- package/dist/assets/MarkdownRendererImpl-CkBoUibc.js +7 -0
- package/dist/assets/{MarkstreamRendererImpl-BFdJzxia.js → MarkstreamRendererImpl-BrXUOI9g.js} +1 -1
- package/dist/assets/MobileDetailNavigation-aY9-HwBN.js +1 -0
- package/dist/assets/MobileShareBridge-CRt_8DmM.js +4 -0
- package/dist/assets/MobileSurface-CIhtF-CU.js +2 -0
- package/dist/assets/{MultiRunWindow-C3p3lj3U.js → MultiRunWindow-DDq6C8t7.js} +1 -1
- package/dist/assets/OnboardingScreen-j0iCyH7f.js +1 -0
- package/dist/assets/{OverlayScrollbar-KUpvYHdv.js → OverlayScrollbar-CDC5B3RH.js} +1 -1
- package/dist/assets/SettingsView-DFWWMvAj.js +25 -0
- package/dist/assets/{SettingsWindow-8IDuPZA5.js → SettingsWindow-CnYfBplB.js} +1 -1
- package/dist/assets/TerminalView-D5CQFVVm.js +8 -0
- package/dist/assets/ToolOutputDialog-VMI7TQFT.js +10 -0
- package/dist/assets/{appThemeRegistry-B87OLIgc.js → appThemeRegistry-UIrP0-5h.js} +1 -1
- package/dist/assets/assistant-attachment-cache-C0aRnFkj.js +1 -0
- package/dist/assets/{button-C0cth9tl.js → button-xx1UOBA5.js} +1 -1
- package/dist/assets/{clipboard-DJ4nQEuY.js → clipboard-CUasgPiF.js} +1 -1
- package/dist/assets/{collapsible-DFT9cE80.js → collapsible-CACHBRYZ.js} +1 -1
- package/dist/assets/{context-menu-CAp74NfV.js → context-menu-CYbm4wBZ.js} +1 -1
- package/dist/assets/desktopHosts-DILvrAok.js +1 -0
- package/dist/assets/desktopRelayRestore-t8asCr3m.js +1 -0
- package/dist/assets/{diagram-BryzMuta.js → diagram-u0BC9uuY.js} +1 -1
- package/dist/assets/{dialog-CgbV5ljg.js → dialog-BwQ3rY_x.js} +1 -1
- package/dist/assets/dualLimitLru-D8Sphgua.js +1 -0
- package/dist/assets/{es-BHqnSjoZ.js → es-CRjOwvYM.js} +1 -1
- package/dist/assets/fileQueries-Bgly-E8f.js +1 -0
- package/dist/assets/{fr-BfrlJY9i.js → fr-D9taEN9L.js} +1 -1
- package/dist/assets/{gitApi-tTtHQVA3.js → gitApi-Bu4W3bgD.js} +1 -1
- package/dist/assets/imageSource-BlAJJUEI.js +1 -0
- package/dist/assets/{input-DFLpqjGR.js → input-CI730njs.js} +1 -1
- package/dist/assets/insertionBoundaries-B1AqxsBD.js +4 -0
- package/dist/assets/{ja-DP-7qt-x.js → ja-CYQ5aoWp.js} +1 -1
- package/dist/assets/{ko-E2B5vGQP.js → ko-BwASV0Q0.js} +1 -1
- package/dist/assets/main-CqcB6tKc.js +169 -0
- package/dist/assets/main-DG-k0j8A.js +2 -0
- package/dist/assets/markdown-worker-CptoICmq.js +2 -0
- package/dist/assets/markdownHeightCache-DEjGKLv8.js +3 -0
- package/dist/assets/markdownTheme-DOfGg-A7.js +1 -0
- package/dist/assets/miniChat-BQtYQrsh.js +2 -0
- package/dist/assets/mobile-8iyqtXkr.js +2 -0
- package/dist/assets/{multirun-B0V3hdnD.js → multirun-CG_FCb_Z.js} +1 -1
- package/dist/assets/persistence-YbQKzHUQ.js +3 -0
- package/dist/assets/{pl-r8X5gRM5.js → pl-DUI1vPil.js} +1 -1
- package/dist/assets/platform-DzsGqlGW.js +2 -0
- package/dist/assets/projectMeta-DMpMDWE1.js +3 -0
- package/dist/assets/{pt-BR-DcpdXCf5.js → pt-BR-CZQFJllP.js} +1 -1
- package/dist/assets/relay-image-stream-DM7atqJs.js +2 -0
- package/dist/assets/renderElectronMiniChatApp-DBzRdJl8.js +2 -0
- package/dist/assets/renderMobileApp-CIseqCyn.js +28 -0
- package/dist/assets/runtime-auth-D66yq6ie.js +1 -0
- package/dist/assets/runtime-fetch-zIalUnbb.js +1 -0
- package/dist/assets/runtime-switch-LvWDU-SL.js +1 -0
- package/dist/assets/runtimeConfig-D8nJYB-e.js +2 -0
- package/dist/assets/runtimeEndpointReset-CSub_ecV.js +147 -0
- package/dist/assets/{sessionChangeSummary-hVby3sJk.js → sessionChangeSummary-DQDZ5HW-.js} +1 -1
- package/dist/assets/{sessionLookup-B6P_aCun.js → sessionLookup-BiBD-4wC.js} +1 -1
- package/dist/assets/sortable-tabs-strip-Dom6Sthu.js +1 -0
- package/dist/assets/src-AA-5nXsF.css +2 -0
- package/dist/assets/{store-BDFdnk6l.js → store-CSnH_QXM.js} +2 -2
- package/dist/assets/terminalApi-noa4Cdbh.js +1 -0
- package/dist/assets/{textarea-Cgo_nWUU.js → textarea-BfNtUdJE.js} +1 -1
- package/dist/assets/{tooltip-BifLJ8o0.js → tooltip-DPxumadF.js} +1 -1
- package/dist/assets/{uk-Di0vl9V-.js → uk-C1FYZlmA.js} +1 -1
- package/dist/assets/{url-Pk6tQ1EZ.js → url-CVhsI9kR.js} +1 -1
- package/dist/assets/{useAppFontEffects-Dmes1chr.js → useAppFontEffects-qHt5uaFN.js} +32 -32
- package/dist/assets/useAssistantUIStore-CLPCNoj_.js +1 -0
- package/dist/assets/useDesktopWindowControlsLayout-BipyIXiK.js +1 -0
- package/dist/assets/{useEffectiveDirectory-CU2ChP0J.js → useEffectiveDirectory-DSNCCbCt.js} +1 -1
- package/dist/assets/useFontPreferences-CB-cGo3I.js +1 -0
- package/dist/assets/useMobileNavigationStore-C1NJGAik.js +413 -0
- package/dist/assets/useSessionAutoCleanup-Dcn6Unvd.js +2 -0
- package/dist/assets/useUIStore-CxOoCv7B.js +1 -0
- package/dist/assets/useWorkerHighlightedLines-CzpT4M7V.js +7 -0
- package/dist/assets/{utils-BiK3tBGM.js → utils-BHTVlVov.js} +1 -1
- package/dist/assets/{zh-CN-coFCfeQE.js → zh-CN-C96qP1ip.js} +1 -1
- package/dist/assets/{zh-TW-oDRZ1-yb.js → zh-TW-BsRk1Rbb.js} +1 -1
- package/dist/index.html +12 -9
- package/dist/mini-chat.html +12 -9
- package/dist/mobile.html +12 -9
- package/package.json +1 -1
- package/server/index.js +19 -0
- package/server/lib/assistant-delivery-parts.js +19 -1
- package/server/lib/assistants/DOCUMENTATION.md +56 -11
- package/server/lib/assistants/assign.js +455 -30
- package/server/lib/assistants/assign.test.js +351 -0
- package/server/lib/assistants/cards.js +16 -2
- package/server/lib/assistants/cards.test.js +16 -1
- package/server/lib/assistants/contact-active-turn.http.test.js +134 -0
- package/server/lib/assistants/contact-attachments.http.test.js +436 -0
- package/server/lib/assistants/contact-attachments.js +814 -0
- package/server/lib/assistants/contact-attachments.p1.test.js +307 -0
- package/server/lib/assistants/contact-attachments.test.js +249 -0
- package/server/lib/assistants/contact-store.js +552 -19
- package/server/lib/assistants/contact-store.test.js +415 -1
- package/server/lib/assistants/contact-tools.js +114 -14
- package/server/lib/assistants/contact-tools.test.js +135 -6
- package/server/lib/assistants/contracts.js +42 -2
- package/server/lib/assistants/harness.js +4 -1
- package/server/lib/assistants/harness.test.js +124 -0
- package/server/lib/assistants/routes.js +46 -1
- package/server/lib/assistants/service.js +569 -84
- package/server/lib/assistants/service.test.js +936 -37
- package/server/lib/fs/prompt-attachment-store.js +663 -0
- package/server/lib/fs/prompt-attachment-store.test.js +263 -0
- package/server/lib/fs/routes.js +28 -72
- package/server/lib/fs/routes.test.js +15 -30
- package/server/lib/llm/catalog.js +91 -16
- package/server/lib/llm/catalog.test.js +152 -11
- package/server/lib/notifications/APNS.md +20 -4
- package/server/lib/notifications/DOCUMENTATION.md +5 -0
- package/server/lib/notifications/apns-runtime.js +136 -18
- package/server/lib/notifications/apns-runtime.test.js +128 -0
- package/server/lib/notifications/apns-titles.js +21 -1
- package/server/lib/notifications/live-activity-refresh-runtime.js +185 -0
- package/server/lib/notifications/live-activity-refresh-runtime.test.js +242 -0
- package/server/lib/notifications/runtime.js +93 -2
- package/server/lib/notifications/runtime.test.js +167 -4
- package/server/lib/opencode/core-routes.js +2 -1
- package/server/lib/opencode/settings-helpers.js +12 -0
- package/server/lib/opencode/settings-runtime.js +16 -0
- package/server/lib/opencode/settings-runtime.test.js +21 -0
- package/server/lib/scheduled-tasks/runtime.js +21 -0
- package/server/lib/scheduled-tasks/runtime.test.js +24 -0
- package/dist/assets/AssistantView-DxIghtun.js +0 -2
- package/dist/assets/DiagramView-BeUgp_Y0.js +0 -1
- package/dist/assets/FadeInOnReveal-CTlNE4Hg.js +0 -1
- package/dist/assets/MarkdownRenderer-C4MGa_kQ.js +0 -2
- package/dist/assets/MarkdownRendererImpl-DB4Q3kGa.js +0 -7
- package/dist/assets/MobileDetailNavigation-B_RwWOwv.js +0 -1
- package/dist/assets/MobileShareBridge-UiOLx0-2.js +0 -4
- package/dist/assets/MobileSurface-CNChj_dX.js +0 -2
- package/dist/assets/OnboardingScreen-DS2nugLX.js +0 -1
- package/dist/assets/SettingsView-BXfKsz0J.js +0 -25
- package/dist/assets/TerminalView-BiKCueth.js +0 -8
- package/dist/assets/ToolOutputDialog-BC2F_Fdl.js +0 -10
- package/dist/assets/desktopHosts-Dc2PS66M.js +0 -1
- package/dist/assets/desktopRelayRestore-T9y0orqa.js +0 -1
- package/dist/assets/fileQueries-BY-9OWzp.js +0 -1
- package/dist/assets/insertionBoundaries-CCQpZ5zn.js +0 -4
- package/dist/assets/main-C_PUoT3E.js +0 -169
- package/dist/assets/main-Dkg31fRY.js +0 -2
- package/dist/assets/markdown-worker-CTsrtpPh.js +0 -2
- package/dist/assets/markdownHeightCache-CYDlisxr.js +0 -3
- package/dist/assets/markdownTheme-DyJ4Gfcj.js +0 -2
- package/dist/assets/miniChat-C9QMj8T8.js +0 -2
- package/dist/assets/mobile-Z_X7CbzB.js +0 -2
- package/dist/assets/persistence-DdUac14I.js +0 -3
- package/dist/assets/platform-B4KKLnZ5.js +0 -2
- package/dist/assets/projectMeta-DRW9MQ4z.js +0 -3
- package/dist/assets/renderElectronMiniChatApp-Dn1Su1dd.js +0 -2
- package/dist/assets/renderMobileApp-BG1JJt1Q.js +0 -28
- package/dist/assets/runtimeConfig-C6matIEB.js +0 -2
- package/dist/assets/runtimeEndpointReset-kgwmUJDb.js +0 -147
- package/dist/assets/sortable-tabs-strip-C8_9kHqu.js +0 -1
- package/dist/assets/src-B-P5qIBc.css +0 -2
- package/dist/assets/terminalApi-947l7Ra3.js +0 -1
- package/dist/assets/useAssistantUIStore-me6s63b2.js +0 -1
- package/dist/assets/useDesktopWindowControlsLayout-DuZ6URPR.js +0 -1
- package/dist/assets/useFontPreferences-BCOBlaAd.js +0 -1
- package/dist/assets/useMobileNavigationStore-C8_J-qTp.js +0 -413
- package/dist/assets/useSessionAutoCleanup-FOQeu1_T.js +0 -1
- package/dist/assets/useUIStore-CuaA8-Hp.js +0 -1
- package/dist/assets/useWorkerHighlightedLines-GBqKnki1.js +0 -7
|
@@ -6,8 +6,16 @@ import {
|
|
|
6
6
|
ASSIGN_CODES,
|
|
7
7
|
PROJECT_REQUIRED_MESSAGE,
|
|
8
8
|
assignSession,
|
|
9
|
+
attachmentScopeKey,
|
|
10
|
+
buildAssignParts,
|
|
11
|
+
hasAssignImageParts,
|
|
12
|
+
isAmbiguousPromptFailure,
|
|
9
13
|
isManagedAssistantWorkspace,
|
|
14
|
+
requireProvidedString,
|
|
10
15
|
resolveAssignDirectory,
|
|
16
|
+
resolveAssignWorkerModel,
|
|
17
|
+
resolveAssignWorkerVariant,
|
|
18
|
+
sanitizeAssignFileParts,
|
|
11
19
|
} from './assign.js';
|
|
12
20
|
|
|
13
21
|
const root = () => fs.mkdtempSync(path.join(os.tmpdir(), 'assign-'));
|
|
@@ -54,6 +62,157 @@ describe('resolveAssignDirectory', () => {
|
|
|
54
62
|
});
|
|
55
63
|
});
|
|
56
64
|
|
|
65
|
+
describe('requireProvidedString / resolveAssignWorkerModel', () => {
|
|
66
|
+
const catalog = {
|
|
67
|
+
models: [
|
|
68
|
+
{ providerID: 'openai', modelID: 'gpt-4o', name: 'GPT-4o', acceptsImages: true },
|
|
69
|
+
{ providerID: 'xai', modelID: 'grok-4.6', name: 'Grok 4.6', acceptsImages: true },
|
|
70
|
+
{ providerID: 'opencode-go', modelID: 'deepseek-v4-flash', name: 'deepseek-v4-flash', acceptsImages: false },
|
|
71
|
+
{ providerID: 'other', modelID: 'deepseek-v4-flash', name: 'clone', acceptsImages: false },
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
it('defaults to the assistant model without requiring catalog', () => {
|
|
76
|
+
expect(resolveAssignWorkerModel({
|
|
77
|
+
fallback: { providerID: 'p', modelID: 'm' },
|
|
78
|
+
})).toEqual({
|
|
79
|
+
providerID: 'p',
|
|
80
|
+
modelID: 'm',
|
|
81
|
+
source: 'assistant',
|
|
82
|
+
name: null,
|
|
83
|
+
acceptsImages: null,
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('resolves explicit provider/model against the connected catalog', () => {
|
|
88
|
+
expect(resolveAssignWorkerModel({
|
|
89
|
+
model: 'xai/grok-4.6',
|
|
90
|
+
fallback: { providerID: 'p', modelID: 'm' },
|
|
91
|
+
catalog,
|
|
92
|
+
})).toMatchObject({
|
|
93
|
+
providerID: 'xai',
|
|
94
|
+
modelID: 'grok-4.6',
|
|
95
|
+
source: 'explicit',
|
|
96
|
+
acceptsImages: true,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('fails closed for missing or ambiguous model names', () => {
|
|
101
|
+
expect(() => resolveAssignWorkerModel({
|
|
102
|
+
model: 'missing-model',
|
|
103
|
+
fallback: { providerID: 'p', modelID: 'm' },
|
|
104
|
+
catalog,
|
|
105
|
+
})).toThrowError(expect.objectContaining({ code: ASSIGN_CODES.MODEL_NOT_FOUND }));
|
|
106
|
+
expect(() => resolveAssignWorkerModel({
|
|
107
|
+
modelID: 'deepseek-v4-flash',
|
|
108
|
+
fallback: { providerID: 'p', modelID: 'm' },
|
|
109
|
+
catalog,
|
|
110
|
+
})).toThrowError(expect.objectContaining({ code: ASSIGN_CODES.MODEL_AMBIGUOUS }));
|
|
111
|
+
expect(() => resolveAssignWorkerModel({
|
|
112
|
+
model: 'xai/grok-4.6',
|
|
113
|
+
fallback: { providerID: 'p', modelID: 'm' },
|
|
114
|
+
catalog: null,
|
|
115
|
+
})).toThrowError(expect.objectContaining({ code: ASSIGN_CODES.UPSTREAM }));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('fails closed on illegal provided explicit model fields (no silent null fallback)', () => {
|
|
119
|
+
expect(() => requireProvidedString({ x: 1 }, { field: 'model', max: 512 })).toThrowError(
|
|
120
|
+
expect.objectContaining({ code: ASSIGN_CODES.VALIDATION }),
|
|
121
|
+
);
|
|
122
|
+
expect(() => requireProvidedString('x'.repeat(513), { field: 'model', max: 512 })).toThrowError(
|
|
123
|
+
expect.objectContaining({ code: ASSIGN_CODES.VALIDATION, message: expect.stringMatching(/exceeds 512/) }),
|
|
124
|
+
);
|
|
125
|
+
expect(() => requireProvidedString(' ', { field: 'modelID', max: 256 })).toThrowError(
|
|
126
|
+
expect.objectContaining({ code: ASSIGN_CODES.VALIDATION }),
|
|
127
|
+
);
|
|
128
|
+
expect(() => resolveAssignWorkerModel({
|
|
129
|
+
model: 'x'.repeat(513),
|
|
130
|
+
fallback: { providerID: 'p', modelID: 'm' },
|
|
131
|
+
catalog,
|
|
132
|
+
})).toThrowError(expect.objectContaining({ code: ASSIGN_CODES.VALIDATION }));
|
|
133
|
+
expect(() => resolveAssignWorkerModel({
|
|
134
|
+
model: { providerID: 'xai', modelID: 'grok-4.6' },
|
|
135
|
+
fallback: { providerID: 'p', modelID: 'm' },
|
|
136
|
+
catalog,
|
|
137
|
+
})).toThrowError(expect.objectContaining({ code: ASSIGN_CODES.VALIDATION }));
|
|
138
|
+
expect(() => resolveAssignWorkerModel({
|
|
139
|
+
providerID: 'xai',
|
|
140
|
+
modelID: 'grok-4.6',
|
|
141
|
+
model: 'openai/gpt-4o',
|
|
142
|
+
fallback: { providerID: 'p', modelID: 'm' },
|
|
143
|
+
catalog,
|
|
144
|
+
})).toThrowError(expect.objectContaining({
|
|
145
|
+
code: ASSIGN_CODES.VALIDATION,
|
|
146
|
+
message: expect.stringMatching(/conflicts/),
|
|
147
|
+
}));
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('resolveAssignWorkerVariant', () => {
|
|
152
|
+
it('reuses assistant variant only for same/default model; drops it cross-model', () => {
|
|
153
|
+
expect(resolveAssignWorkerVariant({
|
|
154
|
+
source: 'assistant',
|
|
155
|
+
workerProviderID: 'p',
|
|
156
|
+
workerModelID: 'm',
|
|
157
|
+
assistant: { providerID: 'p', modelID: 'm', variant: 'fast' },
|
|
158
|
+
})).toBe('fast');
|
|
159
|
+
expect(resolveAssignWorkerVariant({
|
|
160
|
+
source: 'explicit',
|
|
161
|
+
workerProviderID: 'xai',
|
|
162
|
+
workerModelID: 'grok-4.6',
|
|
163
|
+
assistant: { providerID: 'p', modelID: 'm', variant: 'fast' },
|
|
164
|
+
})).toBeNull();
|
|
165
|
+
expect(resolveAssignWorkerVariant({
|
|
166
|
+
source: 'explicit',
|
|
167
|
+
workerProviderID: 'xai',
|
|
168
|
+
workerModelID: 'grok-4.6',
|
|
169
|
+
assistant: { providerID: 'p', modelID: 'm', variant: 'fast' },
|
|
170
|
+
variant: 'default',
|
|
171
|
+
})).toBe('default');
|
|
172
|
+
expect(() => resolveAssignWorkerVariant({
|
|
173
|
+
source: 'explicit',
|
|
174
|
+
workerProviderID: 'xai',
|
|
175
|
+
workerModelID: 'grok-4.6',
|
|
176
|
+
variant: 'x'.repeat(300),
|
|
177
|
+
})).toThrowError(expect.objectContaining({ code: ASSIGN_CODES.VALIDATION }));
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('assign file parts', () => {
|
|
182
|
+
it('keeps attachment order and fingerprints content without exposing file data', () => {
|
|
183
|
+
const first = { type: 'file', mime: 'text/plain', filename: 'first.txt', url: 'data:text/plain;base64,Zmlyc3Q=' };
|
|
184
|
+
const second = { ...first, filename: 'second.txt', url: 'data:text/plain;base64,c2Vjb25k' };
|
|
185
|
+
const key = attachmentScopeKey([first, second]);
|
|
186
|
+
expect(key).toEqual(attachmentScopeKey([{ ...first }, { ...second }]));
|
|
187
|
+
expect(key).not.toEqual(attachmentScopeKey([second, first]));
|
|
188
|
+
expect(JSON.stringify(key)).not.toContain(first.url);
|
|
189
|
+
expect(JSON.stringify(key)).not.toContain(first.filename);
|
|
190
|
+
});
|
|
191
|
+
it('sanitizes file parts and builds prompt+file worker parts', () => {
|
|
192
|
+
const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' };
|
|
193
|
+
const bad = { type: 'file', mime: 'image/png' };
|
|
194
|
+
expect(sanitizeAssignFileParts([image, bad, { type: 'text', text: 'x' }])).toEqual([image]);
|
|
195
|
+
expect(hasAssignImageParts([image])).toBe(true);
|
|
196
|
+
expect(buildAssignParts({ prompt: 'Fix width', fileParts: [image] })).toEqual([
|
|
197
|
+
{ type: 'text', text: 'Fix width' },
|
|
198
|
+
image,
|
|
199
|
+
]);
|
|
200
|
+
expect(attachmentScopeKey([image])).toEqual([
|
|
201
|
+
expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
202
|
+
]);
|
|
203
|
+
expect(JSON.stringify(attachmentScopeKey([image]))).not.toContain('base64');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('isAmbiguousPromptFailure', () => {
|
|
208
|
+
it('treats transport/5xx/no-status as ambiguous and 4xx as definite', () => {
|
|
209
|
+
expect(isAmbiguousPromptFailure(Object.assign(new Error('fetch failed'), { name: 'TypeError' }), null)).toBe(true);
|
|
210
|
+
expect(isAmbiguousPromptFailure(null, { error: { status: 500 }, response: { status: 500 } })).toBe(true);
|
|
211
|
+
expect(isAmbiguousPromptFailure(null, { error: { status: 400 }, response: { status: 400 } })).toBe(false);
|
|
212
|
+
expect(isAmbiguousPromptFailure(null, { response: { status: 204 } })).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
57
216
|
describe('assignSession', () => {
|
|
58
217
|
it('creates a visible worker session and prompts it', async () => {
|
|
59
218
|
const directory = root();
|
|
@@ -77,6 +236,7 @@ describe('assignSession', () => {
|
|
|
77
236
|
title: 'Login',
|
|
78
237
|
status: 'busy',
|
|
79
238
|
reused: false,
|
|
239
|
+
model: { providerID: 'openai', modelID: 'gpt-5.2' },
|
|
80
240
|
});
|
|
81
241
|
expect(createSession).toHaveBeenCalledWith(expect.objectContaining({
|
|
82
242
|
directory: fs.realpathSync(project),
|
|
@@ -96,11 +256,200 @@ describe('assignSession', () => {
|
|
|
96
256
|
}));
|
|
97
257
|
});
|
|
98
258
|
|
|
259
|
+
it('forwards current-turn images into promptAsync with an explicit vision model', async () => {
|
|
260
|
+
const directory = root();
|
|
261
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
262
|
+
const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'card.png' };
|
|
263
|
+
const createSession = vi.fn(async () => ({ data: { id: 'ses_img' } }));
|
|
264
|
+
const promptExisting = vi.fn(async () => ({ response: { status: 204 } }));
|
|
265
|
+
const assigned = await assignSession({
|
|
266
|
+
prompt: 'Fix mobile card width from this screenshot',
|
|
267
|
+
directory,
|
|
268
|
+
assistant: { id: 'asst_1', name: 'Ada', providerID: 'p', modelID: 'm', variant: 'fast' },
|
|
269
|
+
model: { providerID: 'xai', modelID: 'grok-4.6', acceptsImages: true },
|
|
270
|
+
fileParts: [image],
|
|
271
|
+
allowedRoots: [directory],
|
|
272
|
+
managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
|
|
273
|
+
createSession,
|
|
274
|
+
promptExisting,
|
|
275
|
+
});
|
|
276
|
+
expect(assigned.model).toEqual({ providerID: 'xai', modelID: 'grok-4.6' });
|
|
277
|
+
expect(promptExisting).toHaveBeenCalledWith(expect.objectContaining({
|
|
278
|
+
model: { providerID: 'xai', modelID: 'grok-4.6' },
|
|
279
|
+
parts: [
|
|
280
|
+
{ type: 'text', text: 'Fix mobile card width from this screenshot' },
|
|
281
|
+
image,
|
|
282
|
+
],
|
|
283
|
+
}));
|
|
284
|
+
// Cross-model must not reuse contact-only variant.
|
|
285
|
+
expect(promptExisting.mock.calls[0][0].variant).toBeUndefined();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('reuses assistant variant only when the worker model matches the contact', async () => {
|
|
289
|
+
const directory = root();
|
|
290
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
291
|
+
const promptExisting = vi.fn(async () => ({ response: { status: 204 } }));
|
|
292
|
+
await assignSession({
|
|
293
|
+
prompt: 'Fix',
|
|
294
|
+
directory,
|
|
295
|
+
assistant: { providerID: 'p', modelID: 'm', variant: 'fast' },
|
|
296
|
+
allowedRoots: [directory],
|
|
297
|
+
managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
|
|
298
|
+
createSession: async () => ({ data: { id: 'ses_var' } }),
|
|
299
|
+
promptExisting,
|
|
300
|
+
});
|
|
301
|
+
expect(promptExisting).toHaveBeenCalledWith(expect.objectContaining({ variant: 'fast' }));
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('rejects image attachments for non-vision worker models before create', async () => {
|
|
305
|
+
const directory = root();
|
|
306
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
307
|
+
const createSession = vi.fn();
|
|
308
|
+
await expect(assignSession({
|
|
309
|
+
prompt: 'Look at this',
|
|
310
|
+
directory,
|
|
311
|
+
assistant: { providerID: 'p', modelID: 'm' },
|
|
312
|
+
model: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash', acceptsImages: false },
|
|
313
|
+
fileParts: [{ type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'x.png' }],
|
|
314
|
+
allowedRoots: [directory],
|
|
315
|
+
managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
|
|
316
|
+
createSession,
|
|
317
|
+
promptExisting: vi.fn(),
|
|
318
|
+
})).rejects.toMatchObject({ code: ASSIGN_CODES.IMAGE_NOT_SUPPORTED });
|
|
319
|
+
expect(createSession).not.toHaveBeenCalled();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('deletes a newly created worker on definite prompt failure (4xx)', async () => {
|
|
323
|
+
const directory = root();
|
|
324
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
325
|
+
const createSession = vi.fn(async () => ({ data: { id: 'ses_orphan' } }));
|
|
326
|
+
const promptExisting = vi.fn(async () => ({ error: { status: 400 }, response: { status: 400 } }));
|
|
327
|
+
const deleteSession = vi.fn(async () => ({ data: true }));
|
|
328
|
+
await expect(assignSession({
|
|
329
|
+
prompt: 'Fix',
|
|
330
|
+
directory,
|
|
331
|
+
assistant: { providerID: 'p', modelID: 'm' },
|
|
332
|
+
allowedRoots: [directory],
|
|
333
|
+
managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
|
|
334
|
+
createSession,
|
|
335
|
+
promptExisting,
|
|
336
|
+
deleteSession,
|
|
337
|
+
})).rejects.toMatchObject({ code: ASSIGN_CODES.UPSTREAM });
|
|
338
|
+
expect(deleteSession).toHaveBeenCalledWith({
|
|
339
|
+
sessionID: 'ses_orphan',
|
|
340
|
+
directory: fs.realpathSync(directory),
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('does not delete on ambiguous prompt network/5xx; marks prompt_ambiguous with sessionID', async () => {
|
|
345
|
+
const directory = root();
|
|
346
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
347
|
+
const createSession = vi.fn(async () => ({ data: { id: 'ses_maybe' } }));
|
|
348
|
+
const promptExisting = vi.fn(async () => {
|
|
349
|
+
throw Object.assign(new Error('fetch failed'), { name: 'TypeError' });
|
|
350
|
+
});
|
|
351
|
+
const deleteSession = vi.fn(async () => ({ data: true }));
|
|
352
|
+
await expect(assignSession({
|
|
353
|
+
prompt: 'Fix',
|
|
354
|
+
directory,
|
|
355
|
+
messageID: 'msg_assign_fixed',
|
|
356
|
+
assistant: { providerID: 'p', modelID: 'm' },
|
|
357
|
+
allowedRoots: [directory],
|
|
358
|
+
managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
|
|
359
|
+
createSession,
|
|
360
|
+
promptExisting,
|
|
361
|
+
deleteSession,
|
|
362
|
+
})).rejects.toMatchObject({
|
|
363
|
+
code: ASSIGN_CODES.PROMPT_AMBIGUOUS,
|
|
364
|
+
ambiguous: true,
|
|
365
|
+
sessionID: 'ses_maybe',
|
|
366
|
+
messageID: 'msg_assign_fixed',
|
|
367
|
+
});
|
|
368
|
+
expect(deleteSession).not.toHaveBeenCalled();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('confirms ambiguous prompt via lookupMessage instead of deleting or recreating', async () => {
|
|
372
|
+
const directory = root();
|
|
373
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
374
|
+
const promptExisting = vi.fn(async () => ({ error: { status: 503 }, response: { status: 503 } }));
|
|
375
|
+
const deleteSession = vi.fn();
|
|
376
|
+
const lookupMessage = vi.fn(async () => ({ found: true, id: 'msg_assign_ok' }));
|
|
377
|
+
const assigned = await assignSession({
|
|
378
|
+
prompt: 'Fix',
|
|
379
|
+
directory,
|
|
380
|
+
messageID: 'msg_assign_ok',
|
|
381
|
+
assistant: { providerID: 'p', modelID: 'm' },
|
|
382
|
+
allowedRoots: [directory],
|
|
383
|
+
managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
|
|
384
|
+
createSession: async () => ({ data: { id: 'ses_confirmed' } }),
|
|
385
|
+
promptExisting,
|
|
386
|
+
deleteSession,
|
|
387
|
+
lookupMessage,
|
|
388
|
+
});
|
|
389
|
+
expect(assigned).toMatchObject({
|
|
390
|
+
sessionID: 'ses_confirmed',
|
|
391
|
+
messageID: 'msg_assign_ok',
|
|
392
|
+
promptStatus: 'confirmed',
|
|
393
|
+
});
|
|
394
|
+
expect(deleteSession).not.toHaveBeenCalled();
|
|
395
|
+
expect(lookupMessage).toHaveBeenCalledWith(expect.objectContaining({
|
|
396
|
+
sessionID: 'ses_confirmed',
|
|
397
|
+
messageID: 'msg_assign_ok',
|
|
398
|
+
}));
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it.each([false, true])('confirms an ambiguous response for reused=%s through one admission path', async (reused) => {
|
|
402
|
+
const directory = root();
|
|
403
|
+
const deleteSession = vi.fn();
|
|
404
|
+
const createSession = vi.fn(async () => ({ data: { id: 'ses_common' } }));
|
|
405
|
+
const result = await assignSession({
|
|
406
|
+
prompt: 'Fix', directory, allowedRoots: [directory],
|
|
407
|
+
assistant: { providerID: 'p', modelID: 'm' },
|
|
408
|
+
...(reused ? { sessionID: 'ses_common' } : {}),
|
|
409
|
+
createSession, deleteSession,
|
|
410
|
+
promptExisting: async () => ({ error: { status: 503 } }),
|
|
411
|
+
lookupMessage: async () => ({ found: true }),
|
|
412
|
+
});
|
|
413
|
+
expect(result).toMatchObject({ sessionID: 'ses_common', reused, promptStatus: 'confirmed' });
|
|
414
|
+
expect(createSession).toHaveBeenCalledTimes(reused ? 0 : 1);
|
|
415
|
+
expect(deleteSession).not.toHaveBeenCalled();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it.each([false, true])('cleans up only new workers after definite rejection, reused=%s', async (reused) => {
|
|
419
|
+
const directory = root();
|
|
420
|
+
const deleteSession = vi.fn();
|
|
421
|
+
await expect(assignSession({
|
|
422
|
+
prompt: 'Fix', directory, allowedRoots: [directory],
|
|
423
|
+
assistant: { providerID: 'p', modelID: 'm' },
|
|
424
|
+
...(reused ? { sessionID: 'ses_common' } : {}),
|
|
425
|
+
createSession: async () => ({ data: { id: 'ses_common' } }),
|
|
426
|
+
deleteSession,
|
|
427
|
+
promptExisting: async () => ({ error: { status: 400 } }),
|
|
428
|
+
})).rejects.toMatchObject({ code: ASSIGN_CODES.UPSTREAM });
|
|
429
|
+
expect(deleteSession).toHaveBeenCalledTimes(reused ? 0 : 1);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('bounds create/prompt with deadlines', async () => {
|
|
433
|
+
const directory = root();
|
|
434
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
435
|
+
await expect(assignSession({
|
|
436
|
+
prompt: 'Fix',
|
|
437
|
+
directory,
|
|
438
|
+
assistant: { providerID: 'p', modelID: 'm' },
|
|
439
|
+
allowedRoots: [directory],
|
|
440
|
+
managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
|
|
441
|
+
createTimeoutMs: 20,
|
|
442
|
+
createSession: async () => new Promise(() => {}),
|
|
443
|
+
promptExisting: vi.fn(),
|
|
444
|
+
})).rejects.toMatchObject({ code: ASSIGN_CODES.UPSTREAM, message: expect.stringMatching(/timed out/) });
|
|
445
|
+
});
|
|
446
|
+
|
|
99
447
|
it('reuses an existing session without creating another', async () => {
|
|
100
448
|
const directory = root();
|
|
101
449
|
fs.mkdirSync(directory, { recursive: true });
|
|
102
450
|
const createSession = vi.fn();
|
|
103
451
|
const promptExisting = vi.fn(async () => ({ response: { status: 204 } }));
|
|
452
|
+
const deleteSession = vi.fn();
|
|
104
453
|
const assigned = await assignSession({
|
|
105
454
|
prompt: 'Continue login',
|
|
106
455
|
sessionID: 'ses_existing',
|
|
@@ -110,9 +459,11 @@ describe('assignSession', () => {
|
|
|
110
459
|
managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
|
|
111
460
|
createSession,
|
|
112
461
|
promptExisting,
|
|
462
|
+
deleteSession,
|
|
113
463
|
});
|
|
114
464
|
expect(assigned).toMatchObject({ sessionID: 'ses_existing', reused: true, status: 'busy' });
|
|
115
465
|
expect(createSession).not.toHaveBeenCalled();
|
|
466
|
+
expect(deleteSession).not.toHaveBeenCalled();
|
|
116
467
|
expect(promptExisting).toHaveBeenCalledWith(expect.objectContaining({ sessionID: 'ses_existing' }));
|
|
117
468
|
});
|
|
118
469
|
});
|
|
@@ -74,9 +74,23 @@ export function parseContactCard(value) {
|
|
|
74
74
|
function parseContactFilePart(value) {
|
|
75
75
|
if (!isRecord(value) || value.type !== 'file') return null;
|
|
76
76
|
const mime = string(value.mime);
|
|
77
|
-
|
|
78
|
-
if (!mime || !url) return null;
|
|
77
|
+
if (!mime) return null;
|
|
79
78
|
const filename = string(value.filename);
|
|
79
|
+
const attachmentID = string(value.attachmentID);
|
|
80
|
+
const sha256 = string(value.sha256);
|
|
81
|
+
const size = typeof value.size === 'number' && Number.isSafeInteger(value.size) && value.size >= 0
|
|
82
|
+
? value.size
|
|
83
|
+
: null;
|
|
84
|
+
// Preferred durable form: opaque attachmentID (no client path/url authority).
|
|
85
|
+
if (attachmentID) {
|
|
86
|
+
const next = { type: 'file', mime, attachmentID };
|
|
87
|
+
if (filename) next.filename = filename;
|
|
88
|
+
if (sha256) next.sha256 = sha256;
|
|
89
|
+
if (size != null) next.size = size;
|
|
90
|
+
return next;
|
|
91
|
+
}
|
|
92
|
+
const url = typeof value.url === 'string' && value.url.trim() ? value.url.trim() : '';
|
|
93
|
+
if (!url) return null;
|
|
80
94
|
return filename ? { type: 'file', mime, url, filename } : { type: 'file', mime, url };
|
|
81
95
|
}
|
|
82
96
|
|
|
@@ -68,7 +68,7 @@ describe('contact cards', () => {
|
|
|
68
68
|
})).toBeNull()
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
-
it('parses contact file parts with optional filename', () => {
|
|
71
|
+
it('parses contact file parts with optional filename and attachmentID refs', () => {
|
|
72
72
|
expect(parseContactPart({
|
|
73
73
|
type: 'file',
|
|
74
74
|
mime: 'image/png',
|
|
@@ -89,6 +89,21 @@ describe('contact cards', () => {
|
|
|
89
89
|
mime: 'text/plain',
|
|
90
90
|
url: 'data:text/plain;base64,eA==',
|
|
91
91
|
})
|
|
92
|
+
expect(parseContactPart({
|
|
93
|
+
type: 'file',
|
|
94
|
+
mime: 'image/png',
|
|
95
|
+
attachmentID: 'att_abc',
|
|
96
|
+
sha256: 'a'.repeat(64),
|
|
97
|
+
size: 12,
|
|
98
|
+
filename: 'shot.png',
|
|
99
|
+
})).toEqual({
|
|
100
|
+
type: 'file',
|
|
101
|
+
mime: 'image/png',
|
|
102
|
+
attachmentID: 'att_abc',
|
|
103
|
+
sha256: 'a'.repeat(64),
|
|
104
|
+
size: 12,
|
|
105
|
+
filename: 'shot.png',
|
|
106
|
+
})
|
|
92
107
|
expect(parseContactPart({ type: 'file', mime: 'image/png' })).toBeNull()
|
|
93
108
|
})
|
|
94
109
|
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolated acceptance for server-authoritative contact working without touching :3001.
|
|
3
|
+
* Exercises the same snapshot DTO shape HTTP clients pull after APP reload.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import express from 'express';
|
|
10
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
11
|
+
import { createAssistantsService } from './service.js';
|
|
12
|
+
|
|
13
|
+
const root = () => fs.mkdtempSync(path.join(os.tmpdir(), 'contact-http-'));
|
|
14
|
+
const closers = [];
|
|
15
|
+
|
|
16
|
+
const listen = (app) => new Promise((resolve) => {
|
|
17
|
+
const server = http.createServer(app);
|
|
18
|
+
server.listen(0, '127.0.0.1', () => {
|
|
19
|
+
const { port } = server.address();
|
|
20
|
+
closers.push(() => new Promise((done) => server.close(() => done())));
|
|
21
|
+
resolve({ port, base: `http://127.0.0.1:${port}` });
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const json = async (base, pathName, init = {}) => {
|
|
26
|
+
const response = await fetch(`${base}${pathName}`, {
|
|
27
|
+
...init,
|
|
28
|
+
headers: { 'content-type': 'application/json', ...(init.headers || {}) },
|
|
29
|
+
});
|
|
30
|
+
const body = await response.json().catch(() => null);
|
|
31
|
+
return { status: response.status, body };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
while (closers.length) {
|
|
36
|
+
await closers.pop()();
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('contact active turn HTTP snapshot (isolated)', () => {
|
|
41
|
+
it('admission → running → settle is visible on snapshot GET; APP reload matches; missed-end idle poll converges', async () => {
|
|
42
|
+
const directory = root();
|
|
43
|
+
let release;
|
|
44
|
+
const blocked = new Promise((resolve) => { release = resolve; });
|
|
45
|
+
const service = createAssistantsService({
|
|
46
|
+
dbPath: path.join(directory, 'assistants.sqlite'),
|
|
47
|
+
dataDir: directory,
|
|
48
|
+
getAllowedRoots: () => [directory],
|
|
49
|
+
buildOpenCodeUrl: () => 'http://127.0.0.1:1',
|
|
50
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
51
|
+
clientFactory: () => ({
|
|
52
|
+
session: {
|
|
53
|
+
create: async () => ({ data: { id: 'ses_x' } }),
|
|
54
|
+
get: async () => ({ data: { id: 'ses_x' } }),
|
|
55
|
+
update: async () => ({ data: { id: 'ses_x' } }),
|
|
56
|
+
promptAsync: async () => ({ response: { status: 204 } }),
|
|
57
|
+
summarize: async () => ({ data: true }),
|
|
58
|
+
delete: async () => ({ data: true }),
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
runContactTurn: async () => {
|
|
62
|
+
await blocked;
|
|
63
|
+
return { text: 'done', bubbles: ['done'] };
|
|
64
|
+
},
|
|
65
|
+
createChatCompletion: async () => ({ choices: [{ message: { content: 'x' } }] }),
|
|
66
|
+
});
|
|
67
|
+
closers.push(async () => service.close());
|
|
68
|
+
service.setEnabled({ enabled: true, expectedRevision: service.snapshot().revision });
|
|
69
|
+
const assistant = service.createAssistant({
|
|
70
|
+
name: 'HTTP Bot',
|
|
71
|
+
providerID: 'p',
|
|
72
|
+
modelID: 'm',
|
|
73
|
+
defaultPrompt: '',
|
|
74
|
+
mode: 'continuous',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const app = express();
|
|
78
|
+
app.use(express.json({ limit: '2mb' }));
|
|
79
|
+
app.get('/api/openchamber/assistants/snapshot', (_req, res) => res.json(service.snapshot()));
|
|
80
|
+
app.post('/api/openchamber/assistants/:id/messages', async (req, res) => {
|
|
81
|
+
try {
|
|
82
|
+
const body = await service.send(req.params.id, req.body);
|
|
83
|
+
res.status(202).json(body);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
res.status(400).json({ error: error.code || 'error', message: error.message });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
const { base } = await listen(app);
|
|
89
|
+
|
|
90
|
+
// Fresh client: idle
|
|
91
|
+
let snap = await json(base, '/api/openchamber/assistants/snapshot');
|
|
92
|
+
expect(snap.status).toBe(200);
|
|
93
|
+
expect(snap.body.assistants[0]).toMatchObject({
|
|
94
|
+
id: assistant.id,
|
|
95
|
+
working: false,
|
|
96
|
+
activeContactTurn: null,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const admitted = await json(base, `/api/openchamber/assistants/${encodeURIComponent(assistant.id)}/messages`, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
body: JSON.stringify({ messageID: 'http_turn_1', parts: [{ type: 'text', text: 'hi' }] }),
|
|
102
|
+
});
|
|
103
|
+
expect(admitted.status).toBe(202);
|
|
104
|
+
expect(admitted.body).toMatchObject({ admitted: true, messageID: 'http_turn_1', revision: expect.any(Number) });
|
|
105
|
+
|
|
106
|
+
// Live client observes working after admit
|
|
107
|
+
snap = await json(base, '/api/openchamber/assistants/snapshot');
|
|
108
|
+
expect(snap.body.assistants[0].working).toBe(true);
|
|
109
|
+
expect(snap.body.assistants[0].activeContactTurn).toMatchObject({
|
|
110
|
+
turnID: 'http_turn_1',
|
|
111
|
+
messageID: 'http_turn_1',
|
|
112
|
+
status: expect.stringMatching(/^(queued|running)$/),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// APP reload / new client: same authoritative snapshot
|
|
116
|
+
const reloaded = await json(base, '/api/openchamber/assistants/snapshot');
|
|
117
|
+
expect(reloaded.body.assistants[0].working).toBe(true);
|
|
118
|
+
expect(reloaded.body.assistants[0].activeContactTurn.turnID).toBe('http_turn_1');
|
|
119
|
+
|
|
120
|
+
// Missed contact-turn-end: poll until idle
|
|
121
|
+
release();
|
|
122
|
+
await service.whenContactTurnSettled('http_turn_1');
|
|
123
|
+
let idle = null;
|
|
124
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
125
|
+
idle = await json(base, '/api/openchamber/assistants/snapshot');
|
|
126
|
+
if (!idle.body.assistants[0].working) break;
|
|
127
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
128
|
+
}
|
|
129
|
+
expect(idle.body.assistants[0]).toMatchObject({
|
|
130
|
+
working: false,
|
|
131
|
+
activeContactTurn: null,
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|