kimaki 0.26.0 → 0.27.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 +25 -122
- package/dist/anthropic-account-identity.js +1 -0
- package/dist/anthropic-auth-plugin.js +1 -0
- package/dist/anthropic-auth-state.js +1 -0
- package/dist/bin.js +73 -19
- package/dist/cli-commands/maintenance.js +46 -7
- package/dist/cli-commands/send.js +24 -0
- package/dist/cli-commands/session.js +183 -8
- package/dist/cli-commands/task.js +13 -1
- package/dist/cli-parsing.test.js +59 -3
- package/dist/cli-runner.js +28 -4
- package/dist/cli-runner.test.js +27 -0
- package/dist/cli-send-thread.e2e.test.js +1 -1
- package/dist/cli.js +32 -0
- package/dist/commands/abort.js +5 -1
- package/dist/commands/agent.js +6 -8
- package/dist/commands/ask-question.js +21 -32
- package/dist/commands/ask-question.test.js +43 -1
- package/dist/commands/login.js +6 -0
- package/dist/commands/merge-worktree.js +36 -10
- package/dist/commands/model-variant.js +7 -1
- package/dist/commands/model.js +14 -8
- package/dist/commands/multioauth.js +1 -0
- package/dist/commands/new-worktree.js +170 -102
- package/dist/commands/user-command.js +6 -8
- package/dist/commands/worktrees.js +5 -13
- package/dist/config.js +16 -1
- package/dist/database.js +124 -3
- package/dist/db.js +22 -0
- package/dist/db.test.js +167 -1
- package/dist/discord-bot.js +34 -32
- package/dist/discord-command-registration.js +9 -1
- package/dist/discord-utils.js +13 -5
- package/dist/discord-utils.test.js +17 -1
- package/dist/errors.js +5 -0
- package/dist/exec-async.js +17 -5
- package/dist/external-directory-permissions.e2e.test.js +2 -2
- 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 +5 -5
- package/dist/git-worktree-core.js +182 -17
- package/dist/ipc-tools-plugin.js +63 -3
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
- package/dist/kimaki-opencode-plugin.js +7 -3
- package/dist/kimaki-workspace-adaptor.js +33 -10
- package/dist/logger.js +10 -1
- package/dist/markdown.js +3 -1
- package/dist/markdown.test.js +11 -3
- package/dist/message-formatting.js +18 -7
- package/dist/message-formatting.test.js +56 -9
- 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.test.js +60 -0
- package/dist/opencode.js +53 -12
- package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
- package/dist/question-text-before-dropdown.e2e.test.js +52 -0
- package/dist/queue-advanced-abort.e2e.test.js +1 -1
- package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
- package/dist/queue-advanced-e2e-setup.js +78 -5
- package/dist/queue-advanced-footer.e2e.test.js +40 -246
- package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
- package/dist/queue-advanced-permissions-typing.e2e.test.js +7 -9
- package/dist/queue-advanced-question.e2e.test.js +31 -26
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +2 -2
- package/dist/queue-delete-message.e2e.test.js +1 -1
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
- package/dist/queue-interrupt-drain.e2e.test.js +112 -2
- package/dist/queue-question-select-drain.e2e.test.js +30 -128
- package/dist/runtime-lifecycle.e2e.test.js +10 -37
- package/dist/schema.js +34 -0
- package/dist/session-handler/event-stream-state.js +267 -50
- package/dist/session-handler/event-stream-state.test.js +415 -8
- package/dist/session-handler/model-utils.js +159 -5
- package/dist/session-handler/model-utils.test.js +345 -0
- package/dist/session-handler/thread-session-runtime.js +252 -182
- package/dist/session-sleep.test.js +224 -0
- package/dist/session-title-rename.test.js +7 -39
- package/dist/sleep.e2e.test.js +491 -0
- package/dist/startup-time.e2e.test.js +2 -1
- package/dist/store.js +3 -0
- package/dist/system-message.js +59 -6
- package/dist/system-message.test.js +76 -6
- package/dist/task-runner.js +94 -4
- package/dist/task-schedule.js +56 -0
- package/dist/task-schedule.test.js +71 -1
- package/dist/thread-message-queue.e2e.test.js +10 -169
- package/dist/undo-redo.e2e.test.js +2 -2
- package/dist/voice-attachment.js +23 -1
- package/dist/voice-message.e2e.test.js +10 -10
- package/dist/voice.test.js +55 -29
- package/dist/worktree-clone-isolation.e2e.test.js +188 -0
- package/dist/worktree-lifecycle.e2e.test.js +29 -10
- 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/errore/SKILL.md +18 -0
- package/skills/goke/SKILL.md +150 -10
- package/skills/holocron/SKILL.md +37 -0
- 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 +25 -151
- package/src/anthropic-account-identity.ts +1 -0
- package/src/anthropic-auth-plugin.ts +1 -0
- package/src/anthropic-auth-state.ts +1 -0
- package/src/bin.ts +78 -26
- package/src/cli-commands/maintenance.ts +59 -13
- package/src/cli-commands/send.ts +25 -0
- package/src/cli-commands/session.ts +203 -9
- package/src/cli-commands/task.ts +13 -1
- package/src/cli-parsing.test.ts +69 -3
- package/src/cli-runner.test.ts +49 -0
- package/src/cli-runner.ts +28 -4
- package/src/cli-send-thread.e2e.test.ts +1 -1
- package/src/cli.ts +53 -0
- package/src/commands/abort.ts +6 -1
- package/src/commands/agent.ts +6 -6
- package/src/commands/ask-question.test.ts +48 -1
- package/src/commands/ask-question.ts +21 -32
- package/src/commands/login.ts +6 -0
- package/src/commands/merge-worktree.ts +43 -12
- package/src/commands/model-variant.ts +8 -1
- package/src/commands/model.ts +18 -8
- package/src/commands/multioauth.ts +1 -0
- package/src/commands/new-worktree.ts +138 -55
- package/src/commands/user-command.ts +6 -6
- package/src/commands/worktrees.ts +5 -12
- package/src/config.ts +18 -1
- package/src/database.ts +195 -3
- package/src/db.test.ts +188 -0
- package/src/db.ts +26 -0
- package/src/discord-bot.ts +35 -29
- package/src/discord-command-registration.ts +15 -1
- package/src/discord-utils.test.ts +19 -0
- package/src/discord-utils.ts +21 -6
- package/src/errors.ts +5 -0
- package/src/exec-async.ts +20 -11
- package/src/external-directory-permissions.e2e.test.ts +2 -2
- 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 +5 -5
- package/src/git-worktree-core.ts +247 -20
- package/src/ipc-tools-plugin.ts +75 -5
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
- package/src/kimaki-opencode-plugin.ts +7 -3
- package/src/kimaki-workspace-adaptor.ts +40 -11
- package/src/logger.ts +13 -4
- package/src/markdown.test.ts +13 -3
- package/src/markdown.ts +4 -1
- package/src/message-formatting.test.ts +86 -12
- package/src/message-formatting.ts +19 -7
- 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 +81 -0
- package/src/opencode.ts +71 -11
- package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
- package/src/question-text-before-dropdown.e2e.test.ts +67 -0
- package/src/queue-advanced-abort.e2e.test.ts +1 -1
- package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
- package/src/queue-advanced-e2e-setup.ts +83 -4
- package/src/queue-advanced-footer.e2e.test.ts +47 -294
- package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
- package/src/queue-advanced-permissions-typing.e2e.test.ts +7 -9
- package/src/queue-advanced-question.e2e.test.ts +35 -29
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +2 -2
- package/src/queue-delete-message.e2e.test.ts +1 -1
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
- package/src/queue-interrupt-drain.e2e.test.ts +123 -2
- package/src/queue-question-select-drain.e2e.test.ts +32 -157
- package/src/runtime-lifecycle.e2e.test.ts +10 -41
- package/src/schema.sql +14 -1
- package/src/schema.ts +36 -0
- package/src/session-handler/event-stream-state.test.ts +437 -7
- package/src/session-handler/event-stream-state.ts +389 -53
- package/src/session-handler/model-utils.test.ts +415 -0
- package/src/session-handler/model-utils.ts +249 -5
- package/src/session-handler/thread-session-runtime.ts +300 -215
- package/src/session-sleep.test.ts +294 -0
- package/src/session-title-rename.test.ts +9 -48
- package/src/sleep.e2e.test.ts +562 -0
- package/src/startup-time.e2e.test.ts +5 -1
- package/src/store.ts +21 -0
- package/src/system-message.test.ts +86 -6
- package/src/system-message.ts +66 -7
- package/src/task-runner.ts +136 -2
- package/src/task-schedule.test.ts +76 -0
- package/src/task-schedule.ts +95 -0
- package/src/thread-message-queue.e2e.test.ts +10 -202
- package/src/undo-redo.e2e.test.ts +2 -2
- package/src/voice-attachment.ts +29 -1
- package/src/voice-message.e2e.test.ts +10 -10
- package/src/voice.test.ts +63 -29
- package/src/worktree-clone-isolation.e2e.test.ts +213 -0
- package/src/worktree-lifecycle.e2e.test.ts +29 -9
- 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
|
@@ -105,6 +105,32 @@ export function createDeterministicMatchers(): DeterministicMatcher[] {
|
|
|
105
105
|
},
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
const questionSelectDrainMatcher: DeterministicMatcher = {
|
|
109
|
+
id: 'question-select-drain-first',
|
|
110
|
+
priority: 109,
|
|
111
|
+
when: {
|
|
112
|
+
latestUserTextIncludes: 'QUESTION_SELECT_DRAIN_FIRST_MARKER',
|
|
113
|
+
},
|
|
114
|
+
then: {
|
|
115
|
+
parts: [
|
|
116
|
+
{ type: 'stream-start', warnings: [] },
|
|
117
|
+
{ type: 'text-start', id: 'question-select-drain-first' },
|
|
118
|
+
{
|
|
119
|
+
type: 'text-delta',
|
|
120
|
+
id: 'question-select-drain-first',
|
|
121
|
+
delta: 'question-drain-first',
|
|
122
|
+
},
|
|
123
|
+
{ type: 'text-end', id: 'question-select-drain-first' },
|
|
124
|
+
{
|
|
125
|
+
type: 'finish',
|
|
126
|
+
finishReason: 'stop',
|
|
127
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
partDelaysMs: [0, 0, 0, 400, 0],
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
|
|
108
134
|
const slowAbortMatcher: DeterministicMatcher = {
|
|
109
135
|
id: 'slow-abort-marker',
|
|
110
136
|
priority: 100,
|
|
@@ -294,9 +320,7 @@ export function createDeterministicMatchers(): DeterministicMatcher[] {
|
|
|
294
320
|
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
295
321
|
},
|
|
296
322
|
],
|
|
297
|
-
|
|
298
|
-
// must pulse again. This makes typing resume assertions deterministic.
|
|
299
|
-
partDelaysMs: [0, 0, 0, 0, 8_000],
|
|
323
|
+
partDelaysMs: [0, 400, 0, 0, 0],
|
|
300
324
|
},
|
|
301
325
|
}
|
|
302
326
|
|
|
@@ -423,6 +447,7 @@ export function createDeterministicMatchers(): DeterministicMatcher[] {
|
|
|
423
447
|
when: {
|
|
424
448
|
lastMessageRole: 'user',
|
|
425
449
|
latestUserTextIncludes: 'QUESTION_TEXT_ANSWER_MARKER',
|
|
450
|
+
rawPromptRegex: '^(?!.*question-text-answer-call)',
|
|
426
451
|
},
|
|
427
452
|
then: {
|
|
428
453
|
parts: [
|
|
@@ -459,6 +484,7 @@ export function createDeterministicMatchers(): DeterministicMatcher[] {
|
|
|
459
484
|
when: {
|
|
460
485
|
lastMessageRole: 'user',
|
|
461
486
|
latestUserTextIncludes: 'QUESTION_SELECT_QUEUE_MARKER',
|
|
487
|
+
rawPromptRegex: '^(?!.*question-select-queue-call)',
|
|
462
488
|
},
|
|
463
489
|
then: {
|
|
464
490
|
parts: [
|
|
@@ -487,6 +513,50 @@ export function createDeterministicMatchers(): DeterministicMatcher[] {
|
|
|
487
513
|
},
|
|
488
514
|
}
|
|
489
515
|
|
|
516
|
+
// Text then question, with text-end delayed after the tool call.
|
|
517
|
+
// Reproduces OpenCode emitting question.asked before the text part's time.end.
|
|
518
|
+
const questionAfterTextMatcher: DeterministicMatcher = {
|
|
519
|
+
id: 'question-after-text-marker',
|
|
520
|
+
priority: 108,
|
|
521
|
+
when: {
|
|
522
|
+
lastMessageRole: 'user',
|
|
523
|
+
latestUserTextIncludes: 'QUESTION_AFTER_TEXT_MARKER',
|
|
524
|
+
},
|
|
525
|
+
then: {
|
|
526
|
+
parts: [
|
|
527
|
+
{ type: 'stream-start', warnings: [] },
|
|
528
|
+
{ type: 'text-start', id: 'plan-before-question' },
|
|
529
|
+
{
|
|
530
|
+
type: 'text-delta',
|
|
531
|
+
id: 'plan-before-question',
|
|
532
|
+
delta: 'PLAN_TEXT_BEFORE_QUESTION',
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
type: 'tool-call',
|
|
536
|
+
toolCallId: 'question-after-text-call',
|
|
537
|
+
toolName: 'question',
|
|
538
|
+
input: JSON.stringify({
|
|
539
|
+
questions: [{
|
|
540
|
+
question: 'What next?',
|
|
541
|
+
header: 'Next step',
|
|
542
|
+
options: [
|
|
543
|
+
{ label: 'Commit', description: 'Commit these files' },
|
|
544
|
+
{ label: 'Stop', description: 'Leave uncommitted' },
|
|
545
|
+
],
|
|
546
|
+
}],
|
|
547
|
+
}),
|
|
548
|
+
},
|
|
549
|
+
{ type: 'text-end', id: 'plan-before-question' },
|
|
550
|
+
{
|
|
551
|
+
type: 'finish',
|
|
552
|
+
finishReason: 'tool-calls',
|
|
553
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
554
|
+
},
|
|
555
|
+
],
|
|
556
|
+
partDelaysMs: [200, 0, 0, 0, 400, 0],
|
|
557
|
+
},
|
|
558
|
+
}
|
|
559
|
+
|
|
490
560
|
// Model responds with text + tool call, then after tool result the
|
|
491
561
|
// follow-up matcher responds with text. This creates two assistant messages:
|
|
492
562
|
// first with finish="tool-calls" + completed, second with finish="stop".
|
|
@@ -806,6 +876,7 @@ export function createDeterministicMatchers(): DeterministicMatcher[] {
|
|
|
806
876
|
}
|
|
807
877
|
|
|
808
878
|
return [
|
|
879
|
+
questionSelectDrainMatcher,
|
|
809
880
|
slowAbortMatcher,
|
|
810
881
|
slowBusyMatcher,
|
|
811
882
|
typingRepulseMatcher,
|
|
@@ -813,6 +884,7 @@ export function createDeterministicMatchers(): DeterministicMatcher[] {
|
|
|
813
884
|
actionButtonClickFollowupMatcher,
|
|
814
885
|
questionToolMatcher,
|
|
815
886
|
questionSelectQueueMatcher,
|
|
887
|
+
questionAfterTextMatcher,
|
|
816
888
|
permissionTypingMatcher,
|
|
817
889
|
permissionTypingFollowupMatcher,
|
|
818
890
|
externalDirectoryProbeMatcher,
|
|
@@ -857,11 +929,15 @@ export function setupQueueAdvancedSuite({
|
|
|
857
929
|
username,
|
|
858
930
|
restrictExternalDirectories = false,
|
|
859
931
|
projectPermission,
|
|
932
|
+
extraMatchers = [],
|
|
860
933
|
}: {
|
|
861
934
|
channelId: string
|
|
862
935
|
channelName: string
|
|
863
936
|
dirName: string
|
|
864
937
|
username: string
|
|
938
|
+
// Matchers only this suite needs. Keeps narrow markers out of the shared list
|
|
939
|
+
// so they cannot cascade into unrelated e2e files.
|
|
940
|
+
extraMatchers?: DeterministicMatcher[]
|
|
865
941
|
// Opt into the --restrict-directories behaviour so reads outside the project
|
|
866
942
|
// still raise an external_directory permission prompt. Off by default, which
|
|
867
943
|
// matches the shipped default of allowing every directory.
|
|
@@ -924,7 +1000,10 @@ export function setupQueueAdvancedSuite({
|
|
|
924
1000
|
providerNpm,
|
|
925
1001
|
model: 'deterministic-v2',
|
|
926
1002
|
smallModel: 'deterministic-v3',
|
|
927
|
-
settings: {
|
|
1003
|
+
settings: {
|
|
1004
|
+
strict: false,
|
|
1005
|
+
matchers: [...createDeterministicMatchers(), ...extraMatchers],
|
|
1006
|
+
},
|
|
928
1007
|
})
|
|
929
1008
|
fs.writeFileSync(
|
|
930
1009
|
path.join(ctx.directories.projectDirectory, 'opencode.json'),
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
setupQueueAdvancedSuite,
|
|
7
7
|
TEST_USER_ID,
|
|
8
8
|
} from './queue-advanced-e2e-setup.js'
|
|
9
|
+
import { store } from './store.js'
|
|
9
10
|
import {
|
|
10
11
|
waitForFooterMessage,
|
|
11
12
|
waitForBotMessageContaining,
|
|
@@ -52,7 +53,7 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
52
53
|
--- from: assistant (TestBot)
|
|
53
54
|
*using deterministic-provider/deterministic-v2*
|
|
54
55
|
⬥ ok
|
|
55
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
56
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
|
|
56
57
|
`)
|
|
57
58
|
const foundFooter = footerMessages.some((m) => {
|
|
58
59
|
return m.author.id === ctx.discord.botUserId
|
|
@@ -64,6 +65,45 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
64
65
|
8_000,
|
|
65
66
|
)
|
|
66
67
|
|
|
68
|
+
test(
|
|
69
|
+
'skip-footer-mentions omits the thread creator mention',
|
|
70
|
+
async () => {
|
|
71
|
+
store.setState({ footerMentionsEnabled: false })
|
|
72
|
+
try {
|
|
73
|
+
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
74
|
+
content: 'Reply with exactly: footer-without-mention',
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
78
|
+
timeout: 4_000,
|
|
79
|
+
predicate: (t) => {
|
|
80
|
+
return t.name === 'Reply with exactly: footer-without-mention'
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const th = ctx.discord.thread(thread.id)
|
|
85
|
+
await th.waitForBotReply({ timeout: 4_000 })
|
|
86
|
+
await waitForFooterMessage({
|
|
87
|
+
discord: ctx.discord,
|
|
88
|
+
threadId: thread.id,
|
|
89
|
+
timeout: 4_000,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
93
|
+
"--- from: user (queue-advanced-tester)
|
|
94
|
+
Reply with exactly: footer-without-mention
|
|
95
|
+
--- from: assistant (TestBot)
|
|
96
|
+
*using deterministic-provider/deterministic-v2*
|
|
97
|
+
⬥ ok
|
|
98
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
99
|
+
`)
|
|
100
|
+
} finally {
|
|
101
|
+
store.setState({ footerMentionsEnabled: true })
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
8_000,
|
|
105
|
+
)
|
|
106
|
+
|
|
67
107
|
test(
|
|
68
108
|
'footer appears after second message in same session',
|
|
69
109
|
async () => {
|
|
@@ -121,12 +161,12 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
121
161
|
--- from: assistant (TestBot)
|
|
122
162
|
*using deterministic-provider/deterministic-v2*
|
|
123
163
|
⬥ ok
|
|
124
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
164
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
|
|
125
165
|
--- from: user (queue-advanced-tester)
|
|
126
166
|
Reply with exactly: footer-multi-second
|
|
127
167
|
--- from: assistant (TestBot)
|
|
128
168
|
⬥ ok
|
|
129
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
169
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
|
|
130
170
|
`)
|
|
131
171
|
if (footerCount >= 2) {
|
|
132
172
|
expect(footerCount).toBeGreaterThanOrEqual(2)
|
|
@@ -235,7 +275,7 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
235
275
|
--- from: assistant (TestBot)
|
|
236
276
|
*using deterministic-provider/deterministic-v2*
|
|
237
277
|
⬥ ok
|
|
238
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
278
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
|
|
239
279
|
--- from: user (queue-advanced-tester)
|
|
240
280
|
PLUGIN_TIMEOUT_SLEEP_MARKER
|
|
241
281
|
--- from: assistant (TestBot)
|
|
@@ -244,7 +284,7 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
244
284
|
Reply with exactly: interrupt-footer-followup
|
|
245
285
|
--- from: assistant (TestBot)
|
|
246
286
|
⬥ ok
|
|
247
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
287
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
|
|
248
288
|
`)
|
|
249
289
|
expect(followupUserIdx).toBeGreaterThanOrEqual(0)
|
|
250
290
|
expect(okReplyIdx).toBeGreaterThan(followupUserIdx)
|
|
@@ -263,120 +303,8 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
263
303
|
)
|
|
264
304
|
|
|
265
305
|
test(
|
|
266
|
-
'
|
|
267
|
-
async () => {
|
|
268
|
-
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
269
|
-
content: 'Reply with exactly: plugin-timeout-setup',
|
|
270
|
-
})
|
|
271
|
-
|
|
272
|
-
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
273
|
-
timeout: 4_000,
|
|
274
|
-
predicate: (t) => {
|
|
275
|
-
return t.name === 'Reply with exactly: plugin-timeout-setup'
|
|
276
|
-
},
|
|
277
|
-
})
|
|
278
|
-
|
|
279
|
-
const th = ctx.discord.thread(thread.id)
|
|
280
|
-
await th.waitForBotReply({ timeout: 4_000 })
|
|
281
|
-
await waitForBotMessageContaining({
|
|
282
|
-
discord: ctx.discord,
|
|
283
|
-
threadId: thread.id,
|
|
284
|
-
userId: TEST_USER_ID,
|
|
285
|
-
text: '*project',
|
|
286
|
-
timeout: 4_000,
|
|
287
|
-
})
|
|
288
|
-
|
|
289
|
-
await th.user(TEST_USER_ID).sendMessage({
|
|
290
|
-
content: 'PLUGIN_TIMEOUT_SLEEP_MARKER',
|
|
291
|
-
})
|
|
292
|
-
|
|
293
|
-
await waitForBotMessageContaining({
|
|
294
|
-
discord: ctx.discord,
|
|
295
|
-
threadId: thread.id,
|
|
296
|
-
userId: TEST_USER_ID,
|
|
297
|
-
text: 'starting sleep 100',
|
|
298
|
-
afterUserMessageIncludes: 'PLUGIN_TIMEOUT_SLEEP_MARKER',
|
|
299
|
-
timeout: 4_000,
|
|
300
|
-
})
|
|
301
|
-
|
|
302
|
-
await th.user(TEST_USER_ID).sendMessage({
|
|
303
|
-
content: 'Reply with exactly: plugin-timeout-after',
|
|
304
|
-
})
|
|
305
|
-
|
|
306
|
-
const messages = await waitForBotMessageContaining({
|
|
307
|
-
discord: ctx.discord,
|
|
308
|
-
threadId: thread.id,
|
|
309
|
-
userId: TEST_USER_ID,
|
|
310
|
-
text: 'ok',
|
|
311
|
-
afterUserMessageIncludes: 'plugin-timeout-after',
|
|
312
|
-
timeout: 12_000,
|
|
313
|
-
})
|
|
314
|
-
|
|
315
|
-
const messagesWithFooter = await waitForFooterMessage({
|
|
316
|
-
discord: ctx.discord,
|
|
317
|
-
threadId: thread.id,
|
|
318
|
-
timeout: 12_000,
|
|
319
|
-
afterMessageIncludes: 'plugin-timeout-after',
|
|
320
|
-
afterAuthorId: TEST_USER_ID,
|
|
321
|
-
})
|
|
322
|
-
|
|
323
|
-
const afterIndex = messagesWithFooter.findIndex((message) => {
|
|
324
|
-
return (
|
|
325
|
-
message.author.id === TEST_USER_ID
|
|
326
|
-
&& message.content.includes('plugin-timeout-after')
|
|
327
|
-
)
|
|
328
|
-
})
|
|
329
|
-
expect(await th.text()).toMatchInlineSnapshot(`
|
|
330
|
-
"--- from: user (queue-advanced-tester)
|
|
331
|
-
Reply with exactly: plugin-timeout-setup
|
|
332
|
-
--- from: assistant (TestBot)
|
|
333
|
-
*using deterministic-provider/deterministic-v2*
|
|
334
|
-
⬥ ok
|
|
335
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
336
|
-
--- from: user (queue-advanced-tester)
|
|
337
|
-
PLUGIN_TIMEOUT_SLEEP_MARKER
|
|
338
|
-
--- from: assistant (TestBot)
|
|
339
|
-
⬥ starting sleep 100
|
|
340
|
-
--- from: user (queue-advanced-tester)
|
|
341
|
-
Reply with exactly: plugin-timeout-after
|
|
342
|
-
--- from: assistant (TestBot)
|
|
343
|
-
⬥ ok
|
|
344
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
345
|
-
`)
|
|
346
|
-
expect(afterIndex).toBeGreaterThanOrEqual(0)
|
|
347
|
-
|
|
348
|
-
const okReplyIndex = messagesWithFooter.findIndex((message, index) => {
|
|
349
|
-
if (index <= afterIndex) {
|
|
350
|
-
return false
|
|
351
|
-
}
|
|
352
|
-
return message.author.id === ctx.discord.botUserId && message.content.includes('ok')
|
|
353
|
-
})
|
|
354
|
-
expect(okReplyIndex).toBeGreaterThan(afterIndex)
|
|
355
|
-
|
|
356
|
-
const footerBeforeReply = messagesWithFooter.some((message, index) => {
|
|
357
|
-
if (index <= afterIndex || index >= okReplyIndex) {
|
|
358
|
-
return false
|
|
359
|
-
}
|
|
360
|
-
if (message.author.id !== ctx.discord.botUserId) {
|
|
361
|
-
return false
|
|
362
|
-
}
|
|
363
|
-
return message.content.startsWith('*') && message.content.includes('⋅')
|
|
364
|
-
})
|
|
365
|
-
expect(footerBeforeReply).toBe(false)
|
|
366
|
-
},
|
|
367
|
-
15_000,
|
|
368
|
-
)
|
|
369
|
-
|
|
370
|
-
test(
|
|
371
|
-
'tool-call assistant message gets footer when it completes normally',
|
|
306
|
+
'tool-call turn emits a footer after the final follow-up',
|
|
372
307
|
async () => {
|
|
373
|
-
// Reproduces the bug: model responds with text + tool call,
|
|
374
|
-
// finish="tool-calls", message gets completed timestamp. Then the tool
|
|
375
|
-
// result triggers a follow-up text response in a second assistant message.
|
|
376
|
-
// The second message gets a footer, but the first (tool-call) message
|
|
377
|
-
// should ALSO get a footer since it completed normally.
|
|
378
|
-
// This matches the real-world scenario where an agent calls a bash tool
|
|
379
|
-
// (e.g. `kimaki send`) and then follows up with a summary text.
|
|
380
308
|
const existingThreadIds = new Set(
|
|
381
309
|
(await ctx.discord.channel(TEXT_CHANNEL_ID).getThreads()).map((thread) => {
|
|
382
310
|
return thread.id
|
|
@@ -413,27 +341,6 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
413
341
|
timeout: 4_000,
|
|
414
342
|
})
|
|
415
343
|
|
|
416
|
-
// Poll until both footers have arrived — the first footer (after the
|
|
417
|
-
// tool-call step) and the second (after the text follow-up) are emitted
|
|
418
|
-
// by sequential handleNaturalAssistantCompletion calls but the second
|
|
419
|
-
// may not have hit the Discord thread by the time we first check.
|
|
420
|
-
const deadline = Date.now() + 4_000
|
|
421
|
-
let footerCount = 0
|
|
422
|
-
while (Date.now() < deadline) {
|
|
423
|
-
const msgs = await th.getMessages()
|
|
424
|
-
footerCount = msgs.filter((m) => {
|
|
425
|
-
return m.author.id === ctx.discord.botUserId
|
|
426
|
-
&& m.content.startsWith('*')
|
|
427
|
-
&& m.content.includes('⋅')
|
|
428
|
-
}).length
|
|
429
|
-
if (footerCount >= 2) {
|
|
430
|
-
break
|
|
431
|
-
}
|
|
432
|
-
await new Promise((resolve) => {
|
|
433
|
-
setTimeout(resolve, 100)
|
|
434
|
-
})
|
|
435
|
-
}
|
|
436
|
-
|
|
437
344
|
expect(await th.text()).toMatchInlineSnapshot(`
|
|
438
345
|
"--- from: user (queue-advanced-tester)
|
|
439
346
|
TOOL_CALL_FOOTER_MARKER
|
|
@@ -442,164 +349,10 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
442
349
|
⬥ running tool
|
|
443
350
|
┣ bash _echo tool-call-footer-test_
|
|
444
351
|
⬥ tool call completed
|
|
445
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
352
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
|
|
446
353
|
`)
|
|
447
|
-
|
|
448
|
-
// Only ONE footer at the end — the tool-call step's footer is NOT
|
|
449
|
-
// emitted mid-turn. The final text follow-up gets the footer.
|
|
450
|
-
expect(footerCount).toBe(1)
|
|
451
354
|
},
|
|
452
355
|
10_000,
|
|
453
356
|
)
|
|
454
357
|
|
|
455
|
-
test(
|
|
456
|
-
'multi-step tool chain should only have one footer at the end',
|
|
457
|
-
async () => {
|
|
458
|
-
// Model does 3 sequential tool calls (each a separate assistant message
|
|
459
|
-
// with finish="tool-calls") then a final text response. Only the final
|
|
460
|
-
// text response should get a footer — intermediate tool-call steps
|
|
461
|
-
// should NOT get footers since they're mid-turn work.
|
|
462
|
-
const existingThreadIds = new Set(
|
|
463
|
-
(await ctx.discord.channel(TEXT_CHANNEL_ID).getThreads()).map((thread) => {
|
|
464
|
-
return thread.id
|
|
465
|
-
}),
|
|
466
|
-
)
|
|
467
|
-
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
468
|
-
content: 'MULTI_TOOL_FOOTER_MARKER',
|
|
469
|
-
})
|
|
470
|
-
|
|
471
|
-
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
472
|
-
timeout: 6_000,
|
|
473
|
-
predicate: (t) => {
|
|
474
|
-
return !existingThreadIds.has(t.id)
|
|
475
|
-
},
|
|
476
|
-
})
|
|
477
|
-
|
|
478
|
-
const th = ctx.discord.thread(thread.id)
|
|
479
|
-
|
|
480
|
-
// Wait for the final text response after all 3 tool steps
|
|
481
|
-
await waitForBotMessageContaining({
|
|
482
|
-
discord: ctx.discord,
|
|
483
|
-
threadId: thread.id,
|
|
484
|
-
userId: TEST_USER_ID,
|
|
485
|
-
text: 'all done, fixed 3 files',
|
|
486
|
-
timeout: 6_000,
|
|
487
|
-
})
|
|
488
|
-
|
|
489
|
-
// Wait for the footer after the final response
|
|
490
|
-
await waitForFooterMessage({
|
|
491
|
-
discord: ctx.discord,
|
|
492
|
-
threadId: thread.id,
|
|
493
|
-
timeout: 6_000,
|
|
494
|
-
})
|
|
495
|
-
|
|
496
|
-
// Give any spurious extra footers time to arrive
|
|
497
|
-
await new Promise((resolve) => {
|
|
498
|
-
setTimeout(resolve, 500)
|
|
499
|
-
})
|
|
500
|
-
|
|
501
|
-
const messages = await th.getMessages()
|
|
502
|
-
const footerCount = messages.filter((m) => {
|
|
503
|
-
return m.author.id === ctx.discord.botUserId
|
|
504
|
-
&& m.content.startsWith('*')
|
|
505
|
-
&& m.content.includes('⋅')
|
|
506
|
-
}).length
|
|
507
|
-
|
|
508
|
-
expect(await th.text()).toMatchInlineSnapshot(`
|
|
509
|
-
"--- from: user (queue-advanced-tester)
|
|
510
|
-
MULTI_TOOL_FOOTER_MARKER
|
|
511
|
-
--- from: assistant (TestBot)
|
|
512
|
-
*using deterministic-provider/deterministic-v2*
|
|
513
|
-
⬥ investigating the issue
|
|
514
|
-
┣ bash _echo search-done_
|
|
515
|
-
┣ bash _echo read-done_
|
|
516
|
-
┣ bash _echo fix-done_
|
|
517
|
-
⬥ all done, fixed 3 files
|
|
518
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
519
|
-
`)
|
|
520
|
-
|
|
521
|
-
// Only ONE footer should appear — after the final text response.
|
|
522
|
-
// Intermediate tool-call steps should NOT get footers.
|
|
523
|
-
expect(footerCount).toBe(1)
|
|
524
|
-
},
|
|
525
|
-
10_000,
|
|
526
|
-
)
|
|
527
|
-
|
|
528
|
-
test(
|
|
529
|
-
'3 sequential tool-call steps produce exactly 1 footer, not 3',
|
|
530
|
-
async () => {
|
|
531
|
-
// This is the most obvious reproduction of the multi-footer bug:
|
|
532
|
-
// the model runs 3 sequential tool-call steps (each a SEPARATE
|
|
533
|
-
// assistant message with finish="tool-calls"), then a final text.
|
|
534
|
-
// With a naive fix that treats tool-calls as natural completions,
|
|
535
|
-
// you'd see 4 footers (one per assistant message). Only the final
|
|
536
|
-
// text response should produce a footer.
|
|
537
|
-
const existingThreadIds = new Set(
|
|
538
|
-
(await ctx.discord.channel(TEXT_CHANNEL_ID).getThreads()).map((thread) => {
|
|
539
|
-
return thread.id
|
|
540
|
-
}),
|
|
541
|
-
)
|
|
542
|
-
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
543
|
-
content: 'MULTI_STEP_CHAIN_MARKER',
|
|
544
|
-
})
|
|
545
|
-
|
|
546
|
-
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
547
|
-
timeout: 6_000,
|
|
548
|
-
predicate: (t) => {
|
|
549
|
-
return !existingThreadIds.has(t.id)
|
|
550
|
-
},
|
|
551
|
-
})
|
|
552
|
-
|
|
553
|
-
const th = ctx.discord.thread(thread.id)
|
|
554
|
-
|
|
555
|
-
// Wait for the final text after all 3 sequential tool steps
|
|
556
|
-
await waitForBotMessageContaining({
|
|
557
|
-
discord: ctx.discord,
|
|
558
|
-
threadId: thread.id,
|
|
559
|
-
userId: TEST_USER_ID,
|
|
560
|
-
text: 'chain complete: all 3 steps done',
|
|
561
|
-
timeout: 10_000,
|
|
562
|
-
})
|
|
563
|
-
|
|
564
|
-
// Wait for footer
|
|
565
|
-
await waitForFooterMessage({
|
|
566
|
-
discord: ctx.discord,
|
|
567
|
-
threadId: thread.id,
|
|
568
|
-
timeout: 6_000,
|
|
569
|
-
})
|
|
570
|
-
|
|
571
|
-
// Give any spurious extra footers time to arrive
|
|
572
|
-
await new Promise((resolve) => {
|
|
573
|
-
setTimeout(resolve, 500)
|
|
574
|
-
})
|
|
575
|
-
|
|
576
|
-
const messages = await th.getMessages()
|
|
577
|
-
const footerCount = messages.filter((m) => {
|
|
578
|
-
return m.author.id === ctx.discord.botUserId
|
|
579
|
-
&& m.content.startsWith('*')
|
|
580
|
-
&& m.content.includes('⋅')
|
|
581
|
-
}).length
|
|
582
|
-
|
|
583
|
-
expect(await th.text()).toMatchInlineSnapshot(`
|
|
584
|
-
"--- from: user (queue-advanced-tester)
|
|
585
|
-
MULTI_STEP_CHAIN_MARKER
|
|
586
|
-
--- from: assistant (TestBot)
|
|
587
|
-
*using deterministic-provider/deterministic-v2*
|
|
588
|
-
⬥ chain step 1: reading config
|
|
589
|
-
┣ bash _echo chain-step-1-output_
|
|
590
|
-
⬥ chain step 2: analyzing results
|
|
591
|
-
┣ bash _echo chain-step-2-output_
|
|
592
|
-
⬥ chain step 3: applying fix
|
|
593
|
-
┣ bash _echo chain-step-3-output_
|
|
594
|
-
⬥ chain complete: all 3 steps done
|
|
595
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
596
|
-
`)
|
|
597
|
-
|
|
598
|
-
// The critical assertion: only 1 footer at the very end.
|
|
599
|
-
// With the naive "allow tool-calls as natural completion" fix,
|
|
600
|
-
// this would be 4 (one per assistant message). We want 1.
|
|
601
|
-
expect(footerCount).toBe(1)
|
|
602
|
-
},
|
|
603
|
-
15_000,
|
|
604
|
-
)
|
|
605
358
|
})
|
|
@@ -330,7 +330,7 @@ describe('queue advanced: /model with interrupt recovery', () => {
|
|
|
330
330
|
--- from: assistant (TestBot)
|
|
331
331
|
*using deterministic-provider/deterministic-v2*
|
|
332
332
|
⬥ ok
|
|
333
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
333
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
|
|
334
334
|
Model set for this session:
|
|
335
335
|
**Deterministic Provider** / **deterministic-v3**
|
|
336
336
|
\`deterministic-provider/deterministic-v3\`
|
|
@@ -345,7 +345,7 @@ describe('queue advanced: /model with interrupt recovery', () => {
|
|
|
345
345
|
Reply with exactly: model-switcher-followup
|
|
346
346
|
--- from: assistant (TestBot)
|
|
347
347
|
⬥ ok
|
|
348
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v3*"
|
|
348
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v3* <@200000000000000991>"
|
|
349
349
|
`)
|
|
350
350
|
|
|
351
351
|
expect(footer).toBeDefined()
|
|
@@ -51,7 +51,7 @@ describe('queue advanced: typing around permissions', () => {
|
|
|
51
51
|
})
|
|
52
52
|
|
|
53
53
|
test(
|
|
54
|
-
'permission prompt pauses typing until user click, then typing resumes
|
|
54
|
+
'permission prompt pauses typing until user click, then typing resumes',
|
|
55
55
|
async () => {
|
|
56
56
|
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
57
57
|
content: 'PERMISSION_TYPING_MARKER',
|
|
@@ -83,7 +83,7 @@ describe('queue advanced: typing around permissions', () => {
|
|
|
83
83
|
|
|
84
84
|
th.clearTypingEvents()
|
|
85
85
|
|
|
86
|
-
await th.waitForTypingEvent({ timeout:
|
|
86
|
+
await th.waitForTypingEvent({ timeout: 700 }).then(
|
|
87
87
|
() => {
|
|
88
88
|
throw new Error('Typing should stay paused while permission UI is pending')
|
|
89
89
|
},
|
|
@@ -102,7 +102,7 @@ describe('queue advanced: typing around permissions', () => {
|
|
|
102
102
|
timeout: 4_000,
|
|
103
103
|
})
|
|
104
104
|
|
|
105
|
-
const resumedTyping = await th.waitForTypingEvent({ timeout:
|
|
105
|
+
const resumedTyping = await th.waitForTypingEvent({ timeout: 2_000 })
|
|
106
106
|
expect(resumedTyping).toBeDefined()
|
|
107
107
|
|
|
108
108
|
await waitForBotMessageContaining({
|
|
@@ -110,13 +110,13 @@ describe('queue advanced: typing around permissions', () => {
|
|
|
110
110
|
threadId: thread.id,
|
|
111
111
|
userId: TEST_USER_ID,
|
|
112
112
|
text: 'permission-flow-done',
|
|
113
|
-
timeout:
|
|
113
|
+
timeout: 4_000,
|
|
114
114
|
})
|
|
115
115
|
|
|
116
116
|
await waitForFooterMessage({
|
|
117
117
|
discord: ctx.discord,
|
|
118
118
|
threadId: thread.id,
|
|
119
|
-
timeout:
|
|
119
|
+
timeout: 4_000,
|
|
120
120
|
afterMessageIncludes: 'permission-flow-done',
|
|
121
121
|
afterAuthorId: ctx.discord.botUserId,
|
|
122
122
|
})
|
|
@@ -135,7 +135,7 @@ describe('queue advanced: typing around permissions', () => {
|
|
|
135
135
|
✅ Permission **accepted**
|
|
136
136
|
[user clicks button]
|
|
137
137
|
⬥ permission-flow-done
|
|
138
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
138
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
|
|
139
139
|
`)
|
|
140
140
|
|
|
141
141
|
const timeline = await th.text({
|
|
@@ -150,12 +150,10 @@ describe('queue advanced: typing around permissions', () => {
|
|
|
150
150
|
expect(footerPosition).toBeGreaterThan(donePosition)
|
|
151
151
|
|
|
152
152
|
const afterClick = timeline.slice(clickPosition, donePosition)
|
|
153
|
-
const afterDone = timeline.slice(donePosition, footerPosition)
|
|
154
153
|
expect(afterClick).toContain('[bot typing]')
|
|
155
|
-
expect(afterDone).toContain('[bot typing]')
|
|
156
154
|
expect(timeline.slice(footerPosition)).not.toContain('[bot typing]')
|
|
157
155
|
},
|
|
158
|
-
|
|
156
|
+
12_000,
|
|
159
157
|
)
|
|
160
158
|
|
|
161
159
|
test(
|