@openchambery/web 1.19.11 → 1.19.13-beta.1
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-CiQIIM0d.js} +1 -1
- package/dist/assets/AssistantView-YmeyxehG.js +2 -0
- package/dist/assets/{DiagramView-BQO1cBLs.js → DiagramView-BpZb-G9P.js} +1 -1
- package/dist/assets/{FadeInOnReveal-CTlNE4Hg.js → FadeInOnReveal-nf6-ZCrs.js} +1 -1
- package/dist/assets/{Icon-BYM02QyU.js → Icon-CeKHYyKZ.js} +1 -1
- package/dist/assets/MarkdownRenderer-wpeGdciL.js +2 -0
- package/dist/assets/{MarkdownRendererImpl--takeMXN.js → MarkdownRendererImpl-Peu0GVSQ.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-DGSqWcPN.js → MarkstreamRendererImpl-B-odd2Gz.js} +1 -1
- package/dist/assets/MobileDetailNavigation-CtYke_1C.js +1 -0
- package/dist/assets/{MobileShareBridge-D-BUjACe.js → MobileShareBridge-DIfOl8EZ.js} +3 -3
- package/dist/assets/MobileSurface-_KoX75Ff.js +2 -0
- package/dist/assets/{MultiRunWindow-BNR9ZWmD.js → MultiRunWindow-BT5Dwgzu.js} +1 -1
- package/dist/assets/{OnboardingScreen-DS2nugLX.js → OnboardingScreen-BPusUgFG.js} +1 -1
- package/dist/assets/{OverlayScrollbar-KUpvYHdv.js → OverlayScrollbar-DUghqFGg.js} +1 -1
- package/dist/assets/{SettingsView-DGerg-C2.js → SettingsView-BwQpbIZl.js} +11 -11
- package/dist/assets/{SettingsWindow-CSpd5JbJ.js → SettingsWindow-DfEGfyr8.js} +1 -1
- package/dist/assets/{TerminalView-DS-oUxJr.js → TerminalView-8402vpcO.js} +1 -1
- package/dist/assets/{ToolOutputDialog-Q1k9-IaY.js → ToolOutputDialog-B5RdkhsG.js} +1 -1
- package/dist/assets/{appThemeRegistry-CG7D-3-q.js → appThemeRegistry-CIZ-shMD.js} +1 -1
- package/dist/assets/{button-C0cth9tl.js → button-DFPOdZ0g.js} +1 -1
- package/dist/assets/{clipboard-DJ4nQEuY.js → clipboard-C05anBwU.js} +1 -1
- package/dist/assets/{collapsible-DFT9cE80.js → collapsible-oGbEvbe1.js} +1 -1
- package/dist/assets/{context-menu-CAp74NfV.js → context-menu-DGz4_2hs.js} +1 -1
- package/dist/assets/{desktopHosts-Dc2PS66M.js → desktopHosts-BVJXQq1S.js} +1 -1
- package/dist/assets/{desktopRelayRestore-T9y0orqa.js → desktopRelayRestore-RDfuj1M7.js} +1 -1
- package/dist/assets/{diagram-BryzMuta.js → diagram-Csk5GpDC.js} +1 -1
- package/dist/assets/{dialog-CgbV5ljg.js → dialog-rrqB-H6q.js} +1 -1
- package/dist/assets/{es-BHqnSjoZ.js → es-FE0sJ9EC.js} +1 -1
- package/dist/assets/{fileQueries-BY-9OWzp.js → fileQueries-BcdWOPlD.js} +1 -1
- package/dist/assets/{fr-BfrlJY9i.js → fr-DNDCSfGt.js} +1 -1
- package/dist/assets/{gitApi-Cr-joUEw.js → gitApi-_P0FsoXe.js} +1 -1
- package/dist/assets/{input-DFLpqjGR.js → input-BO3r20op.js} +1 -1
- package/dist/assets/{insertionBoundaries-X0xbikAA.js → insertionBoundaries-CWoioTdE.js} +1 -1
- package/dist/assets/{ja-DP-7qt-x.js → ja-BDwrk0cM.js} +1 -1
- package/dist/assets/{ko-E2B5vGQP.js → ko-BeeU2o4I.js} +1 -1
- package/dist/assets/{main-D0L7fIg3.js → main-CLj_WS1o.js} +2 -2
- package/dist/assets/{main-BI7fNQoi.js → main-CnSZ89pD.js} +4 -4
- package/dist/assets/{markdown-worker-CTsrtpPh.js → markdown-worker-CZWE1F1M.js} +1 -1
- package/dist/assets/{markdownHeightCache-CYDlisxr.js → markdownHeightCache-Cdd3hkMl.js} +1 -1
- package/dist/assets/{markdownTheme-DyJ4Gfcj.js → markdownTheme-DIB8WVmz.js} +1 -1
- package/dist/assets/miniChat-B9c3y2pD.js +2 -0
- package/dist/assets/{mobile-OcCu_0mG.js → mobile-DvbXUfNS.js} +2 -2
- package/dist/assets/{multirun-B0G_BEIN.js → multirun-CxhTsmRq.js} +1 -1
- package/dist/assets/{persistence-DdUac14I.js → persistence-CVTWFaqe.js} +2 -2
- package/dist/assets/{pl-r8X5gRM5.js → pl-BYuzpcI5.js} +1 -1
- package/dist/assets/{platform-B4KKLnZ5.js → platform-BY_Gtsqr.js} +1 -1
- package/dist/assets/{projectMeta-BNOzn1QS.js → projectMeta-Dl5iPCB6.js} +1 -1
- package/dist/assets/{pt-BR-DcpdXCf5.js → pt-BR-Bn7cwEzv.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-BREc3uhX.js → renderElectronMiniChatApp-CkJ51qCh.js} +2 -2
- package/dist/assets/renderMobileApp-DcjQPhWw.js +28 -0
- package/dist/assets/{runtimeConfig-P8nVMYzE.js → runtimeConfig-Bsk2kWsW.js} +1 -1
- package/dist/assets/runtimeEndpointReset-BBLFgl85.js +147 -0
- package/dist/assets/{sessionChangeSummary-hVby3sJk.js → sessionChangeSummary-DDVT7O5J.js} +1 -1
- package/dist/assets/{sessionLookup-C_9-9ggp.js → sessionLookup-C8VZn0gJ.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-C8_9kHqu.js → sortable-tabs-strip-s7UUxsa6.js} +1 -1
- package/dist/assets/src-BpHDMgud.css +2 -0
- package/dist/assets/{store-BDFdnk6l.js → store-n94dSb3M.js} +2 -2
- package/dist/assets/{terminalApi-947l7Ra3.js → terminalApi-C54_rKEK.js} +1 -1
- package/dist/assets/{textarea-Cgo_nWUU.js → textarea-BjX-KhMN.js} +1 -1
- package/dist/assets/{tooltip-BifLJ8o0.js → tooltip-CQGqGAG_.js} +1 -1
- package/dist/assets/{uk-Di0vl9V-.js → uk-CwpbHEa3.js} +1 -1
- package/dist/assets/{url-Pk6tQ1EZ.js → url-BMjqccCo.js} +1 -1
- package/dist/assets/{useAppFontEffects-CEmOsrr6.js → useAppFontEffects-BIFw36SA.js} +5 -5
- package/dist/assets/{useAssistantUIStore-me6s63b2.js → useAssistantUIStore-Cg9bsT4b.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-CxaK-zXh.js → useDesktopWindowControlsLayout-DoygEcN4.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-BIwv5r5l.js → useEffectiveDirectory-DJSYYkVp.js} +1 -1
- package/dist/assets/{useFontPreferences-BCOBlaAd.js → useFontPreferences-B5_apbBC.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-DHm5kZPx.js → useMobileNavigationStore-DuTu0Mzh.js} +5 -5
- package/dist/assets/{useSessionAutoCleanup-CPLP9KWH.js → useSessionAutoCleanup-C-L9teva.js} +1 -1
- package/dist/assets/{useUIStore-CuaA8-Hp.js → useUIStore-DwpQTjLO.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-CZ4ZTWEH.js → useWorkerHighlightedLines-DxTfgdEi.js} +1 -1
- package/dist/assets/{utils-BiK3tBGM.js → utils-81nUI_xp.js} +1 -1
- package/dist/assets/{zh-CN-coFCfeQE.js → zh-CN-BpzFZ-LU.js} +1 -1
- package/dist/assets/{zh-TW-oDRZ1-yb.js → zh-TW-Bopgc5Eo.js} +1 -1
- package/dist/index.html +9 -9
- package/dist/mini-chat.html +9 -9
- package/dist/mobile.html +9 -9
- package/package.json +1 -1
- package/server/index.js +19 -0
- package/server/lib/assistants/DOCUMENTATION.md +21 -6
- package/server/lib/assistants/assign.js +455 -30
- package/server/lib/assistants/assign.test.js +351 -0
- package/server/lib/assistants/contact-active-turn.http.test.js +134 -0
- package/server/lib/assistants/contact-store.js +64 -0
- package/server/lib/assistants/contact-store.test.js +12 -0
- package/server/lib/assistants/contact-tools.js +216 -25
- package/server/lib/assistants/contact-tools.test.js +167 -0
- package/server/lib/assistants/contracts.js +2 -2
- package/server/lib/assistants/harness.js +20 -3
- package/server/lib/assistants/harness.test.js +120 -1
- package/server/lib/assistants/pi-tools.js +50 -3
- package/server/lib/assistants/pi-tools.test.js +25 -0
- package/server/lib/assistants/service.js +320 -27
- package/server/lib/assistants/service.test.js +581 -9
- package/server/lib/llm/DOCUMENTATION.md +31 -18
- package/server/lib/llm/catalog.js +91 -16
- package/server/lib/llm/catalog.test.js +152 -11
- package/server/lib/llm/generate.js +43 -7
- package/server/lib/llm/generate.test.js +98 -1
- package/server/lib/llm/temp-directory.js +34 -13
- package/server/lib/llm/temp-directory.test.js +57 -0
- 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/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-BwrcqJzu.js +0 -2
- package/dist/assets/MarkdownRenderer-C49bfcHx.js +0 -2
- package/dist/assets/MobileDetailNavigation-LZU2vFAs.js +0 -1
- package/dist/assets/MobileSurface-OAJjAQCZ.js +0 -2
- package/dist/assets/miniChat-BJ9AclZX.js +0 -2
- package/dist/assets/renderMobileApp-C4igZ42L.js +0 -28
- package/dist/assets/runtimeEndpointReset-6lVRspis.js +0 -147
- package/dist/assets/src-B-P5qIBc.css +0 -2
|
@@ -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
|
});
|
|
@@ -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
|
+
});
|
|
@@ -108,6 +108,32 @@ export function nextContactOrdinal(db, assistantID) {
|
|
|
108
108
|
).get(assistantID).next);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/** Load one contact row by message id (null when missing). */
|
|
112
|
+
export function getContactMessage(db, messageID) {
|
|
113
|
+
if (typeof messageID !== 'string' || !messageID) return null;
|
|
114
|
+
const row = db.prepare('SELECT * FROM assistant_contact_message WHERE message_id=?').get(messageID);
|
|
115
|
+
if (!row) return null;
|
|
116
|
+
return hydrateMessage(db, row);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Stable fingerprint for idempotent contact admission replay. */
|
|
120
|
+
export function contactPartsFingerprint(parts) {
|
|
121
|
+
const list = Array.isArray(parts) ? parts : [];
|
|
122
|
+
return json(list.map((part) => {
|
|
123
|
+
if (!part || typeof part !== 'object') return null;
|
|
124
|
+
if (part.type === 'text') return { type: 'text', text: typeof part.text === 'string' ? part.text : '' };
|
|
125
|
+
if (part.type === 'file') {
|
|
126
|
+
return {
|
|
127
|
+
type: 'file',
|
|
128
|
+
mime: typeof part.mime === 'string' ? part.mime : '',
|
|
129
|
+
url: typeof part.url === 'string' ? part.url : '',
|
|
130
|
+
...(typeof part.filename === 'string' && part.filename ? { filename: part.filename } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return part;
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
|
|
111
137
|
export function insertContactMessage(db, {
|
|
112
138
|
messageID,
|
|
113
139
|
assistantID,
|
|
@@ -338,6 +364,44 @@ export function trimContactHistoryForLlm(messages, {
|
|
|
338
364
|
* rows (queued turns) must not be injected into this turn; assistant rows written
|
|
339
365
|
* after that ordinal by a prior completed lane turn still enter the window.
|
|
340
366
|
*/
|
|
367
|
+
/**
|
|
368
|
+
* In-process contact-turn activity (not SQLite). Process memory only: APP
|
|
369
|
+
* restart while the server keeps running can rehydrate from snapshot; a server
|
|
370
|
+
* process restart clears every active turn so the green dot cannot stick forever.
|
|
371
|
+
* Status ladder: admission → queued → running → settled (removed).
|
|
372
|
+
*/
|
|
373
|
+
export const CONTACT_ACTIVE_TURN_STATUSES = Object.freeze(['queued', 'running']);
|
|
374
|
+
|
|
375
|
+
export function createActiveContactTurn({ turnID, messageID, status = 'queued', admittedAt }) {
|
|
376
|
+
if (typeof turnID !== 'string' || !turnID.trim()) return null;
|
|
377
|
+
if (typeof messageID !== 'string' || !messageID.trim()) return null;
|
|
378
|
+
if (!CONTACT_ACTIVE_TURN_STATUSES.includes(status)) return null;
|
|
379
|
+
const at = Number(admittedAt);
|
|
380
|
+
if (!Number.isFinite(at)) return null;
|
|
381
|
+
return {
|
|
382
|
+
turnID: turnID.trim(),
|
|
383
|
+
messageID: messageID.trim(),
|
|
384
|
+
status,
|
|
385
|
+
admittedAt: Math.trunc(at),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Earliest admitted active turn; running preferred when admittedAt ties. */
|
|
390
|
+
export function projectActiveContactTurn(activeTurns) {
|
|
391
|
+
const list = Array.isArray(activeTurns)
|
|
392
|
+
? activeTurns
|
|
393
|
+
: (activeTurns instanceof Map ? [...activeTurns.values()] : []);
|
|
394
|
+
const valid = list
|
|
395
|
+
.map((item) => createActiveContactTurn(item))
|
|
396
|
+
.filter(Boolean)
|
|
397
|
+
.sort((left, right) => {
|
|
398
|
+
if (left.admittedAt !== right.admittedAt) return left.admittedAt - right.admittedAt;
|
|
399
|
+
if (left.status === right.status) return left.turnID.localeCompare(right.turnID);
|
|
400
|
+
return left.status === 'running' ? -1 : 1;
|
|
401
|
+
});
|
|
402
|
+
return valid[0] ?? null;
|
|
403
|
+
}
|
|
404
|
+
|
|
341
405
|
export function contactHistoryForLlm(db, assistantID, { excludeMessageIDs = [], beforeOrdinal = null } = {}) {
|
|
342
406
|
const afterOrdinal = getContactContextBoundary(db, assistantID);
|
|
343
407
|
const exclude = new Set(
|
|
@@ -11,12 +11,14 @@ import {
|
|
|
11
11
|
CONTACT_LLM_MAX_TURNS,
|
|
12
12
|
clearContactMemory,
|
|
13
13
|
contactHistoryForLlm,
|
|
14
|
+
createActiveContactTurn,
|
|
14
15
|
deleteContactMessages,
|
|
15
16
|
ensureContactSchema,
|
|
16
17
|
getContactContextBoundary,
|
|
17
18
|
insertContactMessage,
|
|
18
19
|
listContactMessages,
|
|
19
20
|
nextContactOrdinal,
|
|
21
|
+
projectActiveContactTurn,
|
|
20
22
|
trimContactHistoryForLlm,
|
|
21
23
|
} from './contact-store.js';
|
|
22
24
|
|
|
@@ -231,6 +233,16 @@ describe('contact LLM history trim', () => {
|
|
|
231
233
|
reopened.close();
|
|
232
234
|
});
|
|
233
235
|
|
|
236
|
+
it('projects the earliest active contact turn for snapshot recovery', () => {
|
|
237
|
+
expect(createActiveContactTurn({ turnID: '', messageID: 'm', status: 'queued', admittedAt: 1 })).toBeNull();
|
|
238
|
+
expect(createActiveContactTurn({ turnID: 't', messageID: 'm', status: 'settled', admittedAt: 1 })).toBeNull();
|
|
239
|
+
const queued = createActiveContactTurn({ turnID: 't1', messageID: 'm1', status: 'queued', admittedAt: 20 });
|
|
240
|
+
const running = createActiveContactTurn({ turnID: 't0', messageID: 'm0', status: 'running', admittedAt: 10 });
|
|
241
|
+
expect(projectActiveContactTurn([queued, running])).toEqual(running);
|
|
242
|
+
expect(projectActiveContactTurn(new Map([['t1', queued]]))).toEqual(queued);
|
|
243
|
+
expect(projectActiveContactTurn([])).toBeNull();
|
|
244
|
+
});
|
|
245
|
+
|
|
234
246
|
it('keeps the previous boundary when clearContactMemory write fails inside a transaction', () => {
|
|
235
247
|
const db = openDb();
|
|
236
248
|
const assistantID = 'asst_fail';
|