kimaki 0.30.0 → 0.30.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/cli-commands/send.js +3 -9
- package/dist/commands/abort.js +16 -4
- package/dist/commands/queue.js +7 -7
- package/dist/discord-bot.js +5 -5
- package/dist/discord-utils.js +15 -1
- package/dist/external-directory-permissions.e2e.test.js +2 -2
- package/dist/external-directory-project-deny.e2e.test.js +1 -1
- package/dist/message-formatting.js +16 -6
- package/dist/message-formatting.test.js +10 -9
- package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
- package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
- package/dist/queue-advanced-abort.e2e.test.js +61 -5
- package/dist/queue-advanced-footer.e2e.test.js +1 -1
- package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -1
- package/dist/session-handler/event-stream-state.js +15 -15
- package/dist/session-handler/event-stream-state.test.js +152 -11
- package/dist/session-handler/thread-session-runtime.js +41 -28
- package/dist/sleep.e2e.test.js +2 -2
- package/dist/task-runner.js +3 -3
- package/dist/task-schedule.js +2 -2
- package/dist/thread-message-queue.e2e.test.js +1 -1
- package/dist/undo-redo.e2e.test.js +1 -1
- package/package.json +6 -6
- package/skills/holocron/SKILL.md +0 -28
- package/src/cli-commands/send.ts +3 -9
- package/src/commands/abort.ts +20 -4
- package/src/commands/queue.ts +7 -7
- package/src/discord-bot.ts +5 -5
- package/src/discord-utils.ts +17 -1
- package/src/external-directory-permissions.e2e.test.ts +2 -2
- package/src/external-directory-project-deny.e2e.test.ts +1 -1
- package/src/message-formatting.test.ts +10 -9
- package/src/message-formatting.ts +15 -6
- package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
- package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
- package/src/queue-advanced-abort.e2e.test.ts +72 -5
- package/src/queue-advanced-footer.e2e.test.ts +1 -1
- package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -1
- package/src/session-handler/event-stream-state.test.ts +155 -11
- package/src/session-handler/event-stream-state.ts +17 -20
- package/src/session-handler/thread-session-runtime.ts +44 -32
- package/src/sleep.e2e.test.ts +2 -2
- package/src/task-runner.ts +3 -3
- package/src/task-schedule.ts +2 -2
- package/src/thread-message-queue.e2e.test.ts +1 -1
- package/src/undo-redo.e2e.test.ts +1 -1
package/src/commands/queue.ts
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
getRuntime,
|
|
22
22
|
} from '../session-handler/thread-session-runtime.js'
|
|
23
23
|
import { createLogger, LogPrefix } from '../logger.js'
|
|
24
|
-
import {
|
|
24
|
+
import { asSubtext, QUEUE_PREFIX } from '../message-formatting.js'
|
|
25
25
|
import { store } from '../store.js'
|
|
26
26
|
|
|
27
27
|
const logger = createLogger(LogPrefix.QUEUE)
|
|
@@ -124,7 +124,7 @@ export async function handleQueueRemoveButton(
|
|
|
124
124
|
: await deleteThreadQueueItemWithoutRuntime(parsed.queueId)
|
|
125
125
|
if (!removed) {
|
|
126
126
|
await interaction.editReply({
|
|
127
|
-
content:
|
|
127
|
+
content: asSubtext('Queued message is no longer in the queue'),
|
|
128
128
|
components: [],
|
|
129
129
|
})
|
|
130
130
|
return
|
|
@@ -134,7 +134,7 @@ export async function handleQueueRemoveButton(
|
|
|
134
134
|
? `/${removed.command.name}`
|
|
135
135
|
: removed.prompt.slice(0, 120)
|
|
136
136
|
await interaction.editReply({
|
|
137
|
-
content:
|
|
137
|
+
content: asSubtext(`Removed queued message: ${label}`),
|
|
138
138
|
components: [],
|
|
139
139
|
})
|
|
140
140
|
logger.log(
|
|
@@ -211,7 +211,7 @@ export async function handleQueueCommand({
|
|
|
211
211
|
})
|
|
212
212
|
|
|
213
213
|
if (enqueueResult.queued && enqueueResult.queueId) {
|
|
214
|
-
const responseText =
|
|
214
|
+
const responseText = asSubtext(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`)
|
|
215
215
|
await command.reply({
|
|
216
216
|
content: responseText,
|
|
217
217
|
components: [
|
|
@@ -282,7 +282,7 @@ export async function handleClearQueueCommand({
|
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
await command.reply({
|
|
285
|
-
content:
|
|
285
|
+
content: asSubtext(`Cleared queued message at position ${position}`),
|
|
286
286
|
flags: SILENT_MESSAGE_FLAGS,
|
|
287
287
|
})
|
|
288
288
|
|
|
@@ -306,7 +306,7 @@ export async function handleClearQueueCommand({
|
|
|
306
306
|
}
|
|
307
307
|
|
|
308
308
|
await command.reply({
|
|
309
|
-
content:
|
|
309
|
+
content: asSubtext(`Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`),
|
|
310
310
|
flags: SILENT_MESSAGE_FLAGS,
|
|
311
311
|
})
|
|
312
312
|
|
|
@@ -402,7 +402,7 @@ export async function handleQueueCommandCommand({
|
|
|
402
402
|
})
|
|
403
403
|
|
|
404
404
|
if (enqueueResult.queued && enqueueResult.queueId) {
|
|
405
|
-
const responseText =
|
|
405
|
+
const responseText = asSubtext(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`)
|
|
406
406
|
await command.reply({
|
|
407
407
|
content: responseText,
|
|
408
408
|
components: [
|
package/src/discord-bot.ts
CHANGED
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js'
|
|
40
40
|
import { resolveSessionWorkingDirectory, git, isGitRepositoryRoot } from './worktrees.js'
|
|
41
41
|
import { WORKTREE_PREFIX } from './commands/merge-worktree.js'
|
|
42
|
-
import {
|
|
42
|
+
import { asSubtext } from './message-formatting.js'
|
|
43
43
|
import {
|
|
44
44
|
escapeBackticksInCodeBlocks,
|
|
45
45
|
splitMarkdownForDiscord,
|
|
@@ -996,7 +996,7 @@ export async function startDiscordBot({
|
|
|
996
996
|
if (enqueueResult.queued && enqueueResult.position) {
|
|
997
997
|
await sendThreadMessage(
|
|
998
998
|
thread,
|
|
999
|
-
|
|
999
|
+
asSubtext(`Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`),
|
|
1000
1000
|
)
|
|
1001
1001
|
}
|
|
1002
1002
|
}
|
|
@@ -1258,7 +1258,7 @@ export async function startDiscordBot({
|
|
|
1258
1258
|
)
|
|
1259
1259
|
await sendThreadMessage(
|
|
1260
1260
|
channel,
|
|
1261
|
-
|
|
1261
|
+
asSubtext(`**${displayName}** removed message from queue`),
|
|
1262
1262
|
)
|
|
1263
1263
|
} else {
|
|
1264
1264
|
discordLogger.log(
|
|
@@ -1266,7 +1266,7 @@ export async function startDiscordBot({
|
|
|
1266
1266
|
)
|
|
1267
1267
|
await sendThreadMessage(
|
|
1268
1268
|
channel,
|
|
1269
|
-
|
|
1269
|
+
asSubtext(`**${displayName}** edited queued message`),
|
|
1270
1270
|
)
|
|
1271
1271
|
}
|
|
1272
1272
|
}
|
|
@@ -1297,7 +1297,7 @@ export async function startDiscordBot({
|
|
|
1297
1297
|
)
|
|
1298
1298
|
await sendThreadMessage(
|
|
1299
1299
|
channel,
|
|
1300
|
-
|
|
1300
|
+
asSubtext(`**${removed.username}** removed message from queue`),
|
|
1301
1301
|
)
|
|
1302
1302
|
} catch (error) {
|
|
1303
1303
|
discordLogger.error(
|
package/src/discord-utils.ts
CHANGED
|
@@ -26,8 +26,10 @@ import {
|
|
|
26
26
|
shouldLeadWithBlankLine,
|
|
27
27
|
type SessionChunk,
|
|
28
28
|
} from './message-formatting.js'
|
|
29
|
-
import { getChannelDirectory, getThreadWorktreeOrWorkspace } from './database.js'
|
|
29
|
+
import { getChannelDirectory, getThreadIdBySessionId, getThreadWorktreeOrWorkspace } from './database.js'
|
|
30
30
|
import { DiscordOperationError } from './errors.js'
|
|
31
|
+
import type { ThreadStartMarker } from './system-message.js'
|
|
32
|
+
import YAML from 'yaml'
|
|
31
33
|
import { limitHeadingDepth } from './limit-heading-depth.js'
|
|
32
34
|
import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js'
|
|
33
35
|
import { createLogger, LogPrefix } from './logger.js'
|
|
@@ -38,6 +40,20 @@ import path from 'node:path'
|
|
|
38
40
|
|
|
39
41
|
const discordLogger = createLogger(LogPrefix.DISCORD)
|
|
40
42
|
|
|
43
|
+
/** Starter embed with the YAML marker. Links the parent thread when the parent session is known locally. */
|
|
44
|
+
export async function buildThreadStartEmbeds(marker: ThreadStartMarker) {
|
|
45
|
+
const parentThreadId = marker.parentSessionId
|
|
46
|
+
? await getThreadIdBySessionId(marker.parentSessionId)
|
|
47
|
+
: undefined
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
color: 0x2b2d31,
|
|
51
|
+
...(parentThreadId && { description: `Parent thread: <#${parentThreadId}>` }),
|
|
52
|
+
footer: { text: YAML.stringify(marker) },
|
|
53
|
+
},
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
|
|
41
57
|
/**
|
|
42
58
|
* Centralized permission check for Kimaki bot access.
|
|
43
59
|
* Returns true if the member has permission to use the bot:
|
|
@@ -84,7 +84,7 @@ describe('external directory permissions', () => {
|
|
|
84
84
|
-# *using deterministic-provider/deterministic-v2*
|
|
85
85
|
> reading external directory
|
|
86
86
|
|
|
87
|
-
┣ read *probe.txt*
|
|
87
|
+
-# ┣ read *probe.txt*
|
|
88
88
|
|
|
89
89
|
> external-directory-probe-done
|
|
90
90
|
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
@@ -93,7 +93,7 @@ describe('external directory permissions', () => {
|
|
|
93
93
|
--- from: assistant (TestBot)
|
|
94
94
|
> reading external directory
|
|
95
95
|
|
|
96
|
-
┣ read *probe.txt*
|
|
96
|
+
-# ┣ read *probe.txt*
|
|
97
97
|
|
|
98
98
|
> external-directory-probe-done
|
|
99
99
|
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { afterEach, describe, test, expect } from 'vitest'
|
|
4
|
-
import { asDiscordQuote,
|
|
4
|
+
import { asDiscordQuote, asSubtext, batchChunksForDiscord, collectSessionChunks, formatBashToolTitle, formatPart, formatTaskToolTitle, formatTodoList, getTextAttachments, planAssistantTurnFlush, quotedTextFitsOneDiscordMessage, serializeEmbeds, serializePoll, serializeMessageSnapshots, sessionPartContent, shouldLeadWithBlankLine, TEXT_ATTACHMENT_INLINE_LIMIT_BYTES } from './message-formatting.js'
|
|
5
5
|
import { getDataDir } from './config.js'
|
|
6
6
|
import type { Collection, Embed, Message, MessageSnapshot, Poll } from 'discord.js'
|
|
7
7
|
import type { Part } from '@opencode-ai/sdk/v2'
|
|
@@ -76,16 +76,17 @@ describe('formatPart', () => {
|
|
|
76
76
|
|
|
77
77
|
})
|
|
78
78
|
|
|
79
|
-
describe('
|
|
80
|
-
test('prefixes every line', () => {
|
|
81
|
-
expect(
|
|
79
|
+
describe('asSubtext', () => {
|
|
80
|
+
test('prefixes every non-empty line', () => {
|
|
81
|
+
expect(asSubtext('hello\n\nworld')).toMatchInlineSnapshot(`
|
|
82
82
|
"-# hello
|
|
83
|
+
|
|
83
84
|
-# world"
|
|
84
85
|
`)
|
|
85
86
|
})
|
|
86
87
|
|
|
87
88
|
test('does not double-prefix', () => {
|
|
88
|
-
expect(
|
|
89
|
+
expect(asSubtext('-# already marked')).toMatchInlineSnapshot(`"-# already marked"`)
|
|
89
90
|
})
|
|
90
91
|
})
|
|
91
92
|
|
|
@@ -596,7 +597,7 @@ describe('collectSessionChunks', () => {
|
|
|
596
597
|
})
|
|
597
598
|
expect(chunks.map((chunk) => chunk.content)).toEqual([
|
|
598
599
|
'> I will read it',
|
|
599
|
-
'┣ read',
|
|
600
|
+
'-# ┣ read',
|
|
600
601
|
])
|
|
601
602
|
})
|
|
602
603
|
|
|
@@ -771,7 +772,7 @@ describe('formatTaskToolTitle', () => {
|
|
|
771
772
|
sessionId: 'ses_child',
|
|
772
773
|
}),
|
|
773
774
|
),
|
|
774
|
-
).toMatchInlineSnapshot(`"┣ general **Classify pending changes**"`)
|
|
775
|
+
).toMatchInlineSnapshot(`"-# ┣ general **Classify pending changes**"`)
|
|
775
776
|
})
|
|
776
777
|
|
|
777
778
|
test('prefers input description on running parts', () => {
|
|
@@ -784,7 +785,7 @@ describe('formatTaskToolTitle', () => {
|
|
|
784
785
|
sessionId: 'ses_child',
|
|
785
786
|
}),
|
|
786
787
|
),
|
|
787
|
-
).toMatchInlineSnapshot(`"┣ explore **inspect repo**"`)
|
|
788
|
+
).toMatchInlineSnapshot(`"-# ┣ explore **inspect repo**"`)
|
|
788
789
|
})
|
|
789
790
|
|
|
790
791
|
test('does not format completed parts so Discord does not post the line at the end', () => {
|
|
@@ -811,7 +812,7 @@ describe('formatTaskToolTitle', () => {
|
|
|
811
812
|
},
|
|
812
813
|
}),
|
|
813
814
|
),
|
|
814
|
-
).toMatchInlineSnapshot(`"┣ general **audit customer pages**"`)
|
|
815
|
+
).toMatchInlineSnapshot(`"-# ┣ general **audit customer pages**"`)
|
|
815
816
|
})
|
|
816
817
|
})
|
|
817
818
|
|
|
@@ -30,17 +30,19 @@ export const TOOL_PREFIX = '┣ '
|
|
|
30
30
|
export const FILE_EDIT_PREFIX = '◼︎ '
|
|
31
31
|
export const THINKING_PREFIX = '┣ '
|
|
32
32
|
// Discord subtext. Not `#-`.
|
|
33
|
-
export const
|
|
34
|
-
|
|
33
|
+
export const SUBTEXT_PREFIX = '-# '
|
|
34
|
+
// Same width as ┣. Only used for tool-related status lines; other bot status lines have no glyph.
|
|
35
|
+
export const STATUS_PREFIX = '⬦ '
|
|
35
36
|
export const QUEUE_PREFIX = '» '
|
|
36
37
|
export const WORKTREE_PREFIX = '⬦ '
|
|
37
38
|
|
|
38
|
-
export function
|
|
39
|
+
export function asSubtext(text: string): string {
|
|
39
40
|
const lead = text.startsWith('\n') ? '\n' : ''
|
|
40
41
|
const body = lead ? text.slice(1) : text
|
|
41
42
|
return lead + body.split('\n').map((line) => {
|
|
42
|
-
if (line.
|
|
43
|
-
return
|
|
43
|
+
if (!line.trim()) return line
|
|
44
|
+
if (line.startsWith('-#')) return line
|
|
45
|
+
return `${SUBTEXT_PREFIX}${line}`
|
|
44
46
|
}).join('\n')
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -830,10 +832,17 @@ export function formatTaskToolTitle(part: Extract<Part, { type: 'tool' }>): stri
|
|
|
830
832
|
|
|
831
833
|
const subagentType = part.state.input?.subagent_type
|
|
832
834
|
const agent = typeof subagentType === 'string' ? subagentType : 'task'
|
|
833
|
-
return `${TOOL_PREFIX}${escapeInlineMarkdown(agent)} **${escapeInlineMarkdown(title)}**`
|
|
835
|
+
return asSubtext(`${TOOL_PREFIX}${escapeInlineMarkdown(agent)} **${escapeInlineMarkdown(title)}**`)
|
|
834
836
|
}
|
|
835
837
|
|
|
838
|
+
// Non-text parts (tools, thinking, files) render as Discord subtext so they look dimmer than text.
|
|
836
839
|
export function formatPart(part: Part, prefix?: string): string {
|
|
840
|
+
const formatted = formatPartBody(part, prefix)
|
|
841
|
+
if (!formatted || part.type === 'text') return formatted
|
|
842
|
+
return asSubtext(formatted)
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function formatPartBody(part: Part, prefix?: string): string {
|
|
837
846
|
const pfx = prefix ? `${prefix} ⋅ ` : ''
|
|
838
847
|
|
|
839
848
|
if (part.type === 'text') {
|
|
@@ -65,7 +65,7 @@ describe('abort clears pending question dropdown', () => {
|
|
|
65
65
|
if (!runtime) {
|
|
66
66
|
throw new Error('Expected runtime for question abort test')
|
|
67
67
|
}
|
|
68
|
-
runtime.abortActiveRun('test-question-abort')
|
|
68
|
+
void runtime.abortActiveRun('test-question-abort')
|
|
69
69
|
|
|
70
70
|
// Click the now-dead dropdown. The answer echo must never appear.
|
|
71
71
|
const interaction = await th.user(TEST_USER_ID).selectMenu({
|
|
@@ -129,7 +129,7 @@ describe('question text before dropdown after abort retry', () => {
|
|
|
129
129
|
if (!runtime) {
|
|
130
130
|
throw new Error('Expected runtime for abort-retry question test')
|
|
131
131
|
}
|
|
132
|
-
runtime.abortActiveRun('test-question-abort-retry-flush')
|
|
132
|
+
void runtime.abortActiveRun('test-question-abort-retry-flush')
|
|
133
133
|
|
|
134
134
|
await th.user(TEST_USER_ID).sendMessage({
|
|
135
135
|
content: 'QUESTION_AFTER_ABORT_RETRY_MARKER',
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
getRuntime,
|
|
11
11
|
} from './session-handler/thread-session-runtime.js'
|
|
12
12
|
import { getThreadState } from './session-handler/thread-runtime-state.js'
|
|
13
|
-
import { setSessionModel } from './database.js'
|
|
13
|
+
import { listThreadQueueItems, setSessionModel } from './database.js'
|
|
14
14
|
import {
|
|
15
15
|
isFooterMessage,
|
|
16
16
|
waitForFooterMessage,
|
|
@@ -82,7 +82,7 @@ e2eTest('queue advanced: abort and retry', () => {
|
|
|
82
82
|
throw new Error('Expected runtime to exist for explicit-abort test')
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
runtime.abortActiveRun('test-explicit-abort')
|
|
85
|
+
void runtime.abortActiveRun('test-explicit-abort')
|
|
86
86
|
|
|
87
87
|
await th.user(TEST_USER_ID).sendMessage({
|
|
88
88
|
content: 'Reply with exactly: papa',
|
|
@@ -145,6 +145,73 @@ e2eTest('queue advanced: abort and retry', () => {
|
|
|
145
145
|
12_000,
|
|
146
146
|
)
|
|
147
147
|
|
|
148
|
+
test(
|
|
149
|
+
'/abort clears queued messages so they are never sent',
|
|
150
|
+
async () => {
|
|
151
|
+
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
152
|
+
content: 'PLUGIN_TIMEOUT_SLEEP_MARKER',
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
156
|
+
timeout: 4_000,
|
|
157
|
+
predicate: (t) => {
|
|
158
|
+
return t.name === 'PLUGIN_TIMEOUT_SLEEP_MARKER'
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
const th = ctx.discord.thread(thread.id)
|
|
162
|
+
|
|
163
|
+
await waitForBotMessageContaining({
|
|
164
|
+
discord: ctx.discord,
|
|
165
|
+
threadId: thread.id,
|
|
166
|
+
userId: TEST_USER_ID,
|
|
167
|
+
text: 'starting sleep',
|
|
168
|
+
timeout: 4_000,
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const { id: queueInteractionId } = await th.user(TEST_USER_ID).runSlashCommand({
|
|
172
|
+
name: 'queue',
|
|
173
|
+
options: [{ name: 'message', type: 3, value: 'Reply with exactly: abort-cleared-queued' }],
|
|
174
|
+
})
|
|
175
|
+
await th.waitForInteractionAck({ interactionId: queueInteractionId, timeout: 4_000 })
|
|
176
|
+
expect(getRuntime(thread.id)?.getQueueLength()).toBe(1)
|
|
177
|
+
expect(await listThreadQueueItems(thread.id)).toHaveLength(1)
|
|
178
|
+
|
|
179
|
+
const { id: abortInteractionId } = await th.user(TEST_USER_ID).runSlashCommand({
|
|
180
|
+
name: 'abort',
|
|
181
|
+
})
|
|
182
|
+
await th.waitForInteractionAck({ interactionId: abortInteractionId, timeout: 4_000 })
|
|
183
|
+
await waitForBotMessageContaining({
|
|
184
|
+
discord: ctx.discord,
|
|
185
|
+
threadId: thread.id,
|
|
186
|
+
userId: TEST_USER_ID,
|
|
187
|
+
text: 'Request **aborted**',
|
|
188
|
+
timeout: 4_000,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// Queued message must never be dispatched after abort.
|
|
192
|
+
for (let i = 0; i < 10; i++) {
|
|
193
|
+
await new Promise((resolve) => {
|
|
194
|
+
setTimeout(resolve, 20)
|
|
195
|
+
})
|
|
196
|
+
const text = await th.text()
|
|
197
|
+
expect(text).not.toContain('» **queue-advanced-tester:** Reply with exactly: abort-cleared-queued')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
201
|
+
"--- from: user (queue-advanced-tester)
|
|
202
|
+
PLUGIN_TIMEOUT_SLEEP_MARKER
|
|
203
|
+
--- from: assistant (TestBot)
|
|
204
|
+
-# *using deterministic-provider/deterministic-v2*
|
|
205
|
+
> starting sleep 100
|
|
206
|
+
-# Queued message (position 1)
|
|
207
|
+
Request **aborted**, cleared 1 queued message"
|
|
208
|
+
`)
|
|
209
|
+
expect(getRuntime(thread.id)?.getQueueLength()).toBe(0)
|
|
210
|
+
expect(await listThreadQueueItems(thread.id)).toHaveLength(0)
|
|
211
|
+
},
|
|
212
|
+
10_000,
|
|
213
|
+
)
|
|
214
|
+
|
|
148
215
|
test(
|
|
149
216
|
'explicit abort emits MessageAbortedError and does not emit footer',
|
|
150
217
|
async () => {
|
|
@@ -183,7 +250,7 @@ e2eTest('queue advanced: abort and retry', () => {
|
|
|
183
250
|
const beforeAbortMessages = await th.getMessages()
|
|
184
251
|
const baselineCount = beforeAbortMessages.length
|
|
185
252
|
|
|
186
|
-
runtime.abortActiveRun('test-no-footer-on-abort')
|
|
253
|
+
void runtime.abortActiveRun('test-no-footer-on-abort')
|
|
187
254
|
|
|
188
255
|
for (let i = 0; i < 10; i++) {
|
|
189
256
|
await new Promise((resolve) => {
|
|
@@ -242,7 +309,7 @@ e2eTest('queue advanced: abort and retry', () => {
|
|
|
242
309
|
throw new Error('Expected runtime to exist for race abort scenario')
|
|
243
310
|
}
|
|
244
311
|
|
|
245
|
-
runtime.abortActiveRun('test-race-abort')
|
|
312
|
+
void runtime.abortActiveRun('test-race-abort')
|
|
246
313
|
|
|
247
314
|
await th.user(TEST_USER_ID).sendMessage({
|
|
248
315
|
content: raceFinalPrompt,
|
|
@@ -366,7 +433,7 @@ e2eTest('queue advanced: abort and retry', () => {
|
|
|
366
433
|
throw new Error('Expected runtime to exist for forced-abort test')
|
|
367
434
|
}
|
|
368
435
|
|
|
369
|
-
runtime.abortActiveRun('force-abort-test')
|
|
436
|
+
void runtime.abortActiveRun('force-abort-test')
|
|
370
437
|
|
|
371
438
|
expect(await th.text()).toMatchInlineSnapshot(`
|
|
372
439
|
"--- from: user (queue-advanced-tester)
|
|
@@ -345,7 +345,7 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
345
345
|
-# *using deterministic-provider/deterministic-v2*
|
|
346
346
|
> running tool
|
|
347
347
|
|
|
348
|
-
┣ bash _echo tool-call-footer-test_
|
|
348
|
+
-# ┣ bash _echo tool-call-footer-test_
|
|
349
349
|
|
|
350
350
|
> tool call completed
|
|
351
351
|
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
@@ -128,7 +128,7 @@ describe('queue advanced: typing around permissions', () => {
|
|
|
128
128
|
-# *using deterministic-provider/deterministic-v2*
|
|
129
129
|
> requesting external read permission
|
|
130
130
|
|
|
131
|
-
┣ read *.zprofile*
|
|
131
|
+
-# ┣ read *.zprofile*
|
|
132
132
|
⚠️ **Permission Required**
|
|
133
133
|
**Type:** \`external_directory\`
|
|
134
134
|
Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)
|
|
@@ -1754,13 +1754,110 @@ describe('getPromptCacheClear', () => {
|
|
|
1754
1754
|
sessionId,
|
|
1755
1755
|
currentMessageId: 'msg_asst_2',
|
|
1756
1756
|
})).toEqual({
|
|
1757
|
-
|
|
1757
|
+
expectedCacheRead: 20000,
|
|
1758
1758
|
currentCacheRead: 0,
|
|
1759
1759
|
previousMessageId: 'msg_asst_1',
|
|
1760
1760
|
currentMessageId: 'msg_asst_2',
|
|
1761
1761
|
})
|
|
1762
1762
|
})
|
|
1763
1763
|
|
|
1764
|
+
test('detects a miss on the second turn when the first turn only wrote cache', () => {
|
|
1765
|
+
const sessionId = 'ses_cache_second_turn'
|
|
1766
|
+
const events = [
|
|
1767
|
+
userEvent({ sessionId, messageId: 'msg_user_1', created: 1 }),
|
|
1768
|
+
assistantEvent({
|
|
1769
|
+
sessionId,
|
|
1770
|
+
messageId: 'msg_asst_1',
|
|
1771
|
+
parentID: 'msg_user_1',
|
|
1772
|
+
created: 2,
|
|
1773
|
+
tokens: {
|
|
1774
|
+
input: 100,
|
|
1775
|
+
output: 10,
|
|
1776
|
+
reasoning: 0,
|
|
1777
|
+
cache: { read: 0, write: 20000 },
|
|
1778
|
+
},
|
|
1779
|
+
}),
|
|
1780
|
+
userEvent({ sessionId, messageId: 'msg_user_2', created: 3 }),
|
|
1781
|
+
assistantEvent({
|
|
1782
|
+
sessionId,
|
|
1783
|
+
messageId: 'msg_asst_2',
|
|
1784
|
+
parentID: 'msg_user_2',
|
|
1785
|
+
created: 4,
|
|
1786
|
+
tokens: {
|
|
1787
|
+
input: 100,
|
|
1788
|
+
output: 12,
|
|
1789
|
+
reasoning: 0,
|
|
1790
|
+
cache: { read: 0, write: 20200 },
|
|
1791
|
+
},
|
|
1792
|
+
}),
|
|
1793
|
+
]
|
|
1794
|
+
expect(getPromptCacheClear({
|
|
1795
|
+
events,
|
|
1796
|
+
sessionId,
|
|
1797
|
+
currentMessageId: 'msg_asst_2',
|
|
1798
|
+
})).toEqual({
|
|
1799
|
+
expectedCacheRead: 20000,
|
|
1800
|
+
currentCacheRead: 0,
|
|
1801
|
+
previousMessageId: 'msg_asst_1',
|
|
1802
|
+
currentMessageId: 'msg_asst_2',
|
|
1803
|
+
})
|
|
1804
|
+
})
|
|
1805
|
+
|
|
1806
|
+
test('detects consecutive misses, not only every other turn', () => {
|
|
1807
|
+
const sessionId = 'ses_cache_consecutive'
|
|
1808
|
+
const events = [
|
|
1809
|
+
userEvent({ sessionId, messageId: 'msg_user_1', created: 1 }),
|
|
1810
|
+
assistantEvent({
|
|
1811
|
+
sessionId,
|
|
1812
|
+
messageId: 'msg_asst_1',
|
|
1813
|
+
parentID: 'msg_user_1',
|
|
1814
|
+
created: 2,
|
|
1815
|
+
tokens: {
|
|
1816
|
+
input: 100,
|
|
1817
|
+
output: 10,
|
|
1818
|
+
reasoning: 0,
|
|
1819
|
+
cache: { read: 20000, write: 100 },
|
|
1820
|
+
},
|
|
1821
|
+
}),
|
|
1822
|
+
userEvent({ sessionId, messageId: 'msg_user_2', created: 3 }),
|
|
1823
|
+
assistantEvent({
|
|
1824
|
+
sessionId,
|
|
1825
|
+
messageId: 'msg_asst_2',
|
|
1826
|
+
parentID: 'msg_user_2',
|
|
1827
|
+
created: 4,
|
|
1828
|
+
tokens: {
|
|
1829
|
+
input: 100,
|
|
1830
|
+
output: 10,
|
|
1831
|
+
reasoning: 0,
|
|
1832
|
+
cache: { read: 0, write: 20300 },
|
|
1833
|
+
},
|
|
1834
|
+
}),
|
|
1835
|
+
userEvent({ sessionId, messageId: 'msg_user_3', created: 5 }),
|
|
1836
|
+
assistantEvent({
|
|
1837
|
+
sessionId,
|
|
1838
|
+
messageId: 'msg_asst_3',
|
|
1839
|
+
parentID: 'msg_user_3',
|
|
1840
|
+
created: 6,
|
|
1841
|
+
tokens: {
|
|
1842
|
+
input: 100,
|
|
1843
|
+
output: 10,
|
|
1844
|
+
reasoning: 0,
|
|
1845
|
+
cache: { read: 0, write: 20500 },
|
|
1846
|
+
},
|
|
1847
|
+
}),
|
|
1848
|
+
]
|
|
1849
|
+
expect(getPromptCacheClear({
|
|
1850
|
+
events,
|
|
1851
|
+
sessionId,
|
|
1852
|
+
currentMessageId: 'msg_asst_3',
|
|
1853
|
+
})).toEqual({
|
|
1854
|
+
expectedCacheRead: 20300,
|
|
1855
|
+
currentCacheRead: 0,
|
|
1856
|
+
previousMessageId: 'msg_asst_2',
|
|
1857
|
+
currentMessageId: 'msg_asst_3',
|
|
1858
|
+
})
|
|
1859
|
+
})
|
|
1860
|
+
|
|
1764
1861
|
test('detects a cache drop when prompt size stays similar', () => {
|
|
1765
1862
|
const sessionId = 'ses_cache_drop'
|
|
1766
1863
|
const events = [
|
|
@@ -1796,7 +1893,7 @@ describe('getPromptCacheClear', () => {
|
|
|
1796
1893
|
sessionId,
|
|
1797
1894
|
currentMessageId: 'msg_asst_2',
|
|
1798
1895
|
})).toEqual({
|
|
1799
|
-
|
|
1896
|
+
expectedCacheRead: 40000,
|
|
1800
1897
|
currentCacheRead: 8000,
|
|
1801
1898
|
previousMessageId: 'msg_asst_1',
|
|
1802
1899
|
currentMessageId: 'msg_asst_2',
|
|
@@ -1895,8 +1992,8 @@ describe('getPromptCacheClear', () => {
|
|
|
1895
1992
|
})).toBeUndefined()
|
|
1896
1993
|
})
|
|
1897
1994
|
|
|
1898
|
-
test('ignores a smaller prompt after
|
|
1899
|
-
const sessionId = '
|
|
1995
|
+
test('ignores a smaller prompt that is still read from cache after a revert', () => {
|
|
1996
|
+
const sessionId = 'ses_cache_revert'
|
|
1900
1997
|
const events = [
|
|
1901
1998
|
userEvent({ sessionId, messageId: 'msg_user_1', created: 1 }),
|
|
1902
1999
|
assistantEvent({
|
|
@@ -1918,10 +2015,10 @@ describe('getPromptCacheClear', () => {
|
|
|
1918
2015
|
parentID: 'msg_user_2',
|
|
1919
2016
|
created: 4,
|
|
1920
2017
|
tokens: {
|
|
1921
|
-
input:
|
|
2018
|
+
input: 200,
|
|
1922
2019
|
output: 10,
|
|
1923
2020
|
reasoning: 0,
|
|
1924
|
-
cache: { read: 8000, write:
|
|
2021
|
+
cache: { read: 8000, write: 0 },
|
|
1925
2022
|
},
|
|
1926
2023
|
}),
|
|
1927
2024
|
]
|
|
@@ -1932,6 +2029,48 @@ describe('getPromptCacheClear', () => {
|
|
|
1932
2029
|
})).toBeUndefined()
|
|
1933
2030
|
})
|
|
1934
2031
|
|
|
2032
|
+
test('detects a miss when the prompt shrank without pruning', () => {
|
|
2033
|
+
const sessionId = 'ses_cache_shrunk_miss'
|
|
2034
|
+
const events = [
|
|
2035
|
+
userEvent({ sessionId, messageId: 'msg_user_1', created: 1 }),
|
|
2036
|
+
assistantEvent({
|
|
2037
|
+
sessionId,
|
|
2038
|
+
messageId: 'msg_asst_1',
|
|
2039
|
+
parentID: 'msg_user_1',
|
|
2040
|
+
created: 2,
|
|
2041
|
+
tokens: {
|
|
2042
|
+
input: 100,
|
|
2043
|
+
output: 10,
|
|
2044
|
+
reasoning: 0,
|
|
2045
|
+
cache: { read: 187000, write: 0 },
|
|
2046
|
+
},
|
|
2047
|
+
}),
|
|
2048
|
+
userEvent({ sessionId, messageId: 'msg_user_2', created: 3 }),
|
|
2049
|
+
assistantEvent({
|
|
2050
|
+
sessionId,
|
|
2051
|
+
messageId: 'msg_asst_2',
|
|
2052
|
+
parentID: 'msg_user_2',
|
|
2053
|
+
created: 4,
|
|
2054
|
+
tokens: {
|
|
2055
|
+
input: 182000,
|
|
2056
|
+
output: 10,
|
|
2057
|
+
reasoning: 0,
|
|
2058
|
+
cache: { read: 0, write: 0 },
|
|
2059
|
+
},
|
|
2060
|
+
}),
|
|
2061
|
+
]
|
|
2062
|
+
expect(getPromptCacheClear({
|
|
2063
|
+
events,
|
|
2064
|
+
sessionId,
|
|
2065
|
+
currentMessageId: 'msg_asst_2',
|
|
2066
|
+
})).toEqual({
|
|
2067
|
+
expectedCacheRead: 182000,
|
|
2068
|
+
currentCacheRead: 0,
|
|
2069
|
+
previousMessageId: 'msg_asst_1',
|
|
2070
|
+
currentMessageId: 'msg_asst_2',
|
|
2071
|
+
})
|
|
2072
|
+
})
|
|
2073
|
+
|
|
1935
2074
|
test('compares against the previous user turn, not a same-turn tool step', () => {
|
|
1936
2075
|
const sessionId = 'ses_cache_same_turn'
|
|
1937
2076
|
const events = [
|
|
@@ -1979,7 +2118,7 @@ describe('getPromptCacheClear', () => {
|
|
|
1979
2118
|
sessionId,
|
|
1980
2119
|
currentMessageId: 'msg_asst_2b',
|
|
1981
2120
|
})).toEqual({
|
|
1982
|
-
|
|
2121
|
+
expectedCacheRead: 20000,
|
|
1983
2122
|
currentCacheRead: 0,
|
|
1984
2123
|
previousMessageId: 'msg_asst_1',
|
|
1985
2124
|
currentMessageId: 'msg_asst_2b',
|
|
@@ -2023,7 +2162,7 @@ describe('getPromptCacheClear', () => {
|
|
|
2023
2162
|
})).toBeUndefined()
|
|
2024
2163
|
})
|
|
2025
2164
|
|
|
2026
|
-
test('
|
|
2165
|
+
test('compares across an aborted assistant with the last good reply', () => {
|
|
2027
2166
|
const sessionId = 'ses_cache_abort_bridge'
|
|
2028
2167
|
const events = [
|
|
2029
2168
|
userEvent({ sessionId, messageId: 'msg_user_1', created: 1 }),
|
|
@@ -2071,7 +2210,12 @@ describe('getPromptCacheClear', () => {
|
|
|
2071
2210
|
events,
|
|
2072
2211
|
sessionId,
|
|
2073
2212
|
currentMessageId: 'msg_asst_3',
|
|
2074
|
-
})).
|
|
2213
|
+
})).toEqual({
|
|
2214
|
+
expectedCacheRead: 40000,
|
|
2215
|
+
currentCacheRead: 0,
|
|
2216
|
+
previousMessageId: 'msg_asst_1',
|
|
2217
|
+
currentMessageId: 'msg_asst_3',
|
|
2218
|
+
})
|
|
2075
2219
|
})
|
|
2076
2220
|
|
|
2077
2221
|
test('does not compare across a compaction summary', () => {
|
|
@@ -2216,13 +2360,13 @@ describe('getPromptCacheClear', () => {
|
|
|
2216
2360
|
|
|
2217
2361
|
test('formats a compact cache-miss notice', () => {
|
|
2218
2362
|
expect(formatPromptCacheClearMessage({
|
|
2219
|
-
|
|
2363
|
+
expectedCacheRead: 20000,
|
|
2220
2364
|
currentCacheRead: 0,
|
|
2221
2365
|
previousMessageId: 'msg_asst_1',
|
|
2222
2366
|
currentMessageId: 'msg_asst_2',
|
|
2223
2367
|
})).toBe('prompt cache missed (20k → 0)')
|
|
2224
2368
|
expect(formatPromptCacheClearMessage({
|
|
2225
|
-
|
|
2369
|
+
expectedCacheRead: 20000,
|
|
2226
2370
|
currentCacheRead: 0,
|
|
2227
2371
|
previousMessageId: 'msg_asst_1',
|
|
2228
2372
|
currentMessageId: 'msg_asst_2',
|