kimaki 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-model.e2e.test.js +250 -164
- package/dist/anthropic-account-identity.js +9 -1
- package/dist/anthropic-auth-plugin.js +10 -13
- package/dist/anthropic-auth-plugin.test.js +17 -0
- package/dist/anthropic-auth-state.js +1 -0
- package/dist/bash-tool-schema-plugin.js +32 -0
- package/dist/bash-tool-schema-plugin.test.js +49 -0
- package/dist/bin.js +73 -19
- package/dist/cli-commands/maintenance.js +46 -7
- package/dist/cli-commands/project.js +11 -27
- package/dist/cli-commands/send.js +30 -1
- package/dist/cli-commands/session.js +322 -90
- package/dist/cli-commands/task.js +34 -15
- package/dist/cli-parsing.test.js +83 -4
- package/dist/cli-runner.js +44 -8
- package/dist/cli-runner.test.js +27 -0
- package/dist/cli-send-thread.e2e.test.js +9 -9
- package/dist/cli.js +32 -0
- package/dist/commands/abort.js +5 -1
- package/dist/commands/action-buttons.js +2 -1
- package/dist/commands/add-project.js +4 -7
- package/dist/commands/agent.js +164 -32
- package/dist/commands/ask-question.js +23 -33
- package/dist/commands/ask-question.test.js +43 -1
- package/dist/commands/btw.js +28 -6
- package/dist/commands/file-upload.js +2 -1
- package/dist/commands/fork-subagent.js +2 -4
- package/dist/commands/fork.js +4 -4
- package/dist/commands/last-sessions.js +17 -10
- package/dist/commands/login.js +6 -0
- package/dist/commands/merge-worktree.js +47 -14
- package/dist/commands/model-variant.js +8 -1
- package/dist/commands/model.js +16 -9
- package/dist/commands/multioauth.js +1 -0
- package/dist/commands/new-worktree.js +170 -102
- package/dist/commands/queue.js +3 -2
- package/dist/commands/resume.js +4 -4
- package/dist/commands/session.js +78 -63
- package/dist/commands/user-command.js +6 -8
- package/dist/commands/worktrees.js +5 -13
- package/dist/config.js +16 -1
- package/dist/cpu-profiler.js +211 -0
- package/dist/cpu-profiler.test.js +160 -0
- package/dist/database.js +152 -3
- package/dist/db.js +33 -0
- package/dist/db.test.js +267 -1
- package/dist/default-channel-provisioning.test.js +148 -0
- package/dist/discord-bot.js +517 -488
- package/dist/discord-command-registration.js +25 -10
- package/dist/discord-command-registration.test.js +17 -0
- package/dist/discord-utils.js +97 -5
- package/dist/discord-utils.test.js +151 -1
- package/dist/errors.js +10 -0
- package/dist/exec-async.js +17 -5
- package/dist/external-directory-permissions.e2e.test.js +11 -7
- package/dist/external-directory-project-deny.e2e.test.js +5 -3
- package/dist/external-opencode-sync.js +94 -17
- package/dist/external-opencode-sync.test.js +206 -0
- package/dist/extract-sdk-error-message.test.js +13 -0
- package/dist/file-edit-log.js +197 -0
- package/dist/file-edit-log.test.js +258 -0
- package/dist/format-tables.js +52 -0
- package/dist/format-tables.test.js +93 -0
- package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
- package/dist/gateway-proxy.e2e.test.js +18 -18
- package/dist/git-worktree-core.js +182 -17
- package/dist/interaction-handler.js +416 -398
- package/dist/ipc-tools-plugin.js +68 -3
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
- package/dist/kimaki-opencode-plugin.js +9 -3
- package/dist/kimaki-workspace-adaptor.js +33 -10
- package/dist/logger.js +11 -1
- package/dist/markdown.js +3 -1
- package/dist/markdown.test.js +11 -3
- package/dist/message-formatting.js +181 -51
- package/dist/message-formatting.test.js +529 -22
- package/dist/message-preprocessing.js +105 -1
- package/dist/oauth-rotation-shared.js +10 -0
- package/dist/openai-auth-plugin.js +1 -0
- package/dist/openai-auth-state.js +1 -0
- package/dist/opencode-command.js +28 -0
- package/dist/opencode-command.test.js +109 -1
- package/dist/opencode-interrupt-plugin.js +4 -0
- package/dist/opencode-interrupt-plugin.test.js +24 -0
- package/dist/opencode.js +82 -15
- package/dist/project-add.test.js +62 -0
- package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
- package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
- package/dist/question-text-before-dropdown.e2e.test.js +52 -0
- package/dist/queue-advanced-abort.e2e.test.js +8 -10
- package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
- package/dist/queue-advanced-e2e-setup.js +78 -5
- package/dist/queue-advanced-footer.e2e.test.js +60 -269
- package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
- package/dist/queue-advanced-permissions-typing.e2e.test.js +15 -15
- package/dist/queue-advanced-question.e2e.test.js +32 -27
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
- package/dist/queue-advanced-typing.e2e.test.js +7 -9
- package/dist/queue-delete-message.e2e.test.js +3 -3
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
- package/dist/queue-interrupt-drain.e2e.test.js +122 -12
- package/dist/queue-question-select-drain.e2e.test.js +34 -132
- package/dist/runtime-lifecycle.e2e.test.js +21 -51
- package/dist/schema.js +35 -1
- package/dist/session-handler/event-stream-state.js +391 -50
- package/dist/session-handler/event-stream-state.test.js +736 -8
- package/dist/session-handler/model-utils.js +172 -5
- package/dist/session-handler/model-utils.test.js +445 -0
- package/dist/session-handler/opencode-session-event-log.js +30 -1
- package/dist/session-handler/thread-ingress.test.js +41 -0
- package/dist/session-handler/thread-runtime-state.js +2 -0
- package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
- package/dist/session-handler/thread-session-runtime.js +574 -299
- package/dist/session-search.js +123 -4
- package/dist/session-search.test.js +248 -1
- package/dist/session-sleep.test.js +224 -0
- package/dist/session-title-rename.test.js +37 -39
- package/dist/sleep.e2e.test.js +494 -0
- package/dist/startup-time.e2e.test.js +2 -1
- package/dist/store.js +3 -0
- package/dist/system-message.js +102 -16
- package/dist/system-message.test.js +192 -15
- package/dist/task-runner.js +96 -5
- package/dist/task-schedule.js +87 -1
- package/dist/task-schedule.test.js +149 -1
- package/dist/test-utils.js +17 -9
- package/dist/thinking-utils.js +10 -0
- package/dist/thinking-utils.test.js +35 -0
- package/dist/thread-message-queue.e2e.test.js +65 -213
- package/dist/undo-redo.e2e.test.js +17 -9
- package/dist/voice-attachment.js +23 -1
- package/dist/voice-handler.js +21 -4
- package/dist/voice-message.e2e.test.js +144 -30
- package/dist/voice-routing-input.e2e.test.js +195 -0
- package/dist/voice-routing-question.e2e.test.js +125 -0
- package/dist/voice.js +28 -8
- package/dist/voice.test.js +96 -30
- package/dist/worktree-clone-isolation.e2e.test.js +188 -0
- package/dist/worktree-lifecycle.e2e.test.js +55 -36
- package/dist/worktrees.js +226 -443
- package/dist/worktrees.test.js +435 -58
- package/dist/xai-auth-plugin.js +1 -0
- package/dist/xai-auth-state.js +1 -0
- package/package.json +8 -6
- package/skills/bump-holocron-consumers/SKILL.md +137 -0
- package/skills/critique/SKILL.md +4 -4
- package/skills/errore/SKILL.md +60 -218
- package/skills/goke/SKILL.md +150 -10
- package/skills/holocron/SKILL.md +154 -17
- package/skills/npm-package/SKILL.md +55 -35
- package/skills/profano/SKILL.md +17 -2
- package/skills/spiceflow/SKILL.md +39 -3
- package/skills/strada/SKILL.md +39 -0
- package/skills/zele/SKILL.md +19 -0
- package/src/agent-model.e2e.test.ts +287 -186
- package/src/anthropic-account-identity.ts +20 -1
- package/src/anthropic-auth-plugin.test.ts +19 -0
- package/src/anthropic-auth-plugin.ts +12 -16
- package/src/anthropic-auth-state.ts +1 -0
- package/src/bash-tool-schema-plugin.test.ts +55 -0
- package/src/bash-tool-schema-plugin.ts +46 -0
- package/src/bin.ts +78 -26
- package/src/cli-commands/maintenance.ts +59 -13
- package/src/cli-commands/project.ts +14 -35
- package/src/cli-commands/send.ts +31 -1
- package/src/cli-commands/session.ts +374 -114
- package/src/cli-commands/task.ts +34 -15
- package/src/cli-parsing.test.ts +103 -4
- package/src/cli-runner.test.ts +49 -0
- package/src/cli-runner.ts +44 -7
- package/src/cli-send-thread.e2e.test.ts +9 -11
- package/src/cli.ts +53 -0
- package/src/commands/abort.ts +6 -1
- package/src/commands/action-buttons.ts +2 -1
- package/src/commands/add-project.ts +4 -8
- package/src/commands/agent.ts +205 -29
- package/src/commands/ask-question.test.ts +48 -1
- package/src/commands/ask-question.ts +23 -33
- package/src/commands/btw.ts +40 -5
- package/src/commands/file-upload.ts +2 -1
- package/src/commands/fork-subagent.ts +2 -3
- package/src/commands/fork.ts +4 -3
- package/src/commands/last-sessions.ts +10 -4
- package/src/commands/login.ts +6 -0
- package/src/commands/merge-worktree.ts +54 -16
- package/src/commands/model-variant.ts +9 -1
- package/src/commands/model.ts +20 -9
- package/src/commands/multioauth.ts +1 -0
- package/src/commands/new-worktree.ts +138 -55
- package/src/commands/queue.ts +3 -2
- package/src/commands/resume.ts +4 -3
- package/src/commands/session.ts +94 -70
- package/src/commands/user-command.ts +6 -6
- package/src/commands/worktrees.ts +5 -12
- package/src/config.ts +18 -1
- package/src/cpu-profiler.test.ts +183 -0
- package/src/cpu-profiler.ts +293 -0
- package/src/database.ts +235 -3
- package/src/db.test.ts +293 -0
- package/src/db.ts +38 -0
- package/src/default-channel-provisioning.test.ts +155 -0
- package/src/discord-bot.ts +78 -36
- package/src/discord-command-registration.test.ts +20 -0
- package/src/discord-command-registration.ts +42 -12
- package/src/discord-utils.test.ts +176 -0
- package/src/discord-utils.ts +152 -7
- package/src/errors.ts +12 -0
- package/src/exec-async.ts +20 -11
- package/src/external-directory-permissions.e2e.test.ts +11 -7
- package/src/external-directory-project-deny.e2e.test.ts +5 -3
- package/src/external-opencode-sync.test.ts +248 -0
- package/src/external-opencode-sync.ts +122 -19
- package/src/extract-sdk-error-message.test.ts +18 -0
- package/src/file-edit-log.test.ts +287 -0
- package/src/file-edit-log.ts +283 -0
- package/src/format-tables.test.ts +101 -0
- package/src/format-tables.ts +75 -0
- package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
- package/src/gateway-proxy.e2e.test.ts +18 -17
- package/src/git-worktree-core.ts +247 -20
- package/src/interaction-handler.ts +36 -13
- package/src/ipc-tools-plugin.ts +82 -5
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
- package/src/kimaki-opencode-plugin.ts +9 -3
- package/src/kimaki-workspace-adaptor.ts +40 -11
- package/src/logger.ts +14 -4
- package/src/markdown.test.ts +13 -3
- package/src/markdown.ts +4 -1
- package/src/message-formatting.test.ts +672 -25
- package/src/message-formatting.ts +242 -53
- package/src/message-preprocessing.ts +116 -1
- package/src/oauth-rotation-shared.ts +10 -0
- package/src/openai-auth-plugin.ts +1 -0
- package/src/openai-auth-state.ts +1 -0
- package/src/opencode-command.test.ts +140 -0
- package/src/opencode-command.ts +38 -0
- package/src/opencode-interrupt-plugin.test.ts +32 -0
- package/src/opencode-interrupt-plugin.ts +3 -0
- package/src/opencode.ts +115 -15
- package/src/project-add.test.ts +80 -0
- package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
- package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
- package/src/question-text-before-dropdown.e2e.test.ts +67 -0
- package/src/queue-advanced-abort.e2e.test.ts +8 -9
- package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
- package/src/queue-advanced-e2e-setup.ts +83 -4
- package/src/queue-advanced-footer.e2e.test.ts +67 -315
- package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
- package/src/queue-advanced-permissions-typing.e2e.test.ts +15 -15
- package/src/queue-advanced-question.e2e.test.ts +36 -30
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
- package/src/queue-advanced-typing.e2e.test.ts +8 -8
- package/src/queue-delete-message.e2e.test.ts +3 -3
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
- package/src/queue-interrupt-drain.e2e.test.ts +133 -12
- package/src/queue-question-select-drain.e2e.test.ts +36 -161
- package/src/runtime-lifecycle.e2e.test.ts +21 -54
- package/src/schema.sql +15 -2
- package/src/schema.ts +37 -1
- package/src/session-handler/event-stream-state.test.ts +790 -7
- package/src/session-handler/event-stream-state.ts +568 -53
- package/src/session-handler/model-utils.test.ts +537 -0
- package/src/session-handler/model-utils.ts +281 -5
- package/src/session-handler/opencode-session-event-log.ts +31 -1
- package/src/session-handler/thread-ingress.test.ts +54 -0
- package/src/session-handler/thread-runtime-state.ts +3 -0
- package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
- package/src/session-handler/thread-session-runtime.ts +712 -358
- package/src/session-search.test.ts +281 -0
- package/src/session-search.ts +241 -7
- package/src/session-sleep.test.ts +294 -0
- package/src/session-title-rename.test.ts +54 -48
- package/src/sleep.e2e.test.ts +565 -0
- package/src/startup-time.e2e.test.ts +5 -1
- package/src/store.ts +22 -0
- package/src/system-message.test.ts +226 -15
- package/src/system-message.ts +130 -16
- package/src/task-runner.ts +138 -3
- package/src/task-schedule.test.ts +170 -0
- package/src/task-schedule.ts +141 -1
- package/src/test-utils.ts +21 -10
- package/src/thinking-utils.test.ts +46 -0
- package/src/thinking-utils.ts +21 -0
- package/src/thread-message-queue.e2e.test.ts +65 -245
- package/src/undo-redo.e2e.test.ts +17 -9
- package/src/voice-attachment.ts +29 -1
- package/src/voice-handler.ts +23 -4
- package/src/voice-message.e2e.test.ts +149 -29
- package/src/voice-routing-input.e2e.test.ts +203 -0
- package/src/voice-routing-question.e2e.test.ts +131 -0
- package/src/voice.test.ts +107 -29
- package/src/voice.ts +34 -6
- package/src/worktree-clone-isolation.e2e.test.ts +213 -0
- package/src/worktree-lifecycle.e2e.test.ts +55 -35
- package/src/worktrees.test.ts +505 -64
- package/src/worktrees.ts +257 -603
- package/src/xai-auth-plugin.ts +1 -0
- package/src/xai-auth-state.ts +1 -0
- package/dist/message-finish-field.e2e.test.js +0 -169
- package/dist/subagent-rate-limit-plugin.js +0 -184
- package/src/message-finish-field.e2e.test.ts +0 -196
- package/src/subagent-rate-limit-plugin.ts +0 -228
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Voice routes must leave the source question answerable without aborting its run.
|
|
2
|
+
import { afterEach, describe, expect, test } from 'vitest'
|
|
3
|
+
import { setupQueueAdvancedSuite, TEST_USER_ID } from './queue-advanced-e2e-setup.js'
|
|
4
|
+
import { waitForBotMessageContaining, waitForFooterMessage } from './test-utils.js'
|
|
5
|
+
import { store } from './store.js'
|
|
6
|
+
|
|
7
|
+
const TEXT_CHANNEL_ID = '200000000000001083'
|
|
8
|
+
|
|
9
|
+
describe('voice routing preserves source questions', () => {
|
|
10
|
+
const ctx = setupQueueAdvancedSuite({
|
|
11
|
+
channelId: TEXT_CHANNEL_ID,
|
|
12
|
+
channelName: 'voice-routing-question',
|
|
13
|
+
dirName: 'voice-routing-question',
|
|
14
|
+
username: 'voice-question-tester',
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
store.setState({ test: { deterministicTranscription: null } })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// Separate registrations permit distinct inline snapshots with Vitest 3.
|
|
22
|
+
for (const sessionAction of ['btw', 'new-session'] as const) {
|
|
23
|
+
test(`${sessionAction} preserves the source dropdown`, async () => {
|
|
24
|
+
const marker = `QUESTION_SELECT_QUEUE_MARKER voice ${sessionAction}`
|
|
25
|
+
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({ content: marker })
|
|
26
|
+
const source = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
27
|
+
timeout: 4_000,
|
|
28
|
+
predicate: (thread) => thread.name === marker,
|
|
29
|
+
})
|
|
30
|
+
const th = ctx.discord.thread(source.id)
|
|
31
|
+
const messages = await waitForBotMessageContaining({
|
|
32
|
+
discord: ctx.discord,
|
|
33
|
+
threadId: source.id,
|
|
34
|
+
text: 'How to proceed?',
|
|
35
|
+
timeout: 4_000,
|
|
36
|
+
})
|
|
37
|
+
const question = messages.find((message) => message.content.includes('How to proceed?'))
|
|
38
|
+
if (!question) throw new Error('Expected question message')
|
|
39
|
+
const customId = JSON.stringify(question.components).match(
|
|
40
|
+
/"custom_id":"(ask_question:[^"]+)"/,
|
|
41
|
+
)?.[1]
|
|
42
|
+
if (!customId) throw new Error('Expected question dropdown')
|
|
43
|
+
|
|
44
|
+
const prompt = `Explain routing without changing the source ${sessionAction}`
|
|
45
|
+
store.setState({
|
|
46
|
+
test: {
|
|
47
|
+
deterministicTranscription: {
|
|
48
|
+
transcription: prompt,
|
|
49
|
+
queueMessage: false,
|
|
50
|
+
sessionAction,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
await th.user(TEST_USER_ID).sendVoiceMessage()
|
|
55
|
+
const confirmation = sessionAction === 'btw' ? 'Session forked!' : 'Created new session in'
|
|
56
|
+
const routingMessages = await waitForBotMessageContaining({
|
|
57
|
+
discord: ctx.discord,
|
|
58
|
+
threadId: source.id,
|
|
59
|
+
text: confirmation,
|
|
60
|
+
timeout: 4_000,
|
|
61
|
+
})
|
|
62
|
+
const targetId = routingMessages.find((message) => message.content.includes(confirmation))
|
|
63
|
+
?.content.match(/<#(\d+)>/)?.[1]
|
|
64
|
+
if (!targetId) throw new Error('Expected destination thread link')
|
|
65
|
+
const target = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
66
|
+
timeout: 4_000,
|
|
67
|
+
predicate: (thread) => thread.id === targetId,
|
|
68
|
+
})
|
|
69
|
+
await waitForFooterMessage({ discord: ctx.discord, threadId: target.id, timeout: 4_000 })
|
|
70
|
+
const interaction = await th.user(TEST_USER_ID).selectMenu({
|
|
71
|
+
messageId: question.id,
|
|
72
|
+
customId,
|
|
73
|
+
values: ['0'],
|
|
74
|
+
})
|
|
75
|
+
await th.waitForInteractionAck({ interactionId: interaction.id, timeout: 4_000 })
|
|
76
|
+
await waitForBotMessageContaining({
|
|
77
|
+
discord: ctx.discord,
|
|
78
|
+
threadId: source.id,
|
|
79
|
+
text: '» **voice-question-tester:** Alpha',
|
|
80
|
+
timeout: 4_000,
|
|
81
|
+
})
|
|
82
|
+
await waitForFooterMessage({ discord: ctx.discord, threadId: source.id, timeout: 4_000 })
|
|
83
|
+
const transcript = (await th.text({ showInteractions: true })).replaceAll(
|
|
84
|
+
target.id,
|
|
85
|
+
'TARGET_THREAD',
|
|
86
|
+
)
|
|
87
|
+
if (sessionAction === 'btw') {
|
|
88
|
+
expect(transcript).toMatchInlineSnapshot(`
|
|
89
|
+
"--- from: user (voice-question-tester)
|
|
90
|
+
QUESTION_SELECT_QUEUE_MARKER voice btw
|
|
91
|
+
--- from: assistant (TestBot)
|
|
92
|
+
> *using deterministic-provider/deterministic-v2*
|
|
93
|
+
**Select action**
|
|
94
|
+
How to proceed?
|
|
95
|
+
✓ _Alpha_
|
|
96
|
+
--- from: user (voice-question-tester)
|
|
97
|
+
[attachment: voice-message.ogg]
|
|
98
|
+
--- from: assistant (TestBot)
|
|
99
|
+
🎤 Transcribing voice message...
|
|
100
|
+
📝 **Transcribed message:** Explain routing without changing the source btw
|
|
101
|
+
Session forked! Continue in <#TARGET_THREAD>
|
|
102
|
+
[user selects dropdown: 0]
|
|
103
|
+
» **voice-question-tester:** Alpha
|
|
104
|
+
> tool done
|
|
105
|
+
> *project ⋅ main ⋅ 1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>"
|
|
106
|
+
`)
|
|
107
|
+
} else {
|
|
108
|
+
expect(transcript).toMatchInlineSnapshot(`
|
|
109
|
+
"--- from: user (voice-question-tester)
|
|
110
|
+
QUESTION_SELECT_QUEUE_MARKER voice new-session
|
|
111
|
+
--- from: assistant (TestBot)
|
|
112
|
+
> *using deterministic-provider/deterministic-v2*
|
|
113
|
+
**Select action**
|
|
114
|
+
How to proceed?
|
|
115
|
+
✓ _Alpha_
|
|
116
|
+
--- from: user (voice-question-tester)
|
|
117
|
+
[attachment: voice-message.ogg]
|
|
118
|
+
--- from: assistant (TestBot)
|
|
119
|
+
🎤 Transcribing voice message...
|
|
120
|
+
📝 **Transcribed message:** Explain routing without changing the source new-session
|
|
121
|
+
Created new session in <#TARGET_THREAD>
|
|
122
|
+
[user selects dropdown: 0]
|
|
123
|
+
» **voice-question-tester:** Alpha
|
|
124
|
+
> tool done
|
|
125
|
+
> *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>"
|
|
126
|
+
`)
|
|
127
|
+
}
|
|
128
|
+
expect(transcript).not.toContain('expired')
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
})
|
package/src/voice.test.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
extractTranscription,
|
|
11
11
|
normalizeAudioMediaType,
|
|
12
12
|
getOpenAIAudioConversionStrategy,
|
|
13
|
+
buildTranscriptionTool,
|
|
13
14
|
} from './voice.js'
|
|
14
15
|
import {
|
|
15
16
|
getVoiceAttachmentMatchReason,
|
|
@@ -65,9 +66,92 @@ describe('voice attachment detection', () => {
|
|
|
65
66
|
]
|
|
66
67
|
`)
|
|
67
68
|
})
|
|
69
|
+
|
|
70
|
+
test('does not treat iOS and Discord videos as voice', () => {
|
|
71
|
+
expect(
|
|
72
|
+
[
|
|
73
|
+
getVoiceAttachmentMatchReason({
|
|
74
|
+
name: 'ScreenRecording_08-26-2026_11-10-29_1.mov',
|
|
75
|
+
contentType: 'video/quicktime',
|
|
76
|
+
duration: 12.4,
|
|
77
|
+
width: 1170,
|
|
78
|
+
height: 2532,
|
|
79
|
+
}),
|
|
80
|
+
getVoiceAttachmentMatchReason({
|
|
81
|
+
name: 'IMG_1234.MOV',
|
|
82
|
+
contentType: null,
|
|
83
|
+
duration: 8,
|
|
84
|
+
width: 1920,
|
|
85
|
+
height: 1080,
|
|
86
|
+
}),
|
|
87
|
+
getVoiceAttachmentMatchReason({
|
|
88
|
+
name: 'clip.mp4',
|
|
89
|
+
contentType: 'video/mp4',
|
|
90
|
+
duration: 4,
|
|
91
|
+
}),
|
|
92
|
+
getVoiceAttachmentMatchReason({
|
|
93
|
+
name: 'clip.mp4',
|
|
94
|
+
contentType: null,
|
|
95
|
+
}),
|
|
96
|
+
isVoiceAttachment({
|
|
97
|
+
name: 'memo.m4a',
|
|
98
|
+
contentType: null,
|
|
99
|
+
duration: 3.2,
|
|
100
|
+
}),
|
|
101
|
+
]).toMatchInlineSnapshot(`
|
|
102
|
+
[
|
|
103
|
+
null,
|
|
104
|
+
null,
|
|
105
|
+
null,
|
|
106
|
+
null,
|
|
107
|
+
true,
|
|
108
|
+
]
|
|
109
|
+
`)
|
|
110
|
+
})
|
|
68
111
|
})
|
|
69
112
|
|
|
70
113
|
describe('extractTranscription', () => {
|
|
114
|
+
test('only offers contextual routing when a source session exists', () => {
|
|
115
|
+
expect(buildTranscriptionTool({}).inputSchema).toMatchObject({
|
|
116
|
+
properties: { sessionAction: { enum: ['new-session'] } },
|
|
117
|
+
})
|
|
118
|
+
expect(buildTranscriptionTool({ canForkSession: true }).inputSchema).toMatchObject({
|
|
119
|
+
properties: { sessionAction: { enum: ['btw', 'new-session'] } },
|
|
120
|
+
})
|
|
121
|
+
expect(JSON.stringify(buildTranscriptionTool({ canForkSession: true })))
|
|
122
|
+
.not.toContain('by the way')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test.each(['btw', 'new-session'])('extracts %s routing without queueing', (sessionAction) => {
|
|
126
|
+
const result = extractTranscription([{
|
|
127
|
+
type: 'tool-call',
|
|
128
|
+
toolCallId: 'routing',
|
|
129
|
+
toolName: 'transcriptionResult',
|
|
130
|
+
input: JSON.stringify({
|
|
131
|
+
transcription: 'Explain the authentication flow',
|
|
132
|
+
sessionAction,
|
|
133
|
+
queueMessage: true,
|
|
134
|
+
agent: 'plan',
|
|
135
|
+
}),
|
|
136
|
+
}])
|
|
137
|
+
expect(result).toEqual({
|
|
138
|
+
transcription: 'Explain the authentication flow',
|
|
139
|
+
sessionAction,
|
|
140
|
+
queueMessage: false,
|
|
141
|
+
agent: 'plan',
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('ignores unknown session routing', () => {
|
|
146
|
+
const result = extractTranscription([{
|
|
147
|
+
type: 'tool-call',
|
|
148
|
+
toolCallId: 'routing',
|
|
149
|
+
toolName: 'transcriptionResult',
|
|
150
|
+
input: JSON.stringify({ transcription: 'Keep working', sessionAction: 'invalid' }),
|
|
151
|
+
}])
|
|
152
|
+
expect(result).toEqual({ transcription: 'Keep working', queueMessage: false, agent: undefined })
|
|
153
|
+
})
|
|
154
|
+
|
|
71
155
|
test('extracts transcription from tool call', () => {
|
|
72
156
|
const result = extractTranscription([
|
|
73
157
|
{
|
|
@@ -81,6 +165,7 @@ describe('extractTranscription', () => {
|
|
|
81
165
|
{
|
|
82
166
|
"agent": undefined,
|
|
83
167
|
"queueMessage": false,
|
|
168
|
+
"sessionAction": undefined,
|
|
84
169
|
"transcription": "hello world",
|
|
85
170
|
}
|
|
86
171
|
`)
|
|
@@ -102,6 +187,7 @@ describe('extractTranscription', () => {
|
|
|
102
187
|
{
|
|
103
188
|
"agent": undefined,
|
|
104
189
|
"queueMessage": true,
|
|
190
|
+
"sessionAction": undefined,
|
|
105
191
|
"transcription": "Fix the login bug in auth.ts",
|
|
106
192
|
}
|
|
107
193
|
`)
|
|
@@ -172,24 +258,31 @@ describe('extractTranscription', () => {
|
|
|
172
258
|
})
|
|
173
259
|
})
|
|
174
260
|
|
|
175
|
-
|
|
261
|
+
const liveVoiceTest = describe.skipIf(
|
|
262
|
+
process.env['KIMAKI_LIVE_VOICE_TESTS'] !== '1',
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
liveVoiceTest('transcribeAudio with real API', () => {
|
|
176
266
|
const audioPath = path.join(
|
|
177
267
|
import.meta.dirname,
|
|
178
268
|
'..',
|
|
179
269
|
'scripts',
|
|
180
270
|
'example-audio.mp3',
|
|
181
271
|
)
|
|
272
|
+
const oggPath = path.join(import.meta.dirname, '..', 'scripts', 'example-audio.ogg')
|
|
273
|
+
const geminiLiveTest = test.skipIf(
|
|
274
|
+
!process.env.GEMINI_API_KEY || !fs.existsSync(audioPath),
|
|
275
|
+
)
|
|
276
|
+
const openAiLiveTest = test.skipIf(
|
|
277
|
+
!process.env.OPENAI_API_KEY || !fs.existsSync(audioPath),
|
|
278
|
+
)
|
|
279
|
+
const openAiOggLiveTest = test.skipIf(
|
|
280
|
+
!process.env.OPENAI_API_KEY || !fs.existsSync(oggPath),
|
|
281
|
+
)
|
|
182
282
|
|
|
183
|
-
|
|
283
|
+
geminiLiveTest('transcribes with Gemini', { timeout: 30_000 }, async () => {
|
|
184
284
|
const apiKey = process.env.GEMINI_API_KEY
|
|
185
|
-
if (!apiKey)
|
|
186
|
-
console.log('Skipping: GEMINI_API_KEY not set')
|
|
187
|
-
return
|
|
188
|
-
}
|
|
189
|
-
if (!fs.existsSync(audioPath)) {
|
|
190
|
-
console.log('Skipping: example-audio.mp3 not found')
|
|
191
|
-
return
|
|
192
|
-
}
|
|
285
|
+
if (!apiKey) throw new Error('GEMINI_API_KEY is required')
|
|
193
286
|
|
|
194
287
|
const audio = fs.readFileSync(audioPath)
|
|
195
288
|
const result = await transcribeAudio({
|
|
@@ -205,16 +298,9 @@ describe('transcribeAudio with real API', () => {
|
|
|
205
298
|
console.log('Gemini transcription:', result)
|
|
206
299
|
})
|
|
207
300
|
|
|
208
|
-
|
|
301
|
+
openAiLiveTest('transcribes with OpenAI', { timeout: 30_000 }, async () => {
|
|
209
302
|
const apiKey = process.env.OPENAI_API_KEY
|
|
210
|
-
if (!apiKey)
|
|
211
|
-
console.log('Skipping: OPENAI_API_KEY not set')
|
|
212
|
-
return
|
|
213
|
-
}
|
|
214
|
-
if (!fs.existsSync(audioPath)) {
|
|
215
|
-
console.log('Skipping: example-audio.mp3 not found')
|
|
216
|
-
return
|
|
217
|
-
}
|
|
303
|
+
if (!apiKey) throw new Error('OPENAI_API_KEY is required')
|
|
218
304
|
|
|
219
305
|
const audio = fs.readFileSync(audioPath)
|
|
220
306
|
const result = await transcribeAudio({
|
|
@@ -230,17 +316,9 @@ describe('transcribeAudio with real API', () => {
|
|
|
230
316
|
console.log('OpenAI transcription:', result)
|
|
231
317
|
})
|
|
232
318
|
|
|
233
|
-
|
|
319
|
+
openAiOggLiveTest('transcribes OGG with OpenAI (converts to WAV)', { timeout: 30_000 }, async () => {
|
|
234
320
|
const apiKey = process.env.OPENAI_API_KEY
|
|
235
|
-
|
|
236
|
-
if (!apiKey) {
|
|
237
|
-
console.log('Skipping: OPENAI_API_KEY not set')
|
|
238
|
-
return
|
|
239
|
-
}
|
|
240
|
-
if (!fs.existsSync(oggPath)) {
|
|
241
|
-
console.log('Skipping: example-audio.ogg not found')
|
|
242
|
-
return
|
|
243
|
-
}
|
|
321
|
+
if (!apiKey) throw new Error('OPENAI_API_KEY is required')
|
|
244
322
|
|
|
245
323
|
const audio = fs.readFileSync(oggPath)
|
|
246
324
|
const result = await transcribeAudio({
|
package/src/voice.ts
CHANGED
|
@@ -258,22 +258,31 @@ type TranscriptionLoopError =
|
|
|
258
258
|
|
|
259
259
|
// Build the transcription tool schema dynamically so the agent field can
|
|
260
260
|
// use an enum constrained to the actual available agent names.
|
|
261
|
-
function buildTranscriptionTool({
|
|
261
|
+
export function buildTranscriptionTool({
|
|
262
262
|
agentNames,
|
|
263
|
+
canForkSession = false,
|
|
263
264
|
}: {
|
|
264
265
|
agentNames?: string[]
|
|
266
|
+
canForkSession?: boolean
|
|
265
267
|
}): LanguageModelV3FunctionTool {
|
|
266
268
|
const properties: Record<string, Record<string, unknown>> = {
|
|
267
269
|
transcription: {
|
|
268
270
|
type: 'string',
|
|
269
271
|
description:
|
|
270
|
-
'The final transcription of the audio.
|
|
272
|
+
'The final transcription of the audio. If only a session routing instruction was spoken with no request, return an empty string. If audio is unclear, transcribe your best interpretation. If silent, too short to understand, or completely incomprehensible, use "[inaudible audio]".',
|
|
271
273
|
},
|
|
272
274
|
queueMessage: {
|
|
273
275
|
type: 'boolean',
|
|
274
276
|
description:
|
|
275
277
|
'Set to true ONLY if the user explicitly says "queue this message", "queue this", or similar phrasing indicating they want this message queued instead of sent immediately. If not mentioned, omit or set to false.',
|
|
276
278
|
},
|
|
279
|
+
sessionAction: {
|
|
280
|
+
type: 'string',
|
|
281
|
+
enum: canForkSession ? ['btw', 'new-session'] : ['new-session'],
|
|
282
|
+
description:
|
|
283
|
+
'Use "new-session" only when explicitly asked to create a new chat, session, or thread with no conversation history. Remove routing instructions from transcription. Omit for normal messages. Never combine with queueMessage.' +
|
|
284
|
+
(canForkSession ? ' Use "btw" only when explicitly asked to create a side chat or fork with current context.' : ''),
|
|
285
|
+
},
|
|
277
286
|
}
|
|
278
287
|
|
|
279
288
|
if (agentNames && agentNames.length > 0) {
|
|
@@ -301,6 +310,7 @@ function buildTranscriptionTool({
|
|
|
301
310
|
export type TranscriptionResult = {
|
|
302
311
|
transcription: string
|
|
303
312
|
queueMessage: boolean
|
|
313
|
+
sessionAction?: 'btw' | 'new-session'
|
|
304
314
|
/** Agent name extracted from voice message, only set if user explicitly requested an agent. */
|
|
305
315
|
agent?: string
|
|
306
316
|
}
|
|
@@ -308,7 +318,7 @@ export type TranscriptionResult = {
|
|
|
308
318
|
/**
|
|
309
319
|
* Extract transcription result from doGenerate content array.
|
|
310
320
|
* Looks for a tool-call named 'transcriptionResult', falls back to text content.
|
|
311
|
-
* Returns
|
|
321
|
+
* Returns transcription text, queue/session routing, and optional agent selection.
|
|
312
322
|
*/
|
|
313
323
|
export function extractTranscription(
|
|
314
324
|
content: Array<LanguageModelV3Content>,
|
|
@@ -327,7 +337,10 @@ export function extractTranscription(
|
|
|
327
337
|
return {}
|
|
328
338
|
})()
|
|
329
339
|
const transcription = (typeof args.transcription === 'string' ? args.transcription : '').trim()
|
|
330
|
-
const
|
|
340
|
+
const sessionAction = args.sessionAction === 'btw' || args.sessionAction === 'new-session'
|
|
341
|
+
? args.sessionAction
|
|
342
|
+
: undefined
|
|
343
|
+
const queueMessage = !sessionAction && args.queueMessage === true
|
|
331
344
|
const agent = typeof args.agent === 'string' ? args.agent : undefined
|
|
332
345
|
voiceLogger.log(
|
|
333
346
|
`Transcription result received: "${transcription.slice(0, 100)}..."${queueMessage ? ' [QUEUE]' : ''}${agent ? ` [AGENT:${agent}]` : ''}`,
|
|
@@ -335,7 +348,7 @@ export function extractTranscription(
|
|
|
335
348
|
if (!transcription) {
|
|
336
349
|
return new EmptyTranscriptionError()
|
|
337
350
|
}
|
|
338
|
-
return { transcription, queueMessage, agent }
|
|
351
|
+
return { transcription, queueMessage, agent, sessionAction }
|
|
339
352
|
}
|
|
340
353
|
|
|
341
354
|
// Fall back to text content if no tool call
|
|
@@ -363,6 +376,7 @@ async function runTranscriptionOnce({
|
|
|
363
376
|
mediaType,
|
|
364
377
|
temperature,
|
|
365
378
|
agentNames,
|
|
379
|
+
canForkSession,
|
|
366
380
|
provider,
|
|
367
381
|
}: {
|
|
368
382
|
model: LanguageModelV3
|
|
@@ -371,9 +385,10 @@ async function runTranscriptionOnce({
|
|
|
371
385
|
mediaType: string
|
|
372
386
|
temperature: number
|
|
373
387
|
agentNames?: string[]
|
|
388
|
+
canForkSession?: boolean
|
|
374
389
|
provider?: TranscriptionProvider
|
|
375
390
|
}): Promise<TranscriptionLoopError | TranscriptionResult> {
|
|
376
|
-
const tool = buildTranscriptionTool({ agentNames })
|
|
391
|
+
const tool = buildTranscriptionTool({ agentNames, canForkSession })
|
|
377
392
|
const options: LanguageModelV3CallOptions = {
|
|
378
393
|
prompt: [
|
|
379
394
|
{
|
|
@@ -471,6 +486,7 @@ export async function transcribeAudio({
|
|
|
471
486
|
currentSessionContext,
|
|
472
487
|
lastSessionContext,
|
|
473
488
|
agents,
|
|
489
|
+
canForkSession = false,
|
|
474
490
|
}: {
|
|
475
491
|
audio: Buffer | Uint8Array | ArrayBuffer | string
|
|
476
492
|
prompt?: string
|
|
@@ -485,6 +501,7 @@ export async function transcribeAudio({
|
|
|
485
501
|
lastSessionContext?: string
|
|
486
502
|
/** Available agents for agent selection via voice. Names used as enum values in the tool schema. */
|
|
487
503
|
agents?: Array<{ name: string; description?: string }>
|
|
504
|
+
canForkSession?: boolean
|
|
488
505
|
}): Promise<TranscribeAudioErrors | TranscriptionResult> {
|
|
489
506
|
const apiKey =
|
|
490
507
|
apiKeyParam || process.env.OPENAI_API_KEY || process.env.GEMINI_API_KEY
|
|
@@ -595,6 +612,16 @@ This is a software development environment. The speaker is giving instructions t
|
|
|
595
612
|
- Example: "Queue this message. Fix the login bug in auth.ts" → transcription: "Fix the login bug in auth.ts", queueMessage: true
|
|
596
613
|
- If removing the queue phrase would leave empty content (user only said "queue this" with nothing else), keep the full spoken text as the transcription — never return an empty transcription.
|
|
597
614
|
- If no queue intent is detected, omit queueMessage or set it to false.
|
|
615
|
+
|
|
616
|
+
SESSION ROUTING:
|
|
617
|
+
- Only route when the user explicitly asks to create a new chat, session, or thread for the request. Set sessionAction to "new-session": a separate thread with NO conversation history.
|
|
618
|
+
${canForkSession ? '- If the user explicitly asks to create a side chat or fork with current context, set sessionAction to "btw" instead.' : '- Context-preserving side chats and forks are unavailable here. Preserve requests for them as spoken text without setting sessionAction.'}
|
|
619
|
+
- Remove these routing words from the transcription. Include only the actual request, not instructions about where to send it.
|
|
620
|
+
- Example: "Fix the login bug. Create this as a new chat session" -> transcription: "Fix the login bug", sessionAction: "new-session".
|
|
621
|
+
- Do not infer routing from conversational phrases, task content, or session context.
|
|
622
|
+
- If both routing and queueing are requested, sessionAction takes priority; set queueMessage to false. An explicit fresh-session request takes priority over a contextual fork.
|
|
623
|
+
- If there is no actual request after removing routing words, return an empty transcription and omit sessionAction. Never invent placeholder content.
|
|
624
|
+
- Otherwise omit sessionAction. Agent selection can be combined with either route.
|
|
598
625
|
${agents && agents.length > 0 ? `
|
|
599
626
|
AGENT SELECTION:
|
|
600
627
|
- Only set the agent field when the user explicitly says phrases like "use the X agent", "switch to X agent", "with the X agent", or similar phrasing that clearly names a specific agent to switch to.
|
|
@@ -634,6 +661,7 @@ Note: "critique" is a CLI tool for showing diffs in the browser.`
|
|
|
634
661
|
mediaType,
|
|
635
662
|
temperature: temperature ?? 0.3,
|
|
636
663
|
agentNames: agentNames && agentNames.length > 0 ? agentNames : undefined,
|
|
664
|
+
canForkSession,
|
|
637
665
|
provider: resolvedProvider,
|
|
638
666
|
})
|
|
639
667
|
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Verifies OpenCode workspace creation stays bound to the requested local clone.
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { afterAll, beforeAll, expect, test } from 'vitest'
|
|
6
|
+
import { setDataDir } from './config.js'
|
|
7
|
+
import { tryWorkspaceCreate } from './commands/new-worktree.js'
|
|
8
|
+
import { initializeOpencodeForDirectory, stopOpencodeServer } from './opencode.js'
|
|
9
|
+
import { chooseLockPort } from './test-utils.js'
|
|
10
|
+
import { execAsync, getManagedWorktreeDirectory } from './worktrees.js'
|
|
11
|
+
import { KIMAKI_WORKTREE_ADAPTER_TYPE } from './git-worktree-core.js'
|
|
12
|
+
|
|
13
|
+
const WORKTREE_BRANCH = 'opencode/kimaki-clone-isolation'
|
|
14
|
+
const REJECTED_WORKTREE_BRANCH = 'opencode/kimaki-rejected-clone-isolation'
|
|
15
|
+
|
|
16
|
+
async function git({ cwd, args }: { cwd: string; args: string[] }) {
|
|
17
|
+
const result = await execAsync(`git ${args.map((arg) => JSON.stringify(arg)).join(' ')}`, {
|
|
18
|
+
cwd,
|
|
19
|
+
timeout: 60_000,
|
|
20
|
+
})
|
|
21
|
+
return result.stdout.trim()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let sandbox = ''
|
|
25
|
+
let requestedClone = ''
|
|
26
|
+
let otherClone = ''
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
const root = path.resolve(process.cwd(), 'tmp')
|
|
30
|
+
fs.mkdirSync(root, { recursive: true })
|
|
31
|
+
sandbox = fs.mkdtempSync(path.join(root, 'worktree-clone-isolation-'))
|
|
32
|
+
const remote = path.join(sandbox, 'remote.git')
|
|
33
|
+
requestedClone = path.join(sandbox, 'holocron')
|
|
34
|
+
otherClone = path.join(sandbox, 'fumabase')
|
|
35
|
+
|
|
36
|
+
process.env['KIMAKI_LOCK_PORT'] = String(chooseLockPort({ key: 'worktree-clone-isolation' }))
|
|
37
|
+
setDataDir(path.join(sandbox, 'data'))
|
|
38
|
+
|
|
39
|
+
await git({ cwd: sandbox, args: ['init', '--bare', '-b', 'main', remote] })
|
|
40
|
+
await git({ cwd: sandbox, args: ['clone', remote, otherClone] })
|
|
41
|
+
await git({
|
|
42
|
+
cwd: otherClone,
|
|
43
|
+
args: ['config', 'user.email', 'kimaki-tests@example.com'],
|
|
44
|
+
})
|
|
45
|
+
await git({ cwd: otherClone, args: ['config', 'user.name', 'Kimaki Tests'] })
|
|
46
|
+
fs.writeFileSync(path.join(otherClone, 'preview.txt'), 'old preview\n')
|
|
47
|
+
await git({ cwd: otherClone, args: ['add', 'preview.txt'] })
|
|
48
|
+
await git({ cwd: otherClone, args: ['commit', '-m', 'old preview'] })
|
|
49
|
+
await git({ cwd: otherClone, args: ['push', 'origin', 'HEAD:main'] })
|
|
50
|
+
await git({ cwd: otherClone, args: ['switch', '-c', 'preview'] })
|
|
51
|
+
|
|
52
|
+
await git({ cwd: sandbox, args: ['clone', remote, requestedClone] })
|
|
53
|
+
await git({
|
|
54
|
+
cwd: requestedClone,
|
|
55
|
+
args: ['config', 'user.email', 'kimaki-tests@example.com'],
|
|
56
|
+
})
|
|
57
|
+
await git({ cwd: requestedClone, args: ['config', 'user.name', 'Kimaki Tests'] })
|
|
58
|
+
fs.mkdirSync(path.join(requestedClone, 'vite'))
|
|
59
|
+
fs.writeFileSync(path.join(requestedClone, 'vite', 'README.md'), 'new layout\n')
|
|
60
|
+
await git({ cwd: requestedClone, args: ['add', 'vite/README.md'] })
|
|
61
|
+
await git({ cwd: requestedClone, args: ['commit', '-m', 'add vite layout'] })
|
|
62
|
+
}, 20_000)
|
|
63
|
+
|
|
64
|
+
afterAll(async () => {
|
|
65
|
+
await stopOpencodeServer()
|
|
66
|
+
delete process.env['KIMAKI_LOCK_PORT']
|
|
67
|
+
if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('creates a workspace from the exact requested clone and commit', async () => {
|
|
71
|
+
const requestedCommit = await git({
|
|
72
|
+
cwd: requestedClone,
|
|
73
|
+
args: ['rev-parse', 'HEAD^{commit}'],
|
|
74
|
+
})
|
|
75
|
+
const requestedCommonDirectory = path.resolve(
|
|
76
|
+
requestedClone,
|
|
77
|
+
await git({
|
|
78
|
+
cwd: requestedClone,
|
|
79
|
+
args: ['rev-parse', '--git-common-dir'],
|
|
80
|
+
}),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const requestedClientResult = await initializeOpencodeForDirectory(requestedClone)
|
|
84
|
+
if (requestedClientResult instanceof Error) throw requestedClientResult
|
|
85
|
+
const otherClientResult = await initializeOpencodeForDirectory(otherClone)
|
|
86
|
+
if (otherClientResult instanceof Error) throw otherClientResult
|
|
87
|
+
const requestedClient = requestedClientResult()
|
|
88
|
+
const otherClient = otherClientResult()
|
|
89
|
+
|
|
90
|
+
// Load the requested clone first, then overwrite the colliding OpenCode
|
|
91
|
+
// project adapter with the other clone before creating the workspace.
|
|
92
|
+
await requestedClient.config.get({ directory: requestedClone })
|
|
93
|
+
await otherClient.config.get({ directory: otherClone })
|
|
94
|
+
|
|
95
|
+
const response = await requestedClient.experimental.workspace.create({
|
|
96
|
+
directory: requestedClone,
|
|
97
|
+
type: KIMAKI_WORKTREE_ADAPTER_TYPE,
|
|
98
|
+
branch: WORKTREE_BRANCH,
|
|
99
|
+
extra: {
|
|
100
|
+
projectDirectory: requestedClone,
|
|
101
|
+
baseCommit: requestedCommit,
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
if (response.error) throw new Error(JSON.stringify(response.error))
|
|
105
|
+
const workspace = response.data
|
|
106
|
+
if (!workspace) throw new Error('OpenCode returned no workspace')
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const actualCommit = await git({
|
|
110
|
+
cwd: workspace.directory!,
|
|
111
|
+
args: ['rev-parse', 'HEAD^{commit}'],
|
|
112
|
+
})
|
|
113
|
+
const actualCommonDirectory = path.resolve(
|
|
114
|
+
workspace.directory!,
|
|
115
|
+
await git({
|
|
116
|
+
cwd: workspace.directory!,
|
|
117
|
+
args: ['rev-parse', '--git-common-dir'],
|
|
118
|
+
}),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
expect({
|
|
122
|
+
usesRequestedClone:
|
|
123
|
+
path.resolve(actualCommonDirectory) === path.resolve(requestedCommonDirectory),
|
|
124
|
+
usesRequestedCommit: actualCommit === requestedCommit,
|
|
125
|
+
hasRequestedLayout: fs.existsSync(path.join(workspace.directory!, 'vite', 'README.md')),
|
|
126
|
+
absentFromOtherClone: !(
|
|
127
|
+
await git({
|
|
128
|
+
cwd: otherClone,
|
|
129
|
+
args: ['worktree', 'list', '--porcelain'],
|
|
130
|
+
})
|
|
131
|
+
).includes(workspace.directory!),
|
|
132
|
+
}).toMatchInlineSnapshot(`
|
|
133
|
+
{
|
|
134
|
+
"absentFromOtherClone": true,
|
|
135
|
+
"hasRequestedLayout": true,
|
|
136
|
+
"usesRequestedClone": true,
|
|
137
|
+
"usesRequestedCommit": true,
|
|
138
|
+
}
|
|
139
|
+
`)
|
|
140
|
+
} finally {
|
|
141
|
+
await requestedClient.experimental.workspace.remove({
|
|
142
|
+
id: workspace.id,
|
|
143
|
+
directory: requestedClone,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
}, 30_000)
|
|
147
|
+
|
|
148
|
+
test('removes workspace state when identity validation rejects creation', async () => {
|
|
149
|
+
const requestedCommit = await git({
|
|
150
|
+
cwd: requestedClone,
|
|
151
|
+
args: ['rev-parse', 'HEAD^{commit}'],
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const result = await tryWorkspaceCreate({
|
|
155
|
+
worktreeName: REJECTED_WORKTREE_BRANCH,
|
|
156
|
+
projectDirectory: requestedClone,
|
|
157
|
+
baseCommit: requestedCommit.toUpperCase(),
|
|
158
|
+
})
|
|
159
|
+
expect(result).toBeInstanceOf(Error)
|
|
160
|
+
|
|
161
|
+
const clientResult = await initializeOpencodeForDirectory(requestedClone)
|
|
162
|
+
if (clientResult instanceof Error) throw clientResult
|
|
163
|
+
const client = clientResult()
|
|
164
|
+
const listResponse = await client.experimental.workspace.list({
|
|
165
|
+
directory: requestedClone,
|
|
166
|
+
})
|
|
167
|
+
if (listResponse.error) throw new Error(JSON.stringify(listResponse.error))
|
|
168
|
+
const rejectedWorkspaces = (listResponse.data ?? []).filter((workspace) => {
|
|
169
|
+
return workspace.branch === REJECTED_WORKTREE_BRANCH
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const managedDirectory = getManagedWorktreeDirectory({
|
|
174
|
+
directory: requestedClone,
|
|
175
|
+
name: REJECTED_WORKTREE_BRANCH,
|
|
176
|
+
})
|
|
177
|
+
const [requestedWorktrees, otherWorktrees] = await Promise.all([
|
|
178
|
+
git({ cwd: requestedClone, args: ['worktree', 'list', '--porcelain'] }),
|
|
179
|
+
git({ cwd: otherClone, args: ['worktree', 'list', '--porcelain'] }),
|
|
180
|
+
])
|
|
181
|
+
const branchExists = await execAsync(
|
|
182
|
+
`git show-ref --verify --quiet ${JSON.stringify(`refs/heads/${REJECTED_WORKTREE_BRANCH}`)}`,
|
|
183
|
+
{ cwd: requestedClone },
|
|
184
|
+
)
|
|
185
|
+
.then(() => true)
|
|
186
|
+
.catch(() => false)
|
|
187
|
+
|
|
188
|
+
expect({
|
|
189
|
+
workspaceRows: rejectedWorkspaces.length,
|
|
190
|
+
directoryExists: fs.existsSync(managedDirectory),
|
|
191
|
+
requestedCloneRegistration: requestedWorktrees.includes(managedDirectory),
|
|
192
|
+
otherCloneRegistration: otherWorktrees.includes(managedDirectory),
|
|
193
|
+
branchExists,
|
|
194
|
+
}).toMatchInlineSnapshot(`
|
|
195
|
+
{
|
|
196
|
+
"branchExists": false,
|
|
197
|
+
"directoryExists": false,
|
|
198
|
+
"otherCloneRegistration": false,
|
|
199
|
+
"requestedCloneRegistration": false,
|
|
200
|
+
"workspaceRows": 0,
|
|
201
|
+
}
|
|
202
|
+
`)
|
|
203
|
+
} finally {
|
|
204
|
+
await Promise.all(
|
|
205
|
+
rejectedWorkspaces.map((workspace) => {
|
|
206
|
+
return client.experimental.workspace.remove({
|
|
207
|
+
id: workspace.id,
|
|
208
|
+
directory: requestedClone,
|
|
209
|
+
})
|
|
210
|
+
}),
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
}, 30_000)
|