kimaki 0.26.0 → 0.28.0
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/agent-model.e2e.test.js +250 -164
- package/dist/anthropic-account-identity.js +9 -1
- package/dist/anthropic-auth-plugin.js +10 -13
- package/dist/anthropic-auth-plugin.test.js +17 -0
- package/dist/anthropic-auth-state.js +1 -0
- package/dist/bash-tool-schema-plugin.js +32 -0
- package/dist/bash-tool-schema-plugin.test.js +49 -0
- package/dist/bin.js +73 -19
- package/dist/cli-commands/maintenance.js +46 -7
- package/dist/cli-commands/project.js +11 -27
- package/dist/cli-commands/send.js +30 -1
- package/dist/cli-commands/session.js +322 -90
- package/dist/cli-commands/task.js +34 -15
- package/dist/cli-parsing.test.js +83 -4
- package/dist/cli-runner.js +44 -8
- package/dist/cli-runner.test.js +27 -0
- package/dist/cli-send-thread.e2e.test.js +9 -9
- package/dist/cli.js +32 -0
- package/dist/commands/abort.js +5 -1
- package/dist/commands/action-buttons.js +2 -1
- package/dist/commands/add-project.js +4 -7
- package/dist/commands/agent.js +164 -32
- package/dist/commands/ask-question.js +23 -33
- package/dist/commands/ask-question.test.js +43 -1
- package/dist/commands/btw.js +28 -6
- package/dist/commands/file-upload.js +2 -1
- package/dist/commands/fork-subagent.js +2 -4
- package/dist/commands/fork.js +4 -4
- package/dist/commands/last-sessions.js +17 -10
- package/dist/commands/login.js +6 -0
- package/dist/commands/merge-worktree.js +47 -14
- package/dist/commands/model-variant.js +8 -1
- package/dist/commands/model.js +16 -9
- package/dist/commands/multioauth.js +1 -0
- package/dist/commands/new-worktree.js +170 -102
- package/dist/commands/queue.js +3 -2
- package/dist/commands/resume.js +4 -4
- package/dist/commands/session.js +78 -63
- package/dist/commands/user-command.js +6 -8
- package/dist/commands/worktrees.js +5 -13
- package/dist/config.js +16 -1
- package/dist/cpu-profiler.js +211 -0
- package/dist/cpu-profiler.test.js +160 -0
- package/dist/database.js +152 -3
- package/dist/db.js +33 -0
- package/dist/db.test.js +267 -1
- package/dist/default-channel-provisioning.test.js +148 -0
- package/dist/discord-bot.js +517 -488
- package/dist/discord-command-registration.js +25 -10
- package/dist/discord-command-registration.test.js +17 -0
- package/dist/discord-utils.js +97 -5
- package/dist/discord-utils.test.js +151 -1
- package/dist/errors.js +10 -0
- package/dist/exec-async.js +17 -5
- package/dist/external-directory-permissions.e2e.test.js +11 -7
- package/dist/external-directory-project-deny.e2e.test.js +5 -3
- package/dist/external-opencode-sync.js +94 -17
- package/dist/external-opencode-sync.test.js +206 -0
- package/dist/extract-sdk-error-message.test.js +13 -0
- package/dist/file-edit-log.js +197 -0
- package/dist/file-edit-log.test.js +258 -0
- package/dist/format-tables.js +52 -0
- package/dist/format-tables.test.js +93 -0
- package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
- package/dist/gateway-proxy.e2e.test.js +18 -18
- package/dist/git-worktree-core.js +182 -17
- package/dist/interaction-handler.js +416 -398
- package/dist/ipc-tools-plugin.js +68 -3
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
- package/dist/kimaki-opencode-plugin.js +9 -3
- package/dist/kimaki-workspace-adaptor.js +33 -10
- package/dist/logger.js +11 -1
- package/dist/markdown.js +3 -1
- package/dist/markdown.test.js +11 -3
- package/dist/message-formatting.js +181 -51
- package/dist/message-formatting.test.js +529 -22
- package/dist/message-preprocessing.js +105 -1
- package/dist/oauth-rotation-shared.js +10 -0
- package/dist/openai-auth-plugin.js +1 -0
- package/dist/openai-auth-state.js +1 -0
- package/dist/opencode-command.js +28 -0
- package/dist/opencode-command.test.js +109 -1
- package/dist/opencode-interrupt-plugin.js +4 -0
- package/dist/opencode-interrupt-plugin.test.js +24 -0
- package/dist/opencode.js +82 -15
- package/dist/project-add.test.js +62 -0
- package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
- package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
- package/dist/question-text-before-dropdown.e2e.test.js +52 -0
- package/dist/queue-advanced-abort.e2e.test.js +8 -10
- package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
- package/dist/queue-advanced-e2e-setup.js +78 -5
- package/dist/queue-advanced-footer.e2e.test.js +60 -269
- package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
- package/dist/queue-advanced-permissions-typing.e2e.test.js +15 -15
- package/dist/queue-advanced-question.e2e.test.js +32 -27
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
- package/dist/queue-advanced-typing.e2e.test.js +7 -9
- package/dist/queue-delete-message.e2e.test.js +3 -3
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
- package/dist/queue-interrupt-drain.e2e.test.js +122 -12
- package/dist/queue-question-select-drain.e2e.test.js +34 -132
- package/dist/runtime-lifecycle.e2e.test.js +21 -51
- package/dist/schema.js +35 -1
- package/dist/session-handler/event-stream-state.js +391 -50
- package/dist/session-handler/event-stream-state.test.js +736 -8
- package/dist/session-handler/model-utils.js +172 -5
- package/dist/session-handler/model-utils.test.js +445 -0
- package/dist/session-handler/opencode-session-event-log.js +30 -1
- package/dist/session-handler/thread-ingress.test.js +41 -0
- package/dist/session-handler/thread-runtime-state.js +2 -0
- package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
- package/dist/session-handler/thread-session-runtime.js +574 -299
- package/dist/session-search.js +123 -4
- package/dist/session-search.test.js +248 -1
- package/dist/session-sleep.test.js +224 -0
- package/dist/session-title-rename.test.js +37 -39
- package/dist/sleep.e2e.test.js +494 -0
- package/dist/startup-time.e2e.test.js +2 -1
- package/dist/store.js +3 -0
- package/dist/system-message.js +102 -16
- package/dist/system-message.test.js +192 -15
- package/dist/task-runner.js +96 -5
- package/dist/task-schedule.js +87 -1
- package/dist/task-schedule.test.js +149 -1
- package/dist/test-utils.js +17 -9
- package/dist/thinking-utils.js +10 -0
- package/dist/thinking-utils.test.js +35 -0
- package/dist/thread-message-queue.e2e.test.js +65 -213
- package/dist/undo-redo.e2e.test.js +17 -9
- package/dist/voice-attachment.js +23 -1
- package/dist/voice-handler.js +21 -4
- package/dist/voice-message.e2e.test.js +144 -30
- package/dist/voice-routing-input.e2e.test.js +195 -0
- package/dist/voice-routing-question.e2e.test.js +125 -0
- package/dist/voice.js +28 -8
- package/dist/voice.test.js +96 -30
- package/dist/worktree-clone-isolation.e2e.test.js +188 -0
- package/dist/worktree-lifecycle.e2e.test.js +55 -36
- package/dist/worktrees.js +226 -443
- package/dist/worktrees.test.js +435 -58
- package/dist/xai-auth-plugin.js +1 -0
- package/dist/xai-auth-state.js +1 -0
- package/package.json +8 -6
- package/skills/bump-holocron-consumers/SKILL.md +137 -0
- package/skills/critique/SKILL.md +4 -4
- package/skills/errore/SKILL.md +60 -218
- package/skills/goke/SKILL.md +150 -10
- package/skills/holocron/SKILL.md +154 -17
- package/skills/npm-package/SKILL.md +55 -35
- package/skills/profano/SKILL.md +17 -2
- package/skills/spiceflow/SKILL.md +39 -3
- package/skills/strada/SKILL.md +39 -0
- package/skills/zele/SKILL.md +19 -0
- package/src/agent-model.e2e.test.ts +287 -186
- package/src/anthropic-account-identity.ts +20 -1
- package/src/anthropic-auth-plugin.test.ts +19 -0
- package/src/anthropic-auth-plugin.ts +12 -16
- package/src/anthropic-auth-state.ts +1 -0
- package/src/bash-tool-schema-plugin.test.ts +55 -0
- package/src/bash-tool-schema-plugin.ts +46 -0
- package/src/bin.ts +78 -26
- package/src/cli-commands/maintenance.ts +59 -13
- package/src/cli-commands/project.ts +14 -35
- package/src/cli-commands/send.ts +31 -1
- package/src/cli-commands/session.ts +374 -114
- package/src/cli-commands/task.ts +34 -15
- package/src/cli-parsing.test.ts +103 -4
- package/src/cli-runner.test.ts +49 -0
- package/src/cli-runner.ts +44 -7
- package/src/cli-send-thread.e2e.test.ts +9 -11
- package/src/cli.ts +53 -0
- package/src/commands/abort.ts +6 -1
- package/src/commands/action-buttons.ts +2 -1
- package/src/commands/add-project.ts +4 -8
- package/src/commands/agent.ts +205 -29
- package/src/commands/ask-question.test.ts +48 -1
- package/src/commands/ask-question.ts +23 -33
- package/src/commands/btw.ts +40 -5
- package/src/commands/file-upload.ts +2 -1
- package/src/commands/fork-subagent.ts +2 -3
- package/src/commands/fork.ts +4 -3
- package/src/commands/last-sessions.ts +10 -4
- package/src/commands/login.ts +6 -0
- package/src/commands/merge-worktree.ts +54 -16
- package/src/commands/model-variant.ts +9 -1
- package/src/commands/model.ts +20 -9
- package/src/commands/multioauth.ts +1 -0
- package/src/commands/new-worktree.ts +138 -55
- package/src/commands/queue.ts +3 -2
- package/src/commands/resume.ts +4 -3
- package/src/commands/session.ts +94 -70
- package/src/commands/user-command.ts +6 -6
- package/src/commands/worktrees.ts +5 -12
- package/src/config.ts +18 -1
- package/src/cpu-profiler.test.ts +183 -0
- package/src/cpu-profiler.ts +293 -0
- package/src/database.ts +235 -3
- package/src/db.test.ts +293 -0
- package/src/db.ts +38 -0
- package/src/default-channel-provisioning.test.ts +155 -0
- package/src/discord-bot.ts +78 -36
- package/src/discord-command-registration.test.ts +20 -0
- package/src/discord-command-registration.ts +42 -12
- package/src/discord-utils.test.ts +176 -0
- package/src/discord-utils.ts +152 -7
- package/src/errors.ts +12 -0
- package/src/exec-async.ts +20 -11
- package/src/external-directory-permissions.e2e.test.ts +11 -7
- package/src/external-directory-project-deny.e2e.test.ts +5 -3
- package/src/external-opencode-sync.test.ts +248 -0
- package/src/external-opencode-sync.ts +122 -19
- package/src/extract-sdk-error-message.test.ts +18 -0
- package/src/file-edit-log.test.ts +287 -0
- package/src/file-edit-log.ts +283 -0
- package/src/format-tables.test.ts +101 -0
- package/src/format-tables.ts +75 -0
- package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
- package/src/gateway-proxy.e2e.test.ts +18 -17
- package/src/git-worktree-core.ts +247 -20
- package/src/interaction-handler.ts +36 -13
- package/src/ipc-tools-plugin.ts +82 -5
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
- package/src/kimaki-opencode-plugin.ts +9 -3
- package/src/kimaki-workspace-adaptor.ts +40 -11
- package/src/logger.ts +14 -4
- package/src/markdown.test.ts +13 -3
- package/src/markdown.ts +4 -1
- package/src/message-formatting.test.ts +672 -25
- package/src/message-formatting.ts +242 -53
- package/src/message-preprocessing.ts +116 -1
- package/src/oauth-rotation-shared.ts +10 -0
- package/src/openai-auth-plugin.ts +1 -0
- package/src/openai-auth-state.ts +1 -0
- package/src/opencode-command.test.ts +140 -0
- package/src/opencode-command.ts +38 -0
- package/src/opencode-interrupt-plugin.test.ts +32 -0
- package/src/opencode-interrupt-plugin.ts +3 -0
- package/src/opencode.ts +115 -15
- package/src/project-add.test.ts +80 -0
- package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
- package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
- package/src/question-text-before-dropdown.e2e.test.ts +67 -0
- package/src/queue-advanced-abort.e2e.test.ts +8 -9
- package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
- package/src/queue-advanced-e2e-setup.ts +83 -4
- package/src/queue-advanced-footer.e2e.test.ts +67 -315
- package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
- package/src/queue-advanced-permissions-typing.e2e.test.ts +15 -15
- package/src/queue-advanced-question.e2e.test.ts +36 -30
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
- package/src/queue-advanced-typing.e2e.test.ts +8 -8
- package/src/queue-delete-message.e2e.test.ts +3 -3
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
- package/src/queue-interrupt-drain.e2e.test.ts +133 -12
- package/src/queue-question-select-drain.e2e.test.ts +36 -161
- package/src/runtime-lifecycle.e2e.test.ts +21 -54
- package/src/schema.sql +15 -2
- package/src/schema.ts +37 -1
- package/src/session-handler/event-stream-state.test.ts +790 -7
- package/src/session-handler/event-stream-state.ts +568 -53
- package/src/session-handler/model-utils.test.ts +537 -0
- package/src/session-handler/model-utils.ts +281 -5
- package/src/session-handler/opencode-session-event-log.ts +31 -1
- package/src/session-handler/thread-ingress.test.ts +54 -0
- package/src/session-handler/thread-runtime-state.ts +3 -0
- package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
- package/src/session-handler/thread-session-runtime.ts +712 -358
- package/src/session-search.test.ts +281 -0
- package/src/session-search.ts +241 -7
- package/src/session-sleep.test.ts +294 -0
- package/src/session-title-rename.test.ts +54 -48
- package/src/sleep.e2e.test.ts +565 -0
- package/src/startup-time.e2e.test.ts +5 -1
- package/src/store.ts +22 -0
- package/src/system-message.test.ts +226 -15
- package/src/system-message.ts +130 -16
- package/src/task-runner.ts +138 -3
- package/src/task-schedule.test.ts +170 -0
- package/src/task-schedule.ts +141 -1
- package/src/test-utils.ts +21 -10
- package/src/thinking-utils.test.ts +46 -0
- package/src/thinking-utils.ts +21 -0
- package/src/thread-message-queue.e2e.test.ts +65 -245
- package/src/undo-redo.e2e.test.ts +17 -9
- package/src/voice-attachment.ts +29 -1
- package/src/voice-handler.ts +23 -4
- package/src/voice-message.e2e.test.ts +149 -29
- package/src/voice-routing-input.e2e.test.ts +203 -0
- package/src/voice-routing-question.e2e.test.ts +131 -0
- package/src/voice.test.ts +107 -29
- package/src/voice.ts +34 -6
- package/src/worktree-clone-isolation.e2e.test.ts +213 -0
- package/src/worktree-lifecycle.e2e.test.ts +55 -35
- package/src/worktrees.test.ts +505 -64
- package/src/worktrees.ts +257 -603
- package/src/xai-auth-plugin.ts +1 -0
- package/src/xai-auth-state.ts +1 -0
- package/dist/message-finish-field.e2e.test.js +0 -169
- package/dist/subagent-rate-limit-plugin.js +0 -184
- package/src/message-finish-field.e2e.test.ts +0 -196
- package/src/subagent-rate-limit-plugin.ts +0 -228
package/dist/session-search.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// Session search helpers for kimaki CLI commands.
|
|
2
2
|
// Parses string/regex queries and builds readable snippets from matched content.
|
|
3
|
+
// Default window so search does not load years of old session messages.
|
|
4
|
+
export const SESSION_SEARCH_DEFAULT_DAYS = 14;
|
|
5
|
+
export const SESSION_SEARCH_MESSAGE_CONCURRENCY = 4;
|
|
3
6
|
export function parseSessionSearchPattern(query) {
|
|
4
7
|
const trimmedQuery = query.trim();
|
|
5
8
|
if (!trimmedQuery) {
|
|
@@ -80,13 +83,13 @@ export function getPartSearchTexts(part) {
|
|
|
80
83
|
case 'reasoning':
|
|
81
84
|
return part.text ? [part.text] : [];
|
|
82
85
|
case 'tool': {
|
|
83
|
-
const inputText = stringifyUnknown(part.state
|
|
84
|
-
const outputText = part.state
|
|
86
|
+
const inputText = stringifyUnknown(part.state?.input);
|
|
87
|
+
const outputText = part.state?.status === 'completed'
|
|
85
88
|
? stringifyUnknown(part.state.output)
|
|
86
|
-
: part.state
|
|
89
|
+
: part.state?.status === 'error'
|
|
87
90
|
? part.state.error || ''
|
|
88
91
|
: '';
|
|
89
|
-
return [`tool:${part.tool}`, inputText, outputText].filter((entry) => {
|
|
92
|
+
return [`tool:${part.tool || ''}`, inputText, outputText].filter((entry) => {
|
|
90
93
|
return entry.trim().length > 0;
|
|
91
94
|
});
|
|
92
95
|
}
|
|
@@ -98,3 +101,119 @@ export function getPartSearchTexts(part) {
|
|
|
98
101
|
return [];
|
|
99
102
|
}
|
|
100
103
|
}
|
|
104
|
+
export function validateSessionSearchScope({ all, project, channel, }) {
|
|
105
|
+
if (all && (project || channel)) {
|
|
106
|
+
return new Error('Use --all alone. Do not combine it with --project or --channel. Search one project with: kimaki session search "query" --project /path');
|
|
107
|
+
}
|
|
108
|
+
if (project && channel) {
|
|
109
|
+
return new Error('Use either --project or --channel, not both');
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
export function parseSessionSearchDays(raw) {
|
|
114
|
+
if (raw === undefined) {
|
|
115
|
+
return SESSION_SEARCH_DEFAULT_DAYS;
|
|
116
|
+
}
|
|
117
|
+
const parsed = Number.parseInt(raw, 10);
|
|
118
|
+
if (Number.isNaN(parsed) || parsed < 0 || String(parsed) !== raw.trim()) {
|
|
119
|
+
return new Error(`Invalid --days value: ${raw}. Use a whole number of days, or 0 for all time.`);
|
|
120
|
+
}
|
|
121
|
+
return parsed;
|
|
122
|
+
}
|
|
123
|
+
export function sessionSearchMinUpdated({ days, now = Date.now(), }) {
|
|
124
|
+
if (days === 0) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
return now - days * 24 * 60 * 60 * 1000;
|
|
128
|
+
}
|
|
129
|
+
export function resolveSessionSearchDirectories({ all, registeredDirectories, cwd, explicitDirectory, }) {
|
|
130
|
+
if (!all) {
|
|
131
|
+
return [explicitDirectory || cwd];
|
|
132
|
+
}
|
|
133
|
+
const uniqueDirectories = [...new Set(registeredDirectories)];
|
|
134
|
+
if (uniqueDirectories.length === 0) {
|
|
135
|
+
return new Error('No registered projects found. Add a project first with `kimaki project add`, or search one directory with --project.');
|
|
136
|
+
}
|
|
137
|
+
return uniqueDirectories;
|
|
138
|
+
}
|
|
139
|
+
export function getSessionSearchSnippets({ messages, searchPattern, }) {
|
|
140
|
+
return messages
|
|
141
|
+
.flatMap((message) => {
|
|
142
|
+
const rolePrefix = message.info.role === 'assistant'
|
|
143
|
+
? 'assistant'
|
|
144
|
+
: message.info.role === 'user'
|
|
145
|
+
? 'user'
|
|
146
|
+
: 'message';
|
|
147
|
+
return message.parts
|
|
148
|
+
.filter((part) => !(part.type === 'text' && part.synthetic))
|
|
149
|
+
.flatMap((part) => {
|
|
150
|
+
return getPartSearchTexts(part).flatMap((text) => {
|
|
151
|
+
const hit = findFirstSessionSearchHit({
|
|
152
|
+
text,
|
|
153
|
+
searchPattern,
|
|
154
|
+
});
|
|
155
|
+
if (!hit) {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
const snippet = buildSessionSearchSnippet({ text, hit });
|
|
159
|
+
if (!snippet) {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
return [`${rolePrefix}: ${snippet}`];
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
})
|
|
166
|
+
.slice(0, 3);
|
|
167
|
+
}
|
|
168
|
+
export async function collectSessionSearchMatches({ sessions, searchPattern, sessionToThread, limit, minUpdated, concurrency = SESSION_SEARCH_MESSAGE_CONCURRENCY, loadMessages, onMatch, }) {
|
|
169
|
+
const sortedSessions = [...sessions]
|
|
170
|
+
.sort((a, b) => {
|
|
171
|
+
return b.updated - a.updated;
|
|
172
|
+
})
|
|
173
|
+
.filter((session) => {
|
|
174
|
+
return minUpdated === undefined || session.updated >= minUpdated;
|
|
175
|
+
});
|
|
176
|
+
const matches = [];
|
|
177
|
+
let scannedSessions = 0;
|
|
178
|
+
const batchSize = Math.max(1, concurrency);
|
|
179
|
+
for (let offset = 0; offset < sortedSessions.length; offset += batchSize) {
|
|
180
|
+
if (matches.length >= limit) {
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
const batch = sortedSessions.slice(offset, offset + batchSize);
|
|
184
|
+
scannedSessions += batch.length;
|
|
185
|
+
const loadedBatch = await Promise.all(batch.map(async (session) => {
|
|
186
|
+
const messages = session.messages
|
|
187
|
+
? session.messages
|
|
188
|
+
: loadMessages
|
|
189
|
+
? await loadMessages(session)
|
|
190
|
+
: [];
|
|
191
|
+
return { session, messages };
|
|
192
|
+
}));
|
|
193
|
+
for (const { session, messages } of loadedBatch) {
|
|
194
|
+
if (matches.length >= limit) {
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
const snippets = getSessionSearchSnippets({
|
|
198
|
+
messages,
|
|
199
|
+
searchPattern,
|
|
200
|
+
});
|
|
201
|
+
if (snippets.length === 0) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const threadId = sessionToThread.get(session.id);
|
|
205
|
+
const match = {
|
|
206
|
+
id: session.id,
|
|
207
|
+
title: session.title || 'Untitled Session',
|
|
208
|
+
directory: session.directory,
|
|
209
|
+
updated: new Date(session.updated).toISOString(),
|
|
210
|
+
source: threadId ? 'kimaki' : 'opencode',
|
|
211
|
+
threadId: threadId || null,
|
|
212
|
+
snippets,
|
|
213
|
+
};
|
|
214
|
+
matches.push(match);
|
|
215
|
+
onMatch?.(match);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return { matches, scannedSessions };
|
|
219
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Tests for session search query parsing and snippet matching helpers.
|
|
2
2
|
import { describe, expect, test } from 'vitest';
|
|
3
|
-
import { buildSessionSearchSnippet, findFirstSessionSearchHit, parseSessionSearchPattern, } from './session-search.js';
|
|
3
|
+
import { buildSessionSearchSnippet, collectSessionSearchMatches, findFirstSessionSearchHit, parseSessionSearchDays, parseSessionSearchPattern, resolveSessionSearchDirectories, sessionSearchMinUpdated, validateSessionSearchScope, } from './session-search.js';
|
|
4
4
|
describe('session search helpers', () => {
|
|
5
5
|
test('returns error for invalid regex query', () => {
|
|
6
6
|
const parsed = parseSessionSearchPattern('/(unclosed/');
|
|
@@ -37,4 +37,251 @@ describe('session search helpers', () => {
|
|
|
37
37
|
expect(snippet.toUpperCase()).toContain(expectedSubstring.toUpperCase());
|
|
38
38
|
});
|
|
39
39
|
});
|
|
40
|
+
test('rejects --all combined with --project or --channel', () => {
|
|
41
|
+
expect(validateSessionSearchScope({ all: true, project: '/tmp/a' })).toBeInstanceOf(Error);
|
|
42
|
+
expect(validateSessionSearchScope({ all: true, channel: '123' })).toBeInstanceOf(Error);
|
|
43
|
+
expect(validateSessionSearchScope({ all: true })).toBeNull();
|
|
44
|
+
expect(validateSessionSearchScope({ project: '/tmp/a', channel: '123' })).toBeInstanceOf(Error);
|
|
45
|
+
});
|
|
46
|
+
test('resolves --all to every registered project directory', () => {
|
|
47
|
+
const resolved = resolveSessionSearchDirectories({
|
|
48
|
+
all: true,
|
|
49
|
+
registeredDirectories: ['/tmp/kimaki', '/tmp/website', '/tmp/kimaki'],
|
|
50
|
+
cwd: '/tmp/current',
|
|
51
|
+
});
|
|
52
|
+
expect(resolved).toEqual(['/tmp/kimaki', '/tmp/website']);
|
|
53
|
+
});
|
|
54
|
+
test('returns error when --all has no registered projects', () => {
|
|
55
|
+
const resolved = resolveSessionSearchDirectories({
|
|
56
|
+
all: true,
|
|
57
|
+
registeredDirectories: [],
|
|
58
|
+
cwd: '/tmp/current',
|
|
59
|
+
});
|
|
60
|
+
expect(resolved).toBeInstanceOf(Error);
|
|
61
|
+
});
|
|
62
|
+
test('defaults to cwd or an explicit project when --all is off', () => {
|
|
63
|
+
expect(resolveSessionSearchDirectories({
|
|
64
|
+
all: false,
|
|
65
|
+
cwd: '/tmp/current',
|
|
66
|
+
registeredDirectories: ['/tmp/kimaki'],
|
|
67
|
+
})).toEqual(['/tmp/current']);
|
|
68
|
+
expect(resolveSessionSearchDirectories({
|
|
69
|
+
all: false,
|
|
70
|
+
explicitDirectory: '/tmp/website',
|
|
71
|
+
cwd: '/tmp/current',
|
|
72
|
+
registeredDirectories: ['/tmp/kimaki'],
|
|
73
|
+
})).toEqual(['/tmp/website']);
|
|
74
|
+
});
|
|
75
|
+
test('defaults --days to 14 and treats 0 as all time', () => {
|
|
76
|
+
expect(parseSessionSearchDays(undefined)).toBe(14);
|
|
77
|
+
expect(parseSessionSearchDays('0')).toBe(0);
|
|
78
|
+
expect(parseSessionSearchDays('7')).toBe(7);
|
|
79
|
+
expect(parseSessionSearchDays('-1')).toBeInstanceOf(Error);
|
|
80
|
+
expect(parseSessionSearchDays('nope')).toBeInstanceOf(Error);
|
|
81
|
+
expect(sessionSearchMinUpdated({ days: 0, now: 1_000 })).toBeUndefined();
|
|
82
|
+
expect(sessionSearchMinUpdated({ days: 14, now: 1_000 })).toBe(1_000 - 14 * 24 * 60 * 60 * 1000);
|
|
83
|
+
});
|
|
84
|
+
test('skips sessions older than minUpdated without loading messages', async () => {
|
|
85
|
+
const parsed = parseSessionSearchPattern('auth timeout');
|
|
86
|
+
if (parsed instanceof Error) {
|
|
87
|
+
throw parsed;
|
|
88
|
+
}
|
|
89
|
+
const now = 1_000_000_000_000;
|
|
90
|
+
const minUpdated = now - 14 * 24 * 60 * 60 * 1000;
|
|
91
|
+
const loaded = [];
|
|
92
|
+
const result = await collectSessionSearchMatches({
|
|
93
|
+
sessions: [
|
|
94
|
+
{
|
|
95
|
+
id: 'ses_new',
|
|
96
|
+
title: 'new hit',
|
|
97
|
+
directory: '/tmp/kimaki',
|
|
98
|
+
updated: now,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: 'ses_old',
|
|
102
|
+
title: 'old hit',
|
|
103
|
+
directory: '/tmp/website',
|
|
104
|
+
updated: minUpdated - 1,
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
searchPattern: parsed,
|
|
108
|
+
sessionToThread: new Map(),
|
|
109
|
+
limit: 20,
|
|
110
|
+
minUpdated,
|
|
111
|
+
loadMessages: async (session) => {
|
|
112
|
+
loaded.push(session.id);
|
|
113
|
+
return [
|
|
114
|
+
{
|
|
115
|
+
info: { role: 'user' },
|
|
116
|
+
parts: [{ type: 'text', text: 'auth timeout', synthetic: false }],
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
expect(loaded).toEqual(['ses_new']);
|
|
122
|
+
expect(result.scannedSessions).toBe(1);
|
|
123
|
+
expect(result.matches.map((match) => match.id)).toEqual(['ses_new']);
|
|
124
|
+
});
|
|
125
|
+
test('loads old matching sessions when minUpdated is unset', async () => {
|
|
126
|
+
const parsed = parseSessionSearchPattern('auth timeout');
|
|
127
|
+
if (parsed instanceof Error) {
|
|
128
|
+
throw parsed;
|
|
129
|
+
}
|
|
130
|
+
const loaded = [];
|
|
131
|
+
const result = await collectSessionSearchMatches({
|
|
132
|
+
sessions: [
|
|
133
|
+
{
|
|
134
|
+
id: 'ses_old',
|
|
135
|
+
title: 'old hit',
|
|
136
|
+
directory: '/tmp/website',
|
|
137
|
+
updated: 1,
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
searchPattern: parsed,
|
|
141
|
+
sessionToThread: new Map(),
|
|
142
|
+
limit: 20,
|
|
143
|
+
loadMessages: async (session) => {
|
|
144
|
+
loaded.push(session.id);
|
|
145
|
+
return [
|
|
146
|
+
{
|
|
147
|
+
info: { role: 'user' },
|
|
148
|
+
parts: [{ type: 'text', text: 'auth timeout', synthetic: false }],
|
|
149
|
+
},
|
|
150
|
+
];
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
expect(loaded).toEqual(['ses_old']);
|
|
154
|
+
expect(result.matches.map((match) => match.id)).toEqual(['ses_old']);
|
|
155
|
+
});
|
|
156
|
+
test('loads messages in parallel but keeps newest matches first', async () => {
|
|
157
|
+
const parsed = parseSessionSearchPattern('auth timeout');
|
|
158
|
+
if (parsed instanceof Error) {
|
|
159
|
+
throw parsed;
|
|
160
|
+
}
|
|
161
|
+
let inFlight = 0;
|
|
162
|
+
let maxInFlight = 0;
|
|
163
|
+
const streamed = [];
|
|
164
|
+
const result = await collectSessionSearchMatches({
|
|
165
|
+
sessions: [
|
|
166
|
+
{
|
|
167
|
+
id: 'ses_new',
|
|
168
|
+
title: 'new hit',
|
|
169
|
+
directory: '/tmp/kimaki',
|
|
170
|
+
updated: 3,
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: 'ses_mid',
|
|
174
|
+
title: 'mid hit',
|
|
175
|
+
directory: '/tmp/cli',
|
|
176
|
+
updated: 2,
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
id: 'ses_old',
|
|
180
|
+
title: 'old hit',
|
|
181
|
+
directory: '/tmp/website',
|
|
182
|
+
updated: 1,
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
searchPattern: parsed,
|
|
186
|
+
sessionToThread: new Map(),
|
|
187
|
+
limit: 2,
|
|
188
|
+
concurrency: 3,
|
|
189
|
+
onMatch: (match) => {
|
|
190
|
+
streamed.push(match.id);
|
|
191
|
+
},
|
|
192
|
+
loadMessages: async (session) => {
|
|
193
|
+
inFlight++;
|
|
194
|
+
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
195
|
+
await new Promise((resolve) => {
|
|
196
|
+
setTimeout(resolve, session.id === 'ses_new' ? 40 : 5);
|
|
197
|
+
});
|
|
198
|
+
inFlight--;
|
|
199
|
+
return [
|
|
200
|
+
{
|
|
201
|
+
info: { role: 'user' },
|
|
202
|
+
parts: [{ type: 'text', text: 'auth timeout', synthetic: false }],
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
expect(maxInFlight).toBeGreaterThan(1);
|
|
208
|
+
expect(result.matches.map((match) => match.id)).toEqual([
|
|
209
|
+
'ses_new',
|
|
210
|
+
'ses_mid',
|
|
211
|
+
]);
|
|
212
|
+
expect(streamed).toEqual(['ses_new', 'ses_mid']);
|
|
213
|
+
});
|
|
214
|
+
test('collects newest matches across projects up to the global limit', async () => {
|
|
215
|
+
const parsed = parseSessionSearchPattern('auth timeout');
|
|
216
|
+
if (parsed instanceof Error) {
|
|
217
|
+
throw parsed;
|
|
218
|
+
}
|
|
219
|
+
const result = await collectSessionSearchMatches({
|
|
220
|
+
sessions: [
|
|
221
|
+
{
|
|
222
|
+
id: 'ses_old',
|
|
223
|
+
title: 'old website hit',
|
|
224
|
+
directory: '/tmp/website',
|
|
225
|
+
updated: 1,
|
|
226
|
+
messages: [
|
|
227
|
+
{
|
|
228
|
+
info: { role: 'user' },
|
|
229
|
+
parts: [{ type: 'text', text: 'auth timeout on login', synthetic: false }],
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
id: 'ses_new',
|
|
235
|
+
title: 'new kimaki hit',
|
|
236
|
+
directory: '/tmp/kimaki',
|
|
237
|
+
updated: 3,
|
|
238
|
+
messages: [
|
|
239
|
+
{
|
|
240
|
+
info: { role: 'assistant' },
|
|
241
|
+
parts: [{ type: 'text', text: 'fixed the auth timeout', synthetic: false }],
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
id: 'ses_miss',
|
|
247
|
+
title: 'unrelated',
|
|
248
|
+
directory: '/tmp/cli',
|
|
249
|
+
updated: 4,
|
|
250
|
+
messages: [
|
|
251
|
+
{
|
|
252
|
+
info: { role: 'user' },
|
|
253
|
+
parts: [{ type: 'text', text: 'refactor logger', synthetic: false }],
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
id: 'ses_mid',
|
|
259
|
+
title: 'mid cli hit',
|
|
260
|
+
directory: '/tmp/cli',
|
|
261
|
+
updated: 2,
|
|
262
|
+
messages: [
|
|
263
|
+
{
|
|
264
|
+
info: { role: 'user' },
|
|
265
|
+
parts: [{ type: 'text', text: 'auth timeout retry', synthetic: false }],
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
searchPattern: parsed,
|
|
271
|
+
sessionToThread: new Map([
|
|
272
|
+
['ses_new', 'thread_new'],
|
|
273
|
+
['ses_mid', 'thread_mid'],
|
|
274
|
+
]),
|
|
275
|
+
limit: 2,
|
|
276
|
+
concurrency: 1,
|
|
277
|
+
});
|
|
278
|
+
expect(result.scannedSessions).toBe(3);
|
|
279
|
+
expect(result.matches.map((match) => match.id)).toEqual([
|
|
280
|
+
'ses_new',
|
|
281
|
+
'ses_mid',
|
|
282
|
+
]);
|
|
283
|
+
expect(result.matches[0]?.directory).toBe('/tmp/kimaki');
|
|
284
|
+
expect(result.matches[0]?.source).toBe('kimaki');
|
|
285
|
+
expect(result.matches[1]?.directory).toBe('/tmp/cli');
|
|
286
|
+
});
|
|
40
287
|
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Tests for the durable session-sleep delivery protocol.
|
|
2
|
+
//
|
|
3
|
+
// The invariant under test: a wake is delivered at least once and turned into a
|
|
4
|
+
// session turn at most once. A row therefore stays `planned` until ingress
|
|
5
|
+
// consumes it, and every delivery write is guarded on delivery_id so a stale
|
|
6
|
+
// in-flight wake can never touch a newer sleep occurrence.
|
|
7
|
+
import { afterAll, describe, expect, test } from 'vitest';
|
|
8
|
+
import { cancelSessionSleepForThread, claimSessionSleepAttempt, consumeSessionSleepWake, getDueSessionSleeps, getSessionSleep, getThreadIdBySessionId, markSessionSleepFailed, setThreadSession, upsertSessionSleep, } from './database.js';
|
|
9
|
+
import { closeDb } from './db.js';
|
|
10
|
+
import { buildSessionSleepWakeBody } from './task-runner.js';
|
|
11
|
+
const RETRY_AFTER_MS = 30_000;
|
|
12
|
+
const NOW = new Date('2026-08-19T12:00:00Z');
|
|
13
|
+
const DUE = new Date('2026-08-19T11:00:00Z');
|
|
14
|
+
async function createSleep({ sessionId, threadId, wakeAt = DUE, reason = null, }) {
|
|
15
|
+
await setThreadSession(threadId, sessionId);
|
|
16
|
+
return upsertSessionSleep({ sessionId, wakeAt, reason });
|
|
17
|
+
}
|
|
18
|
+
describe('session sleep delivery', () => {
|
|
19
|
+
test('uses a Discord-compatible nonce without changing the delivery marker', () => {
|
|
20
|
+
const deliveryId = 'f0e3a009-dd6b-4c6a-a244-646693d9ae8d';
|
|
21
|
+
const body = buildSessionSleepWakeBody({
|
|
22
|
+
deliveryId,
|
|
23
|
+
wakeAt: DUE,
|
|
24
|
+
reason: 'Continue after deployment',
|
|
25
|
+
});
|
|
26
|
+
expect({
|
|
27
|
+
nonce: body.nonce,
|
|
28
|
+
nonceLength: body.nonce.length,
|
|
29
|
+
markerIncludesDeliveryId: body.embeds[0]?.footer.text.includes(deliveryId),
|
|
30
|
+
}).toMatchInlineSnapshot(`
|
|
31
|
+
{
|
|
32
|
+
"markerIncludesDeliveryId": true,
|
|
33
|
+
"nonce": "f0e3a009dd6b4c6aa24464669",
|
|
34
|
+
"nonceLength": 25,
|
|
35
|
+
}
|
|
36
|
+
`);
|
|
37
|
+
expect(body.nonce.length).toBeLessThanOrEqual(25);
|
|
38
|
+
});
|
|
39
|
+
test('claim reserves an attempt but stays planned so a crash retries', async () => {
|
|
40
|
+
const deliveryId = await createSleep({
|
|
41
|
+
sessionId: 'ses-crash',
|
|
42
|
+
threadId: 'thr-crash',
|
|
43
|
+
});
|
|
44
|
+
const claimed = await claimSessionSleepAttempt({
|
|
45
|
+
sessionId: 'ses-crash',
|
|
46
|
+
deliveryId,
|
|
47
|
+
now: NOW,
|
|
48
|
+
retryAfterMs: RETRY_AFTER_MS,
|
|
49
|
+
});
|
|
50
|
+
expect(claimed?.status).toBe('planned');
|
|
51
|
+
expect(claimed?.attempts).toBe(1);
|
|
52
|
+
// Simulates the process dying before the Discord post: once the retry
|
|
53
|
+
// window passes the row is due again rather than lost.
|
|
54
|
+
const laterDue = await getDueSessionSleeps({
|
|
55
|
+
now: new Date(NOW.getTime() + RETRY_AFTER_MS + 1),
|
|
56
|
+
retryAfterMs: RETRY_AFTER_MS,
|
|
57
|
+
limit: 10,
|
|
58
|
+
});
|
|
59
|
+
expect(laterDue.map((row) => row.session_id)).toContain('ses-crash');
|
|
60
|
+
});
|
|
61
|
+
test('a second claim inside the retry window is refused', async () => {
|
|
62
|
+
const deliveryId = await createSleep({
|
|
63
|
+
sessionId: 'ses-window',
|
|
64
|
+
threadId: 'thr-window',
|
|
65
|
+
});
|
|
66
|
+
const first = await claimSessionSleepAttempt({
|
|
67
|
+
sessionId: 'ses-window',
|
|
68
|
+
deliveryId,
|
|
69
|
+
now: NOW,
|
|
70
|
+
retryAfterMs: RETRY_AFTER_MS,
|
|
71
|
+
});
|
|
72
|
+
const second = await claimSessionSleepAttempt({
|
|
73
|
+
sessionId: 'ses-window',
|
|
74
|
+
deliveryId,
|
|
75
|
+
now: new Date(NOW.getTime() + 1_000),
|
|
76
|
+
retryAfterMs: RETRY_AFTER_MS,
|
|
77
|
+
});
|
|
78
|
+
expect(first).not.toBe(null);
|
|
79
|
+
expect(second).toBe(null);
|
|
80
|
+
});
|
|
81
|
+
test('a new sleep occurrence invalidates an in-flight delivery', async () => {
|
|
82
|
+
const staleDeliveryId = await createSleep({
|
|
83
|
+
sessionId: 'ses-generation',
|
|
84
|
+
threadId: 'thr-generation',
|
|
85
|
+
});
|
|
86
|
+
const freshDeliveryId = await createSleep({
|
|
87
|
+
sessionId: 'ses-generation',
|
|
88
|
+
threadId: 'thr-generation',
|
|
89
|
+
wakeAt: new Date('2026-08-19T11:30:00Z'),
|
|
90
|
+
});
|
|
91
|
+
expect(freshDeliveryId).not.toBe(staleDeliveryId);
|
|
92
|
+
const staleClaim = await claimSessionSleepAttempt({
|
|
93
|
+
sessionId: 'ses-generation',
|
|
94
|
+
deliveryId: staleDeliveryId,
|
|
95
|
+
now: NOW,
|
|
96
|
+
retryAfterMs: RETRY_AFTER_MS,
|
|
97
|
+
});
|
|
98
|
+
// The wake message already in flight for the old occurrence must not be
|
|
99
|
+
// able to deliver against the new one.
|
|
100
|
+
const staleConsume = await consumeSessionSleepWake({
|
|
101
|
+
deliveryId: staleDeliveryId,
|
|
102
|
+
});
|
|
103
|
+
expect(staleClaim).toBe(null);
|
|
104
|
+
expect(staleConsume).toBe(false);
|
|
105
|
+
expect((await getSessionSleep({ sessionId: 'ses-generation' }))?.status).toBe('planned');
|
|
106
|
+
});
|
|
107
|
+
test('a wake becomes a turn at most once', async () => {
|
|
108
|
+
const deliveryId = await createSleep({
|
|
109
|
+
sessionId: 'ses-once',
|
|
110
|
+
threadId: 'thr-once',
|
|
111
|
+
});
|
|
112
|
+
// A retry can post the same wake twice; only the first delivery may win.
|
|
113
|
+
const first = await consumeSessionSleepWake({ deliveryId });
|
|
114
|
+
const second = await consumeSessionSleepWake({ deliveryId });
|
|
115
|
+
expect(first).toBe(true);
|
|
116
|
+
expect(second).toBe(false);
|
|
117
|
+
expect((await getSessionSleep({ sessionId: 'ses-once' }))?.status).toBe('consumed');
|
|
118
|
+
});
|
|
119
|
+
test('a wake cancelled while posting is dropped instead of starting a turn', async () => {
|
|
120
|
+
const deliveryId = await createSleep({
|
|
121
|
+
sessionId: 'ses-raced',
|
|
122
|
+
threadId: 'thr-raced',
|
|
123
|
+
});
|
|
124
|
+
await claimSessionSleepAttempt({
|
|
125
|
+
sessionId: 'ses-raced',
|
|
126
|
+
deliveryId,
|
|
127
|
+
now: NOW,
|
|
128
|
+
retryAfterMs: RETRY_AFTER_MS,
|
|
129
|
+
});
|
|
130
|
+
// User takes the conversation back while the post is in flight.
|
|
131
|
+
await cancelSessionSleepForThread({ threadId: 'thr-raced' });
|
|
132
|
+
expect(await consumeSessionSleepWake({ deliveryId })).toBe(false);
|
|
133
|
+
expect((await getSessionSleep({ sessionId: 'ses-raced' }))?.status).toBe('cancelled');
|
|
134
|
+
});
|
|
135
|
+
test('cancel reaches the sleep from any thread bound to the session', async () => {
|
|
136
|
+
const deliveryId = await createSleep({
|
|
137
|
+
sessionId: 'ses-rebound',
|
|
138
|
+
threadId: 'thr-rebound-old',
|
|
139
|
+
});
|
|
140
|
+
// /resume binds the same session to a second thread. A sleep belongs to the
|
|
141
|
+
// session, not to the thread it was created in, so either thread cancels it.
|
|
142
|
+
await setThreadSession('thr-rebound-new', 'ses-rebound');
|
|
143
|
+
const cancelled = await cancelSessionSleepForThread({
|
|
144
|
+
threadId: 'thr-rebound-new',
|
|
145
|
+
});
|
|
146
|
+
expect(cancelled).toBe(true);
|
|
147
|
+
expect(await consumeSessionSleepWake({ deliveryId })).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
test('a wake nobody consumed stays due and is retried', async () => {
|
|
150
|
+
const deliveryId = await createSleep({
|
|
151
|
+
sessionId: 'ses-stale',
|
|
152
|
+
threadId: 'thr-stale',
|
|
153
|
+
});
|
|
154
|
+
// Posting does not change the status, so a wake that was delivered to
|
|
155
|
+
// Discord but never reached ingress is simply due again after the retry
|
|
156
|
+
// window. This is the same path that covers a crash before posting.
|
|
157
|
+
await claimSessionSleepAttempt({
|
|
158
|
+
sessionId: 'ses-stale',
|
|
159
|
+
deliveryId,
|
|
160
|
+
now: NOW,
|
|
161
|
+
retryAfterMs: RETRY_AFTER_MS,
|
|
162
|
+
});
|
|
163
|
+
expect((await getSessionSleep({ sessionId: 'ses-stale' }))?.status).toBe('planned');
|
|
164
|
+
const retryDue = await getDueSessionSleeps({
|
|
165
|
+
now: new Date(NOW.getTime() + RETRY_AFTER_MS + 1),
|
|
166
|
+
retryAfterMs: RETRY_AFTER_MS,
|
|
167
|
+
limit: 50,
|
|
168
|
+
});
|
|
169
|
+
expect(retryDue.map((row) => row.session_id)).toContain('ses-stale');
|
|
170
|
+
});
|
|
171
|
+
test('failing a delivery is also guarded by delivery_id', async () => {
|
|
172
|
+
const deliveryId = await createSleep({
|
|
173
|
+
sessionId: 'ses-failed',
|
|
174
|
+
threadId: 'thr-failed',
|
|
175
|
+
});
|
|
176
|
+
expect(await markSessionSleepFailed({
|
|
177
|
+
sessionId: 'ses-failed',
|
|
178
|
+
deliveryId: 'some-other-delivery',
|
|
179
|
+
})).toBe(false);
|
|
180
|
+
expect(await markSessionSleepFailed({ sessionId: 'ses-failed', deliveryId })).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
test('only planned rows that are due and not just attempted are returned', async () => {
|
|
183
|
+
await createSleep({ sessionId: 'ses-due', threadId: 'thr-due' });
|
|
184
|
+
await createSleep({
|
|
185
|
+
sessionId: 'ses-future',
|
|
186
|
+
threadId: 'thr-future',
|
|
187
|
+
wakeAt: new Date('2026-08-19T13:00:00Z'),
|
|
188
|
+
});
|
|
189
|
+
const due = await getDueSessionSleeps({
|
|
190
|
+
now: NOW,
|
|
191
|
+
retryAfterMs: RETRY_AFTER_MS,
|
|
192
|
+
limit: 50,
|
|
193
|
+
});
|
|
194
|
+
const ids = due.map((row) => row.session_id);
|
|
195
|
+
expect(ids).toContain('ses-due');
|
|
196
|
+
expect(ids).not.toContain('ses-future');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
describe('session to thread resolution', () => {
|
|
200
|
+
test('resolves to the most recently bound thread, not an arbitrary one', async () => {
|
|
201
|
+
// Bindings are ordered by wall-clock time, so space them enough to get
|
|
202
|
+
// distinct millisecond stamps. Real /resume flows are seconds apart.
|
|
203
|
+
const tick = async () => {
|
|
204
|
+
await new Promise((resolve) => {
|
|
205
|
+
setTimeout(resolve, 5);
|
|
206
|
+
});
|
|
207
|
+
};
|
|
208
|
+
await setThreadSession('thr-first', 'ses-resume');
|
|
209
|
+
await tick();
|
|
210
|
+
// /resume maps the same session into a brand new thread without clearing
|
|
211
|
+
// the old row. The newest binding must win, otherwise plugin tools post
|
|
212
|
+
// into a dead conversation.
|
|
213
|
+
await setThreadSession('thr-second', 'ses-resume');
|
|
214
|
+
expect(await getThreadIdBySessionId('ses-resume')).toBe('thr-second');
|
|
215
|
+
await tick();
|
|
216
|
+
// Rebinding the older thread makes it current again. created_at ordering
|
|
217
|
+
// would still answer thr-second here, which is why updated_at exists.
|
|
218
|
+
await setThreadSession('thr-first', 'ses-resume');
|
|
219
|
+
expect(await getThreadIdBySessionId('ses-resume')).toBe('thr-first');
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
afterAll(async () => {
|
|
223
|
+
await closeDb();
|
|
224
|
+
});
|