@openchambery/web 1.19.13 → 1.19.15-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-mbvX7YlW.js → AgentAvatar-m1jNq4bX.js} +1 -1
- package/dist/assets/AssistantView-D5rEZ-Ss.js +2 -0
- package/dist/assets/{DiagramView-BdI4PT2H.js → DiagramView-DaB9Sb7S.js} +1 -1
- package/dist/assets/{FadeInOnReveal-moBbL8Ll.js → FadeInOnReveal-UGwClbL_.js} +1 -1
- package/dist/assets/Icon-D1BcR8Tz.js +1 -0
- package/dist/assets/MarkdownRenderer-emLeoNjI.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-2ewbRZE2.js → MarkdownRendererImpl-BWCAPlqn.js} +3 -3
- package/dist/assets/{MarkstreamRendererImpl-DXewO7S_.js → MarkstreamRendererImpl-BD_NFKUv.js} +2 -2
- package/dist/assets/MobileDetailNavigation-DqXVAIap.js +1 -0
- package/dist/assets/MobileShareBridge-Bk2fdkJk.js +4 -0
- package/dist/assets/MobileSurface-C4mpcOCh.js +2 -0
- package/dist/assets/{MultiRunWindow-C8ukIdLA.js → MultiRunWindow-rs49_K1E.js} +1 -1
- package/dist/assets/{OnboardingScreen-j0iCyH7f.js → OnboardingScreen-bIWeMLVx.js} +1 -1
- package/dist/assets/{OverlayScrollbar-CDC5B3RH.js → OverlayScrollbar-Bahg_P3r.js} +1 -1
- package/dist/assets/SettingsView-b8LUjoad.js +25 -0
- package/dist/assets/{SettingsWindow-CSePZ_V8.js → SettingsWindow-D8JFlPdA.js} +1 -1
- package/dist/assets/{TerminalView-BmSIrZ81.js → TerminalView-DCd-Godi.js} +1 -1
- package/dist/assets/{ToolOutputDialog-2BYaWLo1.js → ToolOutputDialog-B50L_nUP.js} +1 -1
- package/dist/assets/{appThemeRegistry-Dk4gBaOu.js → appThemeRegistry-BWtCaTMe.js} +1 -1
- package/dist/assets/{button-xx1UOBA5.js → button-9R-WgVMl.js} +1 -1
- package/dist/assets/{clipboard-CUasgPiF.js → clipboard-GUUNlWIv.js} +1 -1
- package/dist/assets/{collapsible-CACHBRYZ.js → collapsible-Bbz9KiJf.js} +1 -1
- package/dist/assets/{context-menu-CYbm4wBZ.js → context-menu-CSyOhRz8.js} +1 -1
- package/dist/assets/{desktopHosts-DILvrAok.js → desktopHosts-B052a4UK.js} +1 -1
- package/dist/assets/{desktopRelayRestore-t8asCr3m.js → desktopRelayRestore-OjKNXr10.js} +1 -1
- package/dist/assets/{diagram-u0BC9uuY.js → diagram-NdsZih8P.js} +1 -1
- package/dist/assets/{dialog-BwQ3rY_x.js → dialog-DhMGbF_B.js} +1 -1
- package/dist/assets/{es-CRjOwvYM.js → es-BQb6I8gl.js} +2 -2
- package/dist/assets/fileMentionTouchSelection-DKYkoypI.js +4 -0
- package/dist/assets/{fileQueries-Bgly-E8f.js → fileQueries-B_s_B-fH.js} +1 -1
- package/dist/assets/{fr-D9taEN9L.js → fr-BjGouC_l.js} +2 -2
- package/dist/assets/{gitApi-DtPf-h-O.js → gitApi-D4PyYgLZ.js} +3 -3
- package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
- package/dist/assets/insertionBoundaries-Bnnx03Wj.js +1 -0
- package/dist/assets/{ja-CYQ5aoWp.js → ja-Cw9zliYY.js} +2 -2
- package/dist/assets/{ko-BwASV0Q0.js → ko-Dyz0Dk9b.js} +2 -2
- package/dist/assets/main-BRiQTNFX.js +169 -0
- package/dist/assets/{main-Ci3JmtpW.js → main-CnY0LWNc.js} +2 -2
- package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
- package/dist/assets/miniChat-B0pw_mUs.js +2 -0
- package/dist/assets/mobile-BKHmxwXz.js +2 -0
- package/dist/assets/{multirun-CmaTeZON.js → multirun-BQKqVfFT.js} +1 -1
- package/dist/assets/{persistence-YbQKzHUQ.js → persistence-DwRBC4zl.js} +2 -2
- package/dist/assets/{pl-DUI1vPil.js → pl-C7EY-dTH.js} +1 -1
- package/dist/assets/platform-DQ5_LNKO.js +2 -0
- package/dist/assets/{projectMeta-cw9nrwpr.js → projectMeta-BvG8sdtC.js} +2 -2
- package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-B1uHpjFW.js} +2 -2
- package/dist/assets/renderElectronMiniChatApp-E5a0yy7E.js +2 -0
- package/dist/assets/renderMobileApp-DT0L4Ah6.js +28 -0
- package/dist/assets/{runtimeConfig-DI8l0VyZ.js → runtimeConfig-utWzrDwa.js} +1 -1
- package/dist/assets/runtimeEndpointReset-DE6lwHyg.js +147 -0
- package/dist/assets/sessionChangeSummary-BG7l2heP.js +2 -0
- package/dist/assets/sessionLookup-12zVOR0J.js +1 -0
- package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-dYW6SH62.js} +1 -1
- package/dist/assets/src-CFUi4riW.css +2 -0
- package/dist/assets/{store-CSnH_QXM.js → store-ClnJdJSj.js} +3 -3
- package/dist/assets/{terminalApi-noa4Cdbh.js → terminalApi-CfgOfnZk.js} +1 -1
- package/dist/assets/{textarea-BfNtUdJE.js → textarea-B9CmN13Z.js} +1 -1
- package/dist/assets/{tooltip-DPxumadF.js → tooltip-DSjr26A_.js} +1 -1
- package/dist/assets/{uk-C1FYZlmA.js → uk-DgG8v2md.js} +2 -2
- package/dist/assets/{url-CVhsI9kR.js → url-CMQwHq4k.js} +1 -1
- package/dist/assets/{useAppFontEffects-BDFUND4v.js → useAppFontEffects-BhYuP4L2.js} +43 -44
- package/dist/assets/useDesktopWindowControlsLayout-DPNMRkrI.js +1 -0
- package/dist/assets/{useEffectiveDirectory-e9HjIqGz.js → useEffectiveDirectory-Ji8AKgzY.js} +1 -1
- package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
- package/dist/assets/useMobileNavigationStore-BYdpuwL7.js +413 -0
- package/dist/assets/useSessionAutoCleanup-CADZCvZL.js +2 -0
- package/dist/assets/{useWorkerHighlightedLines-DTGE0CDs.js → useWorkerHighlightedLines-CuG88Lsj.js} +1 -1
- package/dist/assets/{utils-BHTVlVov.js → utils-2ZK8yboQ.js} +1 -1
- package/dist/assets/{vendor-reactuses-core-DDoDDyM9.js → vendor-reactuses-core-BnW6GxJt.js} +1 -1
- package/dist/assets/{zh-CN-C96qP1ip.js → zh-CN-C6OFKjeT.js} +2 -2
- package/dist/assets/{zh-TW-BsRk1Rbb.js → zh-TW-BVP99aMr.js} +2 -2
- package/dist/index.html +8 -8
- package/dist/mini-chat.html +8 -8
- package/dist/mobile.html +8 -8
- package/package.json +1 -1
- package/server/index.js +1 -0
- package/server/lib/assistants/DOCUMENTATION.md +120 -22
- package/server/lib/assistants/assign.js +117 -12
- package/server/lib/assistants/assign.test.js +79 -0
- package/server/lib/assistants/bubbles.js +1 -1
- package/server/lib/assistants/contact-store.js +520 -11
- package/server/lib/assistants/contact-store.test.js +362 -12
- package/server/lib/assistants/contact-tools.js +305 -54
- package/server/lib/assistants/contact-tools.test.js +239 -7
- package/server/lib/assistants/contracts.js +31 -0
- package/server/lib/assistants/harness.js +295 -132
- package/server/lib/assistants/harness.test.js +662 -10
- package/server/lib/assistants/pi-tools.js +2 -0
- package/server/lib/assistants/pi-tools.test.js +8 -0
- package/server/lib/assistants/routes.js +3 -0
- package/server/lib/assistants/service.js +861 -79
- package/server/lib/assistants/service.test.js +1234 -8
- package/server/lib/llm/DOCUMENTATION.md +10 -3
- package/server/lib/llm/completions.js +4 -0
- package/server/lib/llm/completions.test.js +3 -0
- package/server/lib/llm/generate.js +57 -11
- package/server/lib/llm/generate.test.js +92 -0
- package/server/lib/opencode/feature-routes-runtime.js +5 -0
- package/dist/assets/AssistantView-CKmIS1cS.js +0 -2
- package/dist/assets/Icon-CkzzhaLr.js +0 -1
- package/dist/assets/MarkdownRenderer-CLQ4Z8Z0.js +0 -2
- package/dist/assets/MobileDetailNavigation-BGaAyuBH.js +0 -1
- package/dist/assets/MobileShareBridge-BAZoyeIP.js +0 -4
- package/dist/assets/MobileSurface-BVb3xNWt.js +0 -2
- package/dist/assets/SettingsView-CkQ61pbr.js +0 -25
- package/dist/assets/insertionBoundaries-BMhzPFft.js +0 -4
- package/dist/assets/main-BjeOhdcG.js +0 -169
- package/dist/assets/miniChat-BYrjYEXJ.js +0 -2
- package/dist/assets/mobile-BVMNnPK2.js +0 -2
- package/dist/assets/platform-DzsGqlGW.js +0 -2
- package/dist/assets/renderElectronMiniChatApp-COwia8Cs.js +0 -2
- package/dist/assets/renderMobileApp-CXwwpY1t.js +0 -28
- package/dist/assets/runtimeEndpointReset-BkAQXmXJ.js +0 -147
- package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
- package/dist/assets/sessionLookup-Imc995rW.js +0 -1
- package/dist/assets/src-AA-5nXsF.css +0 -2
- package/dist/assets/useDesktopWindowControlsLayout-C5hZD1co.js +0 -1
- package/dist/assets/useMobileNavigationStore-CYbDxRTM.js +0 -413
- package/dist/assets/useSessionAutoCleanup-BGy9_AhN.js +0 -2
|
@@ -17,22 +17,33 @@ import {
|
|
|
17
17
|
CONTACT_PAGE_MAX_LIMIT,
|
|
18
18
|
CONTACT_PREVIEW_MAX_CHARS,
|
|
19
19
|
CONTACT_SETTLE_TEXT,
|
|
20
|
+
advanceContactReadWatermark,
|
|
20
21
|
bumpContactGeneration,
|
|
21
22
|
clearContactMemory,
|
|
23
|
+
compareContactReadCursor,
|
|
22
24
|
contactHistoryForLlm,
|
|
25
|
+
countContactUnread,
|
|
23
26
|
createActiveContactTurn,
|
|
24
27
|
decodeContactCursor,
|
|
25
28
|
deleteContactMessages,
|
|
29
|
+
deleteContactTurnAssistantOutputs,
|
|
26
30
|
encodeContactCursor,
|
|
27
31
|
ensureContactSchema,
|
|
28
32
|
getContactContextBoundary,
|
|
29
33
|
getContactGeneration,
|
|
34
|
+
getContactReadTip,
|
|
35
|
+
getContactReadWatermark,
|
|
36
|
+
getContactUnreadSnapshots,
|
|
30
37
|
getLatestContactMessagePreview,
|
|
31
38
|
getLatestContactMessagePreviews,
|
|
32
39
|
insertContactMessage,
|
|
40
|
+
isContactUnreadCountableMessage,
|
|
41
|
+
isContactVisiblePart,
|
|
33
42
|
listContactMessages,
|
|
43
|
+
migrateContactReadStateDefaultRead,
|
|
34
44
|
nextContactOrdinal,
|
|
35
45
|
projectActiveContactTurn,
|
|
46
|
+
resetContactReadWatermarkForGeneration,
|
|
36
47
|
sanitizeContactPreviewText,
|
|
37
48
|
trimContactHistoryForLlm,
|
|
38
49
|
} from './contact-store.js';
|
|
@@ -47,14 +58,16 @@ const openDb = () => {
|
|
|
47
58
|
return db;
|
|
48
59
|
};
|
|
49
60
|
|
|
50
|
-
const insert = (db, assistantID, {
|
|
51
|
-
|
|
61
|
+
const insert = (db, assistantID, {
|
|
62
|
+
role, text = '', parts, status = 'complete', fromAssistantID = null, fromAssistantName = null, turnID = null, messageID = null,
|
|
63
|
+
}) => {
|
|
64
|
+
const id = typeof messageID === 'string' && messageID ? messageID : crypto.randomUUID();
|
|
52
65
|
const ordinal = nextContactOrdinal(db, assistantID);
|
|
53
66
|
insertContactMessage(db, {
|
|
54
|
-
messageID,
|
|
67
|
+
messageID: id,
|
|
55
68
|
assistantID,
|
|
56
69
|
role,
|
|
57
|
-
turnID:
|
|
70
|
+
turnID: typeof turnID === 'string' && turnID ? turnID : id,
|
|
58
71
|
bubbleIndex: 0,
|
|
59
72
|
createdAt: Date.now(),
|
|
60
73
|
ordinal,
|
|
@@ -63,35 +76,35 @@ const insert = (db, assistantID, { role, text = '', parts, status = 'complete',
|
|
|
63
76
|
fromAssistantID,
|
|
64
77
|
fromAssistantName,
|
|
65
78
|
});
|
|
66
|
-
return { messageID, ordinal };
|
|
79
|
+
return { messageID: id, ordinal };
|
|
67
80
|
};
|
|
68
81
|
|
|
69
82
|
describe('contact LLM history trim', () => {
|
|
70
83
|
it('documents the budget constants in one place', () => {
|
|
71
|
-
expect(CONTACT_LLM_MAX_TURNS).toBe(
|
|
72
|
-
expect(CONTACT_LLM_MAX_CHARS).toBe(
|
|
73
|
-
expect(CONTACT_LLM_FETCH_LIMIT).toBe(
|
|
84
|
+
expect(CONTACT_LLM_MAX_TURNS).toBe(32);
|
|
85
|
+
expect(CONTACT_LLM_MAX_CHARS).toBe(48_000);
|
|
86
|
+
expect(CONTACT_LLM_FETCH_LIMIT).toBe(100);
|
|
74
87
|
expect(CONTACT_LLM_FILE_CHAR_WEIGHT).toBe(80);
|
|
75
88
|
});
|
|
76
89
|
|
|
77
90
|
it('keeps the newest user+assistant turns and leaves older SQLite rows for the UI', () => {
|
|
78
91
|
const db = openDb();
|
|
79
92
|
const assistantID = 'asst_trim';
|
|
80
|
-
for (let index = 0; index <
|
|
93
|
+
for (let index = 0; index < 36; index += 1) {
|
|
81
94
|
insert(db, assistantID, { role: 'user', text: `user-${index}` });
|
|
82
95
|
insert(db, assistantID, { role: 'assistant', text: `assistant-${index}` });
|
|
83
96
|
}
|
|
84
97
|
const history = contactHistoryForLlm(db, assistantID);
|
|
85
98
|
expect(history).toHaveLength(CONTACT_LLM_MAX_TURNS * 2);
|
|
86
99
|
expect(history[0]).toEqual({ role: 'user', content: 'user-4' });
|
|
87
|
-
expect(history.at(-1)).toEqual({ role: 'assistant', content: 'assistant-
|
|
100
|
+
expect(history.at(-1)).toEqual({ role: 'assistant', content: 'assistant-35' });
|
|
88
101
|
const page = listContactMessages(db, assistantID, { limit: 100 });
|
|
89
|
-
expect(page.messages).toHaveLength(
|
|
102
|
+
expect(page.messages).toHaveLength(72);
|
|
90
103
|
expect(page.messages[0].text).toBe('user-0');
|
|
91
104
|
db.close();
|
|
92
105
|
});
|
|
93
106
|
|
|
94
|
-
it('drops peer
|
|
107
|
+
it('keeps card identity and drops peer and error rows from the LLM window', () => {
|
|
95
108
|
const db = openDb();
|
|
96
109
|
const assistantID = 'asst_filter';
|
|
97
110
|
insert(db, assistantID, { role: 'user', text: 'keep-user' });
|
|
@@ -105,6 +118,7 @@ describe('contact LLM history trim', () => {
|
|
|
105
118
|
expect(contactHistoryForLlm(db, assistantID)).toEqual([
|
|
106
119
|
{ role: 'user', content: 'keep-user' },
|
|
107
120
|
{ role: 'assistant', content: 'keep-assistant' },
|
|
121
|
+
{ role: 'assistant', content: expect.stringContaining('\"sessionID\":\"ses_1\"') },
|
|
108
122
|
]);
|
|
109
123
|
expect(listContactMessages(db, assistantID, { limit: 50 }).messages).toHaveLength(5);
|
|
110
124
|
db.close();
|
|
@@ -169,6 +183,37 @@ describe('contact LLM history trim', () => {
|
|
|
169
183
|
db.close();
|
|
170
184
|
});
|
|
171
185
|
|
|
186
|
+
it('deleteContactTurnAssistantOutputs removes only this turn assistant rows', () => {
|
|
187
|
+
const db = openDb();
|
|
188
|
+
const assistantID = 'asst_turn_out';
|
|
189
|
+
const oldUser = insert(db, assistantID, { role: 'user', text: 'old', turnID: 'turn_old' });
|
|
190
|
+
insert(db, assistantID, { role: 'assistant', text: 'old-reply', turnID: 'turn_old' });
|
|
191
|
+
const clearing = insert(db, assistantID, { role: 'user', text: '开新对话', turnID: 'turn_reset' });
|
|
192
|
+
insert(db, assistantID, { role: 'assistant', text: 'preamble', turnID: 'turn_reset' });
|
|
193
|
+
insert(db, assistantID, {
|
|
194
|
+
role: 'peer',
|
|
195
|
+
text: 'peer keep',
|
|
196
|
+
turnID: 'turn_reset',
|
|
197
|
+
fromAssistantID: 'asst_other',
|
|
198
|
+
fromAssistantName: 'Other',
|
|
199
|
+
});
|
|
200
|
+
const queued = insert(db, assistantID, { role: 'user', text: 'queued', turnID: 'turn_q' });
|
|
201
|
+
const removed = deleteContactTurnAssistantOutputs(db, assistantID, 'turn_reset');
|
|
202
|
+
expect(removed).toBeGreaterThan(0);
|
|
203
|
+
const page = listContactMessages(db, assistantID, { limit: 50 });
|
|
204
|
+
expect(page.messages.map((message) => ({ role: message.role, text: message.text, turn: message.turnID }))).toEqual([
|
|
205
|
+
{ role: 'user', text: 'old', turn: 'turn_old' },
|
|
206
|
+
{ role: 'assistant', text: 'old-reply', turn: 'turn_old' },
|
|
207
|
+
{ role: 'user', text: '开新对话', turn: 'turn_reset' },
|
|
208
|
+
{ role: 'peer', text: 'peer keep', turn: 'turn_reset' },
|
|
209
|
+
{ role: 'user', text: 'queued', turn: 'turn_q' },
|
|
210
|
+
]);
|
|
211
|
+
expect(page.messages[0].messageID).toBe(oldUser.messageID);
|
|
212
|
+
expect(page.messages[2].messageID).toBe(clearing.messageID);
|
|
213
|
+
expect(page.messages[4].messageID).toBe(queued.messageID);
|
|
214
|
+
db.close();
|
|
215
|
+
});
|
|
216
|
+
|
|
172
217
|
it('deleteContactMessages upToOrdinal keeps wipe user + later users; drops late assistant/cards', () => {
|
|
173
218
|
const db = openDb();
|
|
174
219
|
const assistantID = 'asst_wipe_bound';
|
|
@@ -474,6 +519,7 @@ describe('contact latest message preview', () => {
|
|
|
474
519
|
expect(getLatestContactMessagePreview(db, assistantID)).toEqual({
|
|
475
520
|
messageID: latest.messageID,
|
|
476
521
|
ordinal: latest.ordinal,
|
|
522
|
+
createdAt: latest.createdAt,
|
|
477
523
|
role: 'user',
|
|
478
524
|
text: 'newest user line',
|
|
479
525
|
fallbackKind: null,
|
|
@@ -495,6 +541,7 @@ describe('contact latest message preview', () => {
|
|
|
495
541
|
expect(getLatestContactMessagePreview(db, assistantID)).toEqual({
|
|
496
542
|
messageID: peer.messageID,
|
|
497
543
|
ordinal: peer.ordinal,
|
|
544
|
+
createdAt: peer.createdAt,
|
|
498
545
|
role: 'user',
|
|
499
546
|
text: 'hello from peer',
|
|
500
547
|
fallbackKind: null,
|
|
@@ -638,6 +685,7 @@ describe('contact latest message preview', () => {
|
|
|
638
685
|
expect(preview).toMatchObject({
|
|
639
686
|
messageID: latest.messageID,
|
|
640
687
|
ordinal: latest.ordinal,
|
|
688
|
+
createdAt: latest.createdAt,
|
|
641
689
|
role: 'user',
|
|
642
690
|
text: 'visible-after-settles',
|
|
643
691
|
fallbackKind: null,
|
|
@@ -668,3 +716,305 @@ describe('contact latest message preview', () => {
|
|
|
668
716
|
db.close();
|
|
669
717
|
});
|
|
670
718
|
});
|
|
719
|
+
|
|
720
|
+
describe('contact read watermark / unread', () => {
|
|
721
|
+
it('counts only complete/error assistant and peer replies; ignores user and settle-only', () => {
|
|
722
|
+
expect(isContactVisiblePart({ type: 'text', text: 'hi' })).toBe(true);
|
|
723
|
+
expect(isContactVisiblePart({ type: 'text', text: ' ' })).toBe(false);
|
|
724
|
+
expect(isContactVisiblePart({ type: 'text', text: CONTACT_SETTLE_TEXT.complete })).toBe(false);
|
|
725
|
+
expect(isContactVisiblePart({ type: 'file', mime: 'image/png' })).toBe(true);
|
|
726
|
+
expect(isContactUnreadCountableMessage({
|
|
727
|
+
role: 'assistant',
|
|
728
|
+
status: 'complete',
|
|
729
|
+
text: 'hi',
|
|
730
|
+
parts: [{ type: 'text', text: 'hi' }],
|
|
731
|
+
})).toBe(true);
|
|
732
|
+
expect(isContactUnreadCountableMessage({
|
|
733
|
+
role: 'assistant',
|
|
734
|
+
status: 'error',
|
|
735
|
+
text: 'boom',
|
|
736
|
+
parts: [{ type: 'text', text: 'boom' }],
|
|
737
|
+
})).toBe(true);
|
|
738
|
+
expect(isContactUnreadCountableMessage({
|
|
739
|
+
role: 'peer',
|
|
740
|
+
status: 'complete',
|
|
741
|
+
text: 'dm',
|
|
742
|
+
parts: [{ type: 'text', text: 'dm' }],
|
|
743
|
+
})).toBe(true);
|
|
744
|
+
expect(isContactUnreadCountableMessage({
|
|
745
|
+
role: 'user',
|
|
746
|
+
status: 'complete',
|
|
747
|
+
text: 'me',
|
|
748
|
+
parts: [{ type: 'text', text: 'me' }],
|
|
749
|
+
})).toBe(false);
|
|
750
|
+
expect(isContactUnreadCountableMessage({
|
|
751
|
+
role: 'assistant',
|
|
752
|
+
status: 'complete',
|
|
753
|
+
text: CONTACT_SETTLE_TEXT.complete,
|
|
754
|
+
parts: [{ type: 'text', text: CONTACT_SETTLE_TEXT.complete }],
|
|
755
|
+
})).toBe(false);
|
|
756
|
+
// Blank / whitespace-only is not countable.
|
|
757
|
+
expect(isContactUnreadCountableMessage({
|
|
758
|
+
role: 'assistant',
|
|
759
|
+
status: 'complete',
|
|
760
|
+
text: '',
|
|
761
|
+
parts: [{ type: 'text', text: '' }],
|
|
762
|
+
})).toBe(false);
|
|
763
|
+
expect(isContactUnreadCountableMessage({
|
|
764
|
+
role: 'assistant',
|
|
765
|
+
status: 'complete',
|
|
766
|
+
text: ' ',
|
|
767
|
+
parts: [{ type: 'text', text: ' ' }],
|
|
768
|
+
})).toBe(false);
|
|
769
|
+
// Settle beside real body still counts (per-part, not joined-text startsWith).
|
|
770
|
+
expect(isContactUnreadCountableMessage({
|
|
771
|
+
role: 'assistant',
|
|
772
|
+
status: 'complete',
|
|
773
|
+
parts: [
|
|
774
|
+
{ type: 'text', text: CONTACT_SETTLE_TEXT.complete },
|
|
775
|
+
{ type: 'text', text: 'spoken summary' },
|
|
776
|
+
],
|
|
777
|
+
})).toBe(true);
|
|
778
|
+
// Settle prefix + file/card hybrid counts.
|
|
779
|
+
expect(isContactUnreadCountableMessage({
|
|
780
|
+
role: 'assistant',
|
|
781
|
+
status: 'complete',
|
|
782
|
+
parts: [
|
|
783
|
+
{ type: 'text', text: CONTACT_SETTLE_TEXT.error },
|
|
784
|
+
{ type: 'file', mime: 'text/plain', filename: 'a.txt' },
|
|
785
|
+
],
|
|
786
|
+
})).toBe(true);
|
|
787
|
+
|
|
788
|
+
const db = openDb();
|
|
789
|
+
const assistantID = 'asst_unread_count';
|
|
790
|
+
insert(db, assistantID, { role: 'user', text: 'u1' });
|
|
791
|
+
insert(db, assistantID, { role: 'assistant', text: 'a1' });
|
|
792
|
+
insert(db, assistantID, { role: 'assistant', text: CONTACT_SETTLE_TEXT.complete });
|
|
793
|
+
insert(db, assistantID, { role: 'peer', text: 'p1', fromAssistantID: 'other', fromAssistantName: 'Peer' });
|
|
794
|
+
insert(db, assistantID, { role: 'assistant', text: 'failed', status: 'error' });
|
|
795
|
+
// No watermark → all countable unread.
|
|
796
|
+
expect(countContactUnread(db, assistantID)).toBe(3);
|
|
797
|
+
db.close();
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it('SQL single/batch counts match visible-part rules for blank, settle prefix, and mixed parts', () => {
|
|
801
|
+
const db = openDb();
|
|
802
|
+
const assistantID = 'asst_unread_visible';
|
|
803
|
+
insert(db, assistantID, { role: 'assistant', text: '' });
|
|
804
|
+
insert(db, assistantID, { role: 'assistant', text: ' ' });
|
|
805
|
+
insert(db, assistantID, { role: 'assistant', text: CONTACT_SETTLE_TEXT.complete });
|
|
806
|
+
insert(db, assistantID, {
|
|
807
|
+
role: 'assistant',
|
|
808
|
+
parts: [
|
|
809
|
+
{ type: 'text', text: CONTACT_SETTLE_TEXT.question },
|
|
810
|
+
{ type: 'text', text: 'need your input' },
|
|
811
|
+
],
|
|
812
|
+
});
|
|
813
|
+
insert(db, assistantID, {
|
|
814
|
+
role: 'assistant',
|
|
815
|
+
parts: [
|
|
816
|
+
{ type: 'text', text: CONTACT_SETTLE_TEXT.complete },
|
|
817
|
+
createSessionCardPart({
|
|
818
|
+
sessionID: 'ses_visible',
|
|
819
|
+
directory: '/tmp/proj',
|
|
820
|
+
title: 'Work',
|
|
821
|
+
status: 'complete',
|
|
822
|
+
}),
|
|
823
|
+
],
|
|
824
|
+
});
|
|
825
|
+
insert(db, assistantID, { role: 'assistant', text: 'plain' });
|
|
826
|
+
// blank×2 + settle-only excluded; mixed text, settle+card, plain → 3
|
|
827
|
+
expect(countContactUnread(db, assistantID)).toBe(3);
|
|
828
|
+
expect(getContactUnreadSnapshots(db, [assistantID]).get(assistantID)?.unreadCount).toBe(3);
|
|
829
|
+
|
|
830
|
+
const tip = getContactReadTip(db, assistantID);
|
|
831
|
+
advanceContactReadWatermark(db, assistantID, {
|
|
832
|
+
generation: tip.generation,
|
|
833
|
+
ordinal: tip.ordinal,
|
|
834
|
+
messageID: tip.messageID,
|
|
835
|
+
}, { updatedAt: 1 });
|
|
836
|
+
expect(countContactUnread(db, assistantID)).toBe(0);
|
|
837
|
+
expect(getContactUnreadSnapshots(db, [assistantID]).get(assistantID)?.unreadCount).toBe(0);
|
|
838
|
+
db.close();
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it('migrates legacy rows to default-read and only new messages count', () => {
|
|
842
|
+
const db = openDb();
|
|
843
|
+
const assistantID = 'asst_migrate_read';
|
|
844
|
+
const a1 = insert(db, assistantID, { role: 'assistant', text: 'old-a' });
|
|
845
|
+
const a2 = insert(db, assistantID, { role: 'assistant', text: 'old-b' });
|
|
846
|
+
expect(countContactUnread(db, assistantID)).toBe(2);
|
|
847
|
+
const migrated = migrateContactReadStateDefaultRead(db, { updatedAt: 1 });
|
|
848
|
+
expect(migrated.seeded).toBe(1);
|
|
849
|
+
expect(countContactUnread(db, assistantID)).toBe(0);
|
|
850
|
+
const watermark = getContactReadWatermark(db, assistantID);
|
|
851
|
+
expect(watermark).toMatchObject({
|
|
852
|
+
ordinal: a2.ordinal,
|
|
853
|
+
messageID: a2.messageID,
|
|
854
|
+
generation: 0,
|
|
855
|
+
});
|
|
856
|
+
// Re-seed is idempotent.
|
|
857
|
+
expect(migrateContactReadStateDefaultRead(db, { updatedAt: 2 }).seeded).toBe(0);
|
|
858
|
+
|
|
859
|
+
insert(db, assistantID, { role: 'user', text: 'new-u' });
|
|
860
|
+
const newer = insert(db, assistantID, { role: 'assistant', text: 'new-a' });
|
|
861
|
+
expect(countContactUnread(db, assistantID)).toBe(1);
|
|
862
|
+
expect(getContactReadTip(db, assistantID)).toMatchObject({
|
|
863
|
+
ordinal: newer.ordinal,
|
|
864
|
+
messageID: newer.messageID,
|
|
865
|
+
});
|
|
866
|
+
void a1;
|
|
867
|
+
db.close();
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it('advances monotonically, clamps to tip, and rejects stale generation', () => {
|
|
871
|
+
const db = openDb();
|
|
872
|
+
const assistantID = 'asst_mono';
|
|
873
|
+
const first = insert(db, assistantID, { role: 'assistant', text: 'one' });
|
|
874
|
+
const second = insert(db, assistantID, { role: 'assistant', text: 'two' });
|
|
875
|
+
const third = insert(db, assistantID, { role: 'assistant', text: 'three' });
|
|
876
|
+
|
|
877
|
+
const toFirst = advanceContactReadWatermark(db, assistantID, {
|
|
878
|
+
generation: 0,
|
|
879
|
+
ordinal: first.ordinal,
|
|
880
|
+
messageID: first.messageID,
|
|
881
|
+
}, { updatedAt: 10 });
|
|
882
|
+
expect(toFirst.changed).toBe(true);
|
|
883
|
+
expect(toFirst.unreadCount).toBe(2);
|
|
884
|
+
|
|
885
|
+
// Equal → no-op.
|
|
886
|
+
const same = advanceContactReadWatermark(db, assistantID, {
|
|
887
|
+
generation: 0,
|
|
888
|
+
ordinal: first.ordinal,
|
|
889
|
+
messageID: first.messageID,
|
|
890
|
+
}, { updatedAt: 11 });
|
|
891
|
+
expect(same.changed).toBe(false);
|
|
892
|
+
expect(same.unreadCount).toBe(2);
|
|
893
|
+
|
|
894
|
+
// Late lower cursor cannot erase newer unread.
|
|
895
|
+
const staleLower = advanceContactReadWatermark(db, assistantID, {
|
|
896
|
+
generation: 0,
|
|
897
|
+
ordinal: 0,
|
|
898
|
+
messageID: '',
|
|
899
|
+
}, { updatedAt: 12 });
|
|
900
|
+
expect(staleLower.changed).toBe(false);
|
|
901
|
+
expect(getContactReadWatermark(db, assistantID).ordinal).toBe(first.ordinal);
|
|
902
|
+
|
|
903
|
+
// Forward to second.
|
|
904
|
+
const toSecond = advanceContactReadWatermark(db, assistantID, {
|
|
905
|
+
generation: 0,
|
|
906
|
+
ordinal: second.ordinal,
|
|
907
|
+
messageID: second.messageID,
|
|
908
|
+
}, { updatedAt: 13 });
|
|
909
|
+
expect(toSecond.changed).toBe(true);
|
|
910
|
+
expect(toSecond.unreadCount).toBe(1);
|
|
911
|
+
|
|
912
|
+
// Future cursor clamps to tip (third).
|
|
913
|
+
const overshoot = advanceContactReadWatermark(db, assistantID, {
|
|
914
|
+
generation: 0,
|
|
915
|
+
ordinal: third.ordinal + 99,
|
|
916
|
+
messageID: 'zzz_future',
|
|
917
|
+
}, { updatedAt: 14 });
|
|
918
|
+
expect(overshoot.changed).toBe(true);
|
|
919
|
+
expect(overshoot.readWatermark).toMatchObject({
|
|
920
|
+
ordinal: third.ordinal,
|
|
921
|
+
messageID: third.messageID,
|
|
922
|
+
});
|
|
923
|
+
expect(overshoot.unreadCount).toBe(0);
|
|
924
|
+
|
|
925
|
+
const generation = bumpContactGeneration(db, assistantID);
|
|
926
|
+
expect(generation).toBe(1);
|
|
927
|
+
resetContactReadWatermarkForGeneration(db, assistantID, generation, { updatedAt: 15 });
|
|
928
|
+
expect(() => advanceContactReadWatermark(db, assistantID, {
|
|
929
|
+
generation: 0,
|
|
930
|
+
ordinal: third.ordinal,
|
|
931
|
+
messageID: third.messageID,
|
|
932
|
+
})).toThrow(expect.objectContaining({ code: 'contact_generation_conflict' }));
|
|
933
|
+
|
|
934
|
+
// After generation reset, previous tip rows that remain are unread again.
|
|
935
|
+
expect(countContactUnread(db, assistantID)).toBe(3);
|
|
936
|
+
expect(compareContactReadCursor(
|
|
937
|
+
{ ordinal: 1, messageID: 'a' },
|
|
938
|
+
{ ordinal: 1, messageID: 'b' },
|
|
939
|
+
)).toBeLessThan(0);
|
|
940
|
+
db.close();
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
it('batches unread snapshots without per-assistant N+1 shape', () => {
|
|
944
|
+
const db = openDb();
|
|
945
|
+
const a = 'asst_batch_a';
|
|
946
|
+
const b = 'asst_batch_b';
|
|
947
|
+
insert(db, a, { role: 'assistant', text: 'a1' });
|
|
948
|
+
insert(db, a, { role: 'assistant', text: 'a2' });
|
|
949
|
+
insert(db, b, { role: 'peer', text: 'b1', fromAssistantID: 'x', fromAssistantName: 'X' });
|
|
950
|
+
const batch = getContactUnreadSnapshots(db, [a, b, 'asst_empty']);
|
|
951
|
+
expect(batch.get(a)?.unreadCount).toBe(2);
|
|
952
|
+
expect(batch.get(b)?.unreadCount).toBe(1);
|
|
953
|
+
expect(batch.get('asst_empty')?.unreadCount).toBe(0);
|
|
954
|
+
expect(batch.get(a)?.readTip?.ordinal).toBeGreaterThan(0);
|
|
955
|
+
expect(batch.get(a)?.readWatermark?.ordinal).toBe(0);
|
|
956
|
+
db.close();
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
it('pushes watermark keyset into SQL so fully-read history does not scan parts (scale + plan)', () => {
|
|
960
|
+
const db = openDb();
|
|
961
|
+
const assistantID = 'asst_unread_scale';
|
|
962
|
+
const otherID = 'asst_unread_scale_b';
|
|
963
|
+
for (let i = 0; i < 240; i += 1) {
|
|
964
|
+
insert(db, assistantID, { role: 'assistant', text: `hist-${i}` });
|
|
965
|
+
}
|
|
966
|
+
insert(db, otherID, { role: 'assistant', text: 'other-only' });
|
|
967
|
+
const tip = getContactReadTip(db, assistantID);
|
|
968
|
+
advanceContactReadWatermark(db, assistantID, {
|
|
969
|
+
generation: tip.generation,
|
|
970
|
+
ordinal: tip.ordinal,
|
|
971
|
+
messageID: tip.messageID,
|
|
972
|
+
}, { updatedAt: 1 });
|
|
973
|
+
// Fully read: single + batch both 0; other assistant still 1.
|
|
974
|
+
expect(countContactUnread(db, assistantID)).toBe(0);
|
|
975
|
+
const fullyRead = getContactUnreadSnapshots(db, [assistantID, otherID]);
|
|
976
|
+
expect(fullyRead.get(assistantID)?.unreadCount).toBe(0);
|
|
977
|
+
expect(fullyRead.get(otherID)?.unreadCount).toBe(1);
|
|
978
|
+
|
|
979
|
+
insert(db, assistantID, { role: 'assistant', text: CONTACT_SETTLE_TEXT.complete });
|
|
980
|
+
insert(db, assistantID, { role: 'assistant', text: '' });
|
|
981
|
+
insert(db, assistantID, { role: 'assistant', text: 'new-visible' });
|
|
982
|
+
expect(countContactUnread(db, assistantID)).toBe(1);
|
|
983
|
+
expect(getContactUnreadSnapshots(db, [assistantID]).get(assistantID)?.unreadCount).toBe(1);
|
|
984
|
+
|
|
985
|
+
// Keyset-bounded probe must use the page index path (not a full-table sort of history).
|
|
986
|
+
const plan = db.prepare(
|
|
987
|
+
`EXPLAIN QUERY PLAN
|
|
988
|
+
SELECT COUNT(*) AS count
|
|
989
|
+
FROM assistant_contact_message m
|
|
990
|
+
WHERE m.assistant_id = ?
|
|
991
|
+
AND m.role IN ('assistant', 'peer')
|
|
992
|
+
AND m.status IN ('complete', 'error')
|
|
993
|
+
AND (m.ordinal > ? OR (m.ordinal = ? AND m.message_id > ?))`,
|
|
994
|
+
).all(assistantID, tip.ordinal, tip.ordinal, tip.messageID);
|
|
995
|
+
const planText = plan.map((row) => `${row.detail || ''}`).join('\n').toLowerCase();
|
|
996
|
+
expect(planText).toMatch(/assistant_contact_message/);
|
|
997
|
+
expect(
|
|
998
|
+
planText.includes('assistant_id')
|
|
999
|
+
|| planText.includes('assistant_contact_message_page')
|
|
1000
|
+
|| planText.includes('using index')
|
|
1001
|
+
|| planText.includes('search'),
|
|
1002
|
+
).toBe(true);
|
|
1003
|
+
// Batch VALUES+join form also stays index-friendly on assistant_id.
|
|
1004
|
+
const batchPlan = db.prepare(
|
|
1005
|
+
`EXPLAIN QUERY PLAN
|
|
1006
|
+
WITH marks(assistant_id, mark_ordinal, mark_message_id) AS (VALUES (?, ?, ?))
|
|
1007
|
+
SELECT m.assistant_id, COUNT(*) AS count
|
|
1008
|
+
FROM assistant_contact_message m
|
|
1009
|
+
INNER JOIN marks ON marks.assistant_id = m.assistant_id
|
|
1010
|
+
WHERE m.role IN ('assistant', 'peer')
|
|
1011
|
+
AND m.status IN ('complete', 'error')
|
|
1012
|
+
AND (m.ordinal > marks.mark_ordinal
|
|
1013
|
+
OR (m.ordinal = marks.mark_ordinal AND m.message_id > marks.mark_message_id))
|
|
1014
|
+
GROUP BY m.assistant_id`,
|
|
1015
|
+
).all(assistantID, tip.ordinal, tip.messageID);
|
|
1016
|
+
const batchPlanText = batchPlan.map((row) => `${row.detail || ''}`).join('\n').toLowerCase();
|
|
1017
|
+
expect(batchPlanText).toMatch(/assistant_contact_message/);
|
|
1018
|
+
db.close();
|
|
1019
|
+
});
|
|
1020
|
+
});
|