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.
Files changed (46) hide show
  1. package/dist/cli-commands/send.js +3 -9
  2. package/dist/commands/abort.js +16 -4
  3. package/dist/commands/queue.js +7 -7
  4. package/dist/discord-bot.js +5 -5
  5. package/dist/discord-utils.js +15 -1
  6. package/dist/external-directory-permissions.e2e.test.js +2 -2
  7. package/dist/external-directory-project-deny.e2e.test.js +1 -1
  8. package/dist/message-formatting.js +16 -6
  9. package/dist/message-formatting.test.js +10 -9
  10. package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
  11. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
  12. package/dist/queue-advanced-abort.e2e.test.js +61 -5
  13. package/dist/queue-advanced-footer.e2e.test.js +1 -1
  14. package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -1
  15. package/dist/session-handler/event-stream-state.js +15 -15
  16. package/dist/session-handler/event-stream-state.test.js +152 -11
  17. package/dist/session-handler/thread-session-runtime.js +41 -28
  18. package/dist/sleep.e2e.test.js +2 -2
  19. package/dist/task-runner.js +3 -3
  20. package/dist/task-schedule.js +2 -2
  21. package/dist/thread-message-queue.e2e.test.js +1 -1
  22. package/dist/undo-redo.e2e.test.js +1 -1
  23. package/package.json +6 -6
  24. package/skills/holocron/SKILL.md +0 -28
  25. package/src/cli-commands/send.ts +3 -9
  26. package/src/commands/abort.ts +20 -4
  27. package/src/commands/queue.ts +7 -7
  28. package/src/discord-bot.ts +5 -5
  29. package/src/discord-utils.ts +17 -1
  30. package/src/external-directory-permissions.e2e.test.ts +2 -2
  31. package/src/external-directory-project-deny.e2e.test.ts +1 -1
  32. package/src/message-formatting.test.ts +10 -9
  33. package/src/message-formatting.ts +15 -6
  34. package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
  35. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
  36. package/src/queue-advanced-abort.e2e.test.ts +72 -5
  37. package/src/queue-advanced-footer.e2e.test.ts +1 -1
  38. package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -1
  39. package/src/session-handler/event-stream-state.test.ts +155 -11
  40. package/src/session-handler/event-stream-state.ts +17 -20
  41. package/src/session-handler/thread-session-runtime.ts +44 -32
  42. package/src/sleep.e2e.test.ts +2 -2
  43. package/src/task-runner.ts +3 -3
  44. package/src/task-schedule.ts +2 -2
  45. package/src/thread-message-queue.e2e.test.ts +1 -1
  46. package/src/undo-redo.e2e.test.ts +1 -1
@@ -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 { asSystemLine, QUEUE_PREFIX } from '../message-formatting.js'
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: asSystemLine('Queued message is no longer in the queue'),
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: asSystemLine(`Removed queued message: ${label}`),
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 = asSystemLine(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`)
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: asSystemLine(`Cleared queued message at position ${position}`),
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: asSystemLine(`Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`),
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 = asSystemLine(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`)
405
+ const responseText = asSubtext(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`)
406
406
  await command.reply({
407
407
  content: responseText,
408
408
  components: [
@@ -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 { asSystemLine, STATUS_PREFIX } from './message-formatting.js'
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
- asSystemLine(`Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`),
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
- `${STATUS_PREFIX}**${displayName}** removed message from queue`,
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
- `${STATUS_PREFIX}**${displayName}** edited queued message`,
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
- `${STATUS_PREFIX}**${removed.username}** removed message from queue`,
1300
+ asSubtext(`**${removed.username}** removed message from queue`),
1301
1301
  )
1302
1302
  } catch (error) {
1303
1303
  discordLogger.error(
@@ -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*"
@@ -70,7 +70,7 @@ describe('external directory project deny', () => {
70
70
  -# *using deterministic-provider/deterministic-v2*
71
71
  > reading external directory
72
72
 
73
- ┣ read *probe.txt*
73
+ -# ┣ read *probe.txt*
74
74
 
75
75
  > external-directory-probe-denied"
76
76
  `)
@@ -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, asSystemLine, batchChunksForDiscord, collectSessionChunks, formatBashToolTitle, formatPart, formatTaskToolTitle, formatTodoList, getTextAttachments, planAssistantTurnFlush, quotedTextFitsOneDiscordMessage, serializeEmbeds, serializePoll, serializeMessageSnapshots, sessionPartContent, shouldLeadWithBlankLine, TEXT_ATTACHMENT_INLINE_LIMIT_BYTES } from './message-formatting.js'
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('asSystemLine', () => {
80
- test('prefixes every line', () => {
81
- expect(asSystemLine('hello\nworld')).toMatchInlineSnapshot(`
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(asSystemLine('-# already marked')).toMatchInlineSnapshot(`"-# already marked"`)
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 SYSTEM_LINE_PREFIX = '-# '
34
- export const STATUS_PREFIX = SYSTEM_LINE_PREFIX
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 asSystemLine(text: string): string {
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.startsWith(SYSTEM_LINE_PREFIX) || line.startsWith('-#')) return line
43
- return `${SYSTEM_LINE_PREFIX}${line}`
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
- previousCacheRead: 20000,
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
- previousCacheRead: 40000,
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 context reduction', () => {
1899
- const sessionId = 'ses_cache_smaller'
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: 5000,
2018
+ input: 200,
1922
2019
  output: 10,
1923
2020
  reasoning: 0,
1924
- cache: { read: 8000, write: 5000 },
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
- previousCacheRead: 20000,
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('does not compare across an aborted assistant', () => {
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
- })).toBeUndefined()
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
- previousCacheRead: 20000,
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
- previousCacheRead: 20000,
2369
+ expectedCacheRead: 20000,
2226
2370
  currentCacheRead: 0,
2227
2371
  previousMessageId: 'msg_asst_1',
2228
2372
  currentMessageId: 'msg_asst_2',