kimaki 0.4.89 → 0.4.90
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 +2 -1
- package/dist/cli-send-thread.e2e.test.js +2 -1
- package/dist/cli.js +4 -0
- package/dist/commands/btw.js +7 -2
- package/dist/discord-bot.js +5 -4
- package/dist/event-stream-real-capture.e2e.test.js +2 -1
- package/dist/gateway-proxy.e2e.test.js +2 -1
- package/dist/kimaki-digital-twin.e2e.test.js +2 -1
- package/dist/markdown.test.js +5 -6
- package/dist/message-finish-field.e2e.test.js +2 -1
- package/dist/opencode.js +18 -12
- package/dist/queue-advanced-abort.e2e.test.js +14 -15
- package/dist/queue-advanced-e2e-setup.js +2 -0
- package/dist/queue-advanced-permissions-typing.e2e.test.js +25 -23
- package/dist/queue-advanced-question.e2e.test.js +22 -40
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -5
- package/dist/queue-question-select-drain.e2e.test.js +30 -27
- package/dist/runtime-lifecycle.e2e.test.js +2 -1
- package/dist/session-handler/thread-session-runtime.js +13 -8
- package/dist/startup-time.e2e.test.js +2 -1
- package/dist/test-utils.js +20 -0
- package/dist/thread-message-queue.e2e.test.js +16 -30
- package/dist/voice-message.e2e.test.js +2 -1
- package/dist/worktree-lifecycle.e2e.test.js +1 -1
- package/dist/worktrees.test.js +2 -2
- package/package.json +6 -6
- package/src/agent-model.e2e.test.ts +2 -0
- package/src/cli-send-thread.e2e.test.ts +2 -0
- package/src/cli.ts +8 -1
- package/src/commands/btw.ts +8 -2
- package/src/discord-bot.ts +7 -4
- package/src/event-stream-real-capture.e2e.test.ts +2 -1
- package/src/gateway-proxy.e2e.test.ts +2 -0
- package/src/kimaki-digital-twin.e2e.test.ts +2 -1
- package/src/markdown.test.ts +4 -5
- package/src/message-finish-field.e2e.test.ts +2 -1
- package/src/opencode.ts +18 -12
- package/src/queue-advanced-abort.e2e.test.ts +14 -15
- package/src/queue-advanced-e2e-setup.ts +2 -0
- package/src/queue-advanced-permissions-typing.e2e.test.ts +33 -23
- package/src/queue-advanced-question.e2e.test.ts +22 -43
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +13 -5
- package/src/queue-question-select-drain.e2e.test.ts +31 -28
- package/src/runtime-lifecycle.e2e.test.ts +2 -0
- package/src/session-handler/thread-session-runtime.ts +22 -6
- package/src/startup-time.e2e.test.ts +2 -1
- package/src/test-utils.ts +21 -0
- package/src/thread-message-queue.e2e.test.ts +16 -32
- package/src/voice-message.e2e.test.ts +2 -0
- package/src/worktree-lifecycle.e2e.test.ts +1 -1
- package/src/worktrees.test.ts +2 -2
|
@@ -23,7 +23,7 @@ import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChann
|
|
|
23
23
|
import { getPrisma } from './db.js';
|
|
24
24
|
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
25
25
|
import { initializeOpencodeForDirectory, stopOpencodeServer } from './opencode.js';
|
|
26
|
-
import { chooseLockPort, cleanupTestSessions, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
|
|
26
|
+
import { chooseLockPort, cleanupTestSessions, initTestGitRepo, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
|
|
27
27
|
import { buildQuickAgentCommandDescription } from './commands/agent.js';
|
|
28
28
|
const TEST_USER_ID = '200000000000000920';
|
|
29
29
|
const TEXT_CHANNEL_ID = '200000000000000921';
|
|
@@ -38,6 +38,7 @@ function createRunDirectories() {
|
|
|
38
38
|
const dataDir = fs.mkdtempSync(path.join(root, 'data-'));
|
|
39
39
|
const projectDirectory = path.join(root, 'project');
|
|
40
40
|
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
41
|
+
initTestGitRepo(projectDirectory);
|
|
41
42
|
return { root, dataDir, projectDirectory };
|
|
42
43
|
}
|
|
43
44
|
function createDiscordJsClient({ restUrl }) {
|
|
@@ -23,7 +23,7 @@ import { startDiscordBot } from './discord-bot.js';
|
|
|
23
23
|
import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, } from './database.js';
|
|
24
24
|
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
25
25
|
import { initializeOpencodeForDirectory, stopOpencodeServer, } from './opencode.js';
|
|
26
|
-
import { chooseLockPort, cleanupTestSessions, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
|
|
26
|
+
import { chooseLockPort, cleanupTestSessions, initTestGitRepo, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
|
|
27
27
|
import yaml from 'js-yaml';
|
|
28
28
|
const TEST_USER_ID = '200000000000000830';
|
|
29
29
|
const TEXT_CHANNEL_ID = '200000000000000831';
|
|
@@ -34,6 +34,7 @@ function createRunDirectories() {
|
|
|
34
34
|
const dataDir = fs.mkdtempSync(path.join(root, 'data-'));
|
|
35
35
|
const projectDirectory = path.join(root, 'project');
|
|
36
36
|
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
37
|
+
initTestGitRepo(projectDirectory);
|
|
37
38
|
return { root, dataDir, projectDirectory };
|
|
38
39
|
}
|
|
39
40
|
function createDiscordJsClient({ restUrl }) {
|
package/dist/cli.js
CHANGED
|
@@ -2510,7 +2510,11 @@ cli
|
|
|
2510
2510
|
cli
|
|
2511
2511
|
.command('project create <name>', 'Create a new project folder with git and Discord channels')
|
|
2512
2512
|
.option('-g, --guild <guildId>', 'Discord guild ID')
|
|
2513
|
+
.option('--projects-dir <path>', 'Directory where new projects are created (default: <data-dir>/projects)')
|
|
2513
2514
|
.action(async (name, options) => {
|
|
2515
|
+
if (options.projectsDir) {
|
|
2516
|
+
setProjectsDir(options.projectsDir);
|
|
2517
|
+
}
|
|
2514
2518
|
const sanitizedName = name
|
|
2515
2519
|
.toLowerCase()
|
|
2516
2520
|
.replace(/[^a-z0-9-]/g, '-')
|
package/dist/commands/btw.js
CHANGED
|
@@ -86,7 +86,12 @@ export async function handleBtwCommand({ command, appId, }) {
|
|
|
86
86
|
// Short status message with prompt instead of replaying past messages
|
|
87
87
|
const sourceThreadLink = `<#${channel.id}>`;
|
|
88
88
|
await sendThreadMessage(thread, `Reusing context from ${sourceThreadLink} to answer prompt...\n${prompt}`);
|
|
89
|
-
|
|
89
|
+
const wrappedPrompt = [
|
|
90
|
+
`The user asked a side question while you were working on another task.`,
|
|
91
|
+
`This is a forked session whose ONLY goal is to answer this question.`,
|
|
92
|
+
`Do NOT continue, resume, or reference the previous task. Only answer the question below.\n`,
|
|
93
|
+
prompt,
|
|
94
|
+
].join('\n');
|
|
90
95
|
const runtime = getOrCreateRuntime({
|
|
91
96
|
threadId: thread.id,
|
|
92
97
|
thread,
|
|
@@ -96,7 +101,7 @@ export async function handleBtwCommand({ command, appId, }) {
|
|
|
96
101
|
appId,
|
|
97
102
|
});
|
|
98
103
|
await runtime.enqueueIncoming({
|
|
99
|
-
prompt,
|
|
104
|
+
prompt: wrappedPrompt,
|
|
100
105
|
userId: command.user.id,
|
|
101
106
|
username: command.user.displayName,
|
|
102
107
|
appId,
|
package/dist/discord-bot.js
CHANGED
|
@@ -276,10 +276,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
276
276
|
if (isSelfBotMessage && !isCliInjectedPrompt) {
|
|
277
277
|
return;
|
|
278
278
|
}
|
|
279
|
-
// Allow
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
|
|
279
|
+
// Allow CLI-injected prompts from this Kimaki bot through even when role
|
|
280
|
+
// reconciliation did not give the bot the "Kimaki" role yet. Other bots
|
|
281
|
+
// still need Kimaki permission so multi-agent orchestration stays opt-in.
|
|
282
|
+
const isInjectedSelfBotMessage = isCliInjectedPrompt && message.author?.id === discordClient.user?.id;
|
|
283
|
+
if (message.author?.bot && !isInjectedSelfBotMessage) {
|
|
283
284
|
if (!hasKimakiBotPermission(message.member)) {
|
|
284
285
|
return;
|
|
285
286
|
}
|
|
@@ -12,7 +12,7 @@ import { store } from './store.js';
|
|
|
12
12
|
import { startDiscordBot } from './discord-bot.js';
|
|
13
13
|
import { closeDatabase, getChannelVerbosity, initDatabase, setBotToken, setChannelDirectory, setChannelVerbosity, } from './database.js';
|
|
14
14
|
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
15
|
-
import { chooseLockPort, cleanupTestSessions } from './test-utils.js';
|
|
15
|
+
import { chooseLockPort, cleanupTestSessions, initTestGitRepo } from './test-utils.js';
|
|
16
16
|
import { waitForBotMessageContaining, waitForBotReplyAfterUserMessage } from './test-utils.js';
|
|
17
17
|
import { stopOpencodeServer } from './opencode.js';
|
|
18
18
|
import { disposeRuntime, pendingPermissions } from './session-handler/thread-session-runtime.js';
|
|
@@ -35,6 +35,7 @@ function createRunDirectories() {
|
|
|
35
35
|
const sessionEventsDir = path.join(root, 'opencode-session-events');
|
|
36
36
|
const fixtureOutputDir = path.resolve(process.cwd(), 'src', 'session-handler', 'event-stream-fixtures');
|
|
37
37
|
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
38
|
+
initTestGitRepo(projectDirectory);
|
|
38
39
|
fs.mkdirSync(sessionEventsDir, { recursive: true });
|
|
39
40
|
return {
|
|
40
41
|
root,
|
|
@@ -19,7 +19,7 @@ import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
|
19
19
|
import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, } from './database.js';
|
|
20
20
|
import { setDataDir } from './config.js';
|
|
21
21
|
import { startDiscordBot } from './discord-bot.js';
|
|
22
|
-
import { chooseLockPort, cleanupTestSessions, waitForFooterMessage, } from './test-utils.js';
|
|
22
|
+
import { chooseLockPort, cleanupTestSessions, initTestGitRepo, waitForFooterMessage, } from './test-utils.js';
|
|
23
23
|
import { stopOpencodeServer } from './opencode.js';
|
|
24
24
|
import { createDiscordRest } from './discord-urls.js';
|
|
25
25
|
import { store } from './store.js';
|
|
@@ -55,6 +55,7 @@ function createRunDirectories() {
|
|
|
55
55
|
const dataDir = fs.mkdtempSync(path.join(root, 'data-'));
|
|
56
56
|
const projectDirectory = path.join(root, 'project');
|
|
57
57
|
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
58
|
+
initTestGitRepo(projectDirectory);
|
|
58
59
|
return { root, dataDir, projectDirectory };
|
|
59
60
|
}
|
|
60
61
|
function createDiscordJsClient({ restUrl }) {
|
|
@@ -10,7 +10,7 @@ import { setDataDir } from './config.js';
|
|
|
10
10
|
import { startDiscordBot } from './discord-bot.js';
|
|
11
11
|
import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, } from './database.js';
|
|
12
12
|
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
13
|
-
import { cleanupTestSessions, chooseLockPort } from './test-utils.js';
|
|
13
|
+
import { cleanupTestSessions, chooseLockPort, initTestGitRepo } from './test-utils.js';
|
|
14
14
|
import { stopOpencodeServer } from './opencode.js';
|
|
15
15
|
const geminiApiKey = process.env['GEMINI_API_KEY'] ||
|
|
16
16
|
process.env['GOOGLE_GENERATIVE_AI_API_KEY'] ||
|
|
@@ -24,6 +24,7 @@ function createRunDirectories() {
|
|
|
24
24
|
const projectDirectory = path.join(root, 'project');
|
|
25
25
|
const providerCacheDbPath = path.join(root, 'provider-cache.db');
|
|
26
26
|
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
27
|
+
initTestGitRepo(projectDirectory);
|
|
27
28
|
return {
|
|
28
29
|
root,
|
|
29
30
|
dataDir,
|
package/dist/markdown.test.js
CHANGED
|
@@ -11,13 +11,14 @@ import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provid
|
|
|
11
11
|
import { ShareMarkdown, getCompactSessionContext } from './markdown.js';
|
|
12
12
|
import { setDataDir } from './config.js';
|
|
13
13
|
import { initializeOpencodeForDirectory, getOpencodeClient, stopOpencodeServer } from './opencode.js';
|
|
14
|
-
import { cleanupTestSessions } from './test-utils.js';
|
|
14
|
+
import { cleanupTestSessions, initTestGitRepo } from './test-utils.js';
|
|
15
15
|
const ROOT = path.resolve(process.cwd(), 'tmp', 'markdown-test');
|
|
16
16
|
function createRunDirectories() {
|
|
17
17
|
fs.mkdirSync(ROOT, { recursive: true });
|
|
18
18
|
const dataDir = fs.mkdtempSync(path.join(ROOT, 'data-'));
|
|
19
19
|
const projectDirectory = path.join(ROOT, 'project');
|
|
20
20
|
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
21
|
+
initTestGitRepo(projectDirectory);
|
|
21
22
|
return { dataDir, projectDirectory };
|
|
22
23
|
}
|
|
23
24
|
function createMatchers() {
|
|
@@ -139,7 +140,9 @@ function normalizeMarkdown(md) {
|
|
|
139
140
|
// Normalize opencode version
|
|
140
141
|
.replace(/\*\*OpenCode Version\*\*: v[\d.]+.*/g, '**OpenCode Version**: v<version>')
|
|
141
142
|
// Strip git branch context injected by opencode into user messages
|
|
142
|
-
.replace(/\[Current branch: [^\]]+\]\n?\n?/g, '')
|
|
143
|
+
.replace(/\[Current branch: [^\]]+\]\n?\n?/g, '')
|
|
144
|
+
.replace(/\[current git branch is [^\]]+\]\n?\n?/g, '')
|
|
145
|
+
.replace(/\[warning: repository is in detached HEAD[^\]]*\]\n?\n?/g, '');
|
|
143
146
|
}
|
|
144
147
|
test('generate markdown with system info', async () => {
|
|
145
148
|
const exporter = new ShareMarkdown(client);
|
|
@@ -171,8 +174,6 @@ test('generate markdown with system info', async () => {
|
|
|
171
174
|
|
|
172
175
|
### 👤 User
|
|
173
176
|
|
|
174
|
-
[current git branch is main]
|
|
175
|
-
|
|
176
177
|
hello markdown test
|
|
177
178
|
|
|
178
179
|
|
|
@@ -206,8 +207,6 @@ test('generate markdown without system info', async () => {
|
|
|
206
207
|
|
|
207
208
|
### 👤 User
|
|
208
209
|
|
|
209
|
-
[current git branch is main]
|
|
210
|
-
|
|
211
210
|
hello markdown test
|
|
212
211
|
|
|
213
212
|
|
|
@@ -13,13 +13,14 @@ import { test, expect, beforeAll, afterAll } from 'vitest';
|
|
|
13
13
|
import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provider';
|
|
14
14
|
import { setDataDir } from './config.js';
|
|
15
15
|
import { initializeOpencodeForDirectory, stopOpencodeServer } from './opencode.js';
|
|
16
|
-
import { cleanupTestSessions } from './test-utils.js';
|
|
16
|
+
import { cleanupTestSessions, initTestGitRepo } from './test-utils.js';
|
|
17
17
|
const ROOT = path.resolve(process.cwd(), 'tmp', 'finish-field-e2e');
|
|
18
18
|
function createRunDirectories() {
|
|
19
19
|
fs.mkdirSync(ROOT, { recursive: true });
|
|
20
20
|
const dataDir = fs.mkdtempSync(path.join(ROOT, 'data-'));
|
|
21
21
|
const projectDirectory = path.join(ROOT, 'project');
|
|
22
22
|
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
23
|
+
initTestGitRepo(projectDirectory);
|
|
23
24
|
return { dataDir, projectDirectory };
|
|
24
25
|
}
|
|
25
26
|
function createMatchers() {
|
package/dist/opencode.js
CHANGED
|
@@ -334,8 +334,9 @@ async function startSingleServer() {
|
|
|
334
334
|
const kimakiDataDir = path
|
|
335
335
|
.join(os.homedir(), '.kimaki')
|
|
336
336
|
.replaceAll('\\', '/');
|
|
337
|
+
// No catch-all '*': 'ask' here — the user's opencode.json default is respected.
|
|
338
|
+
// Only allowlist specific known-safe directories at the server level.
|
|
337
339
|
const externalDirectoryPermissions = {
|
|
338
|
-
'*': 'ask',
|
|
339
340
|
'/tmp': 'allow',
|
|
340
341
|
'/tmp/*': 'allow',
|
|
341
342
|
'/private/tmp': 'allow',
|
|
@@ -653,8 +654,6 @@ export function buildSessionPermissions({ directory, originalRepoDirectory, }) {
|
|
|
653
654
|
const normalizedDirectory = directory.replaceAll('\\', '/');
|
|
654
655
|
const originalRepo = originalRepoDirectory?.replaceAll('\\', '/');
|
|
655
656
|
const rules = [
|
|
656
|
-
// Base rule: ask for unknown external directories
|
|
657
|
-
{ permission: 'external_directory', pattern: '*', action: 'ask' },
|
|
658
657
|
// Allow tmpdir access
|
|
659
658
|
{ permission: 'external_directory', pattern: '/tmp', action: 'allow' },
|
|
660
659
|
{ permission: 'external_directory', pattern: '/tmp/*', action: 'allow' },
|
|
@@ -748,22 +747,29 @@ export function parsePermissionRules(raw) {
|
|
|
748
747
|
});
|
|
749
748
|
}
|
|
750
749
|
// ── Injection guard per-session config ───────────────────────────
|
|
751
|
-
// Per-session injection guard patterns are written as JSON files to
|
|
752
|
-
//
|
|
753
|
-
// the opencode server process)
|
|
750
|
+
// Per-session injection guard patterns are written as JSON files to
|
|
751
|
+
// <dataDir>/injection-guard/<sessionId>.json. The injection guard plugin
|
|
752
|
+
// (running inside the opencode server process) reads KIMAKI_DATA_DIR env
|
|
753
|
+
// var to find these files in tool.execute.after.
|
|
754
754
|
// This avoids needing env vars (which are per-process, not per-session).
|
|
755
|
-
|
|
755
|
+
function getInjectionGuardDir() {
|
|
756
|
+
return path.join(getDataDir(), 'injection-guard');
|
|
757
|
+
}
|
|
756
758
|
/**
|
|
757
759
|
* Write per-session injection guard config so the plugin picks it up.
|
|
758
760
|
* Only call this if injectionGuardPatterns is non-empty.
|
|
759
761
|
*/
|
|
760
762
|
export function writeInjectionGuardConfig({ sessionId, scanPatterns, }) {
|
|
763
|
+
if (scanPatterns.length === 0) {
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
761
766
|
try {
|
|
762
|
-
|
|
763
|
-
fs.
|
|
767
|
+
const dir = getInjectionGuardDir();
|
|
768
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
769
|
+
fs.writeFileSync(path.join(dir, `${sessionId}.json`), JSON.stringify({ scanPatterns }));
|
|
764
770
|
}
|
|
765
771
|
catch {
|
|
766
|
-
// Best effort -- don't crash the bot if
|
|
772
|
+
// Best effort -- don't crash the bot if data dir write fails
|
|
767
773
|
}
|
|
768
774
|
}
|
|
769
775
|
/**
|
|
@@ -771,7 +777,7 @@ export function writeInjectionGuardConfig({ sessionId, scanPatterns, }) {
|
|
|
771
777
|
*/
|
|
772
778
|
export function removeInjectionGuardConfig({ sessionId }) {
|
|
773
779
|
try {
|
|
774
|
-
fs.unlinkSync(path.join(
|
|
780
|
+
fs.unlinkSync(path.join(getInjectionGuardDir(), `${sessionId}.json`));
|
|
775
781
|
}
|
|
776
782
|
catch {
|
|
777
783
|
// File may already be gone
|
|
@@ -783,7 +789,7 @@ export function removeInjectionGuardConfig({ sessionId }) {
|
|
|
783
789
|
*/
|
|
784
790
|
export function readInjectionGuardConfig({ sessionId }) {
|
|
785
791
|
try {
|
|
786
|
-
const raw = fs.readFileSync(path.join(
|
|
792
|
+
const raw = fs.readFileSync(path.join(getInjectionGuardDir(), `${sessionId}.json`), 'utf-8');
|
|
787
793
|
return JSON.parse(raw);
|
|
788
794
|
}
|
|
789
795
|
catch {
|
|
@@ -78,21 +78,20 @@ e2eTest('queue advanced: abort and retry', () => {
|
|
|
78
78
|
afterMessageIncludes: 'papa',
|
|
79
79
|
afterAuthorId: TEST_USER_ID,
|
|
80
80
|
});
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
`);
|
|
81
|
+
// Assert ordering invariants instead of exact snapshot — the papa reply
|
|
82
|
+
// and footer can interleave non-deterministically.
|
|
83
|
+
const timeline = await th.text();
|
|
84
|
+
expect(timeline).toContain('Reply with exactly: oscar');
|
|
85
|
+
expect(timeline).toContain('PLUGIN_TIMEOUT_SLEEP_MARKER');
|
|
86
|
+
expect(timeline).toContain('⬥ starting sleep 100');
|
|
87
|
+
expect(timeline).toContain('Reply with exactly: papa');
|
|
88
|
+
expect(timeline).toContain('*project ⋅ main ⋅');
|
|
89
|
+
// oscar comes before the sleep marker, sleep before papa
|
|
90
|
+
const oscarIdx = timeline.indexOf('oscar');
|
|
91
|
+
const sleepIdx = timeline.indexOf('PLUGIN_TIMEOUT_SLEEP_MARKER');
|
|
92
|
+
const papaIdx = timeline.indexOf('papa');
|
|
93
|
+
expect(oscarIdx).toBeLessThan(sleepIdx);
|
|
94
|
+
expect(sleepIdx).toBeLessThan(papaIdx);
|
|
96
95
|
expect(afterBotMessages.length).toBeGreaterThanOrEqual(beforeBotCount + 1);
|
|
97
96
|
const sleepToolIndex = after.findIndex((m) => {
|
|
98
97
|
return (m.author.id === TEST_USER_ID &&
|
|
@@ -7,6 +7,7 @@ import { beforeAll, afterAll, afterEach, expect } from 'vitest';
|
|
|
7
7
|
import { ChannelType, Client, GatewayIntentBits, Partials } from 'discord.js';
|
|
8
8
|
import { DigitalDiscord } from 'discord-digital-twin/src';
|
|
9
9
|
import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provider';
|
|
10
|
+
import { initTestGitRepo } from './test-utils.js';
|
|
10
11
|
import { setDataDir } from './config.js';
|
|
11
12
|
import { store } from './store.js';
|
|
12
13
|
import { startDiscordBot } from './discord-bot.js';
|
|
@@ -21,6 +22,7 @@ export function createRunDirectories({ name }) {
|
|
|
21
22
|
const dataDir = fs.mkdtempSync(path.join(root, 'data-'));
|
|
22
23
|
const projectDirectory = path.join(root, 'project');
|
|
23
24
|
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
25
|
+
initTestGitRepo(projectDirectory);
|
|
24
26
|
return { root, dataDir, projectDirectory };
|
|
25
27
|
}
|
|
26
28
|
export function chooseLockPort({ channelId }) {
|
|
@@ -81,11 +81,7 @@ describe('queue advanced: typing around permissions', () => {
|
|
|
81
81
|
afterMessageIncludes: 'permission-flow-done',
|
|
82
82
|
afterAuthorId: ctx.discord.botUserId,
|
|
83
83
|
});
|
|
84
|
-
|
|
85
|
-
showTyping: true,
|
|
86
|
-
showInteractions: true,
|
|
87
|
-
});
|
|
88
|
-
expect(timeline).toMatchInlineSnapshot(`
|
|
84
|
+
expect(await th.text({ showInteractions: true })).toMatchInlineSnapshot(`
|
|
89
85
|
"--- from: user (queue-permission-tester)
|
|
90
86
|
PERMISSION_TYPING_MARKER
|
|
91
87
|
--- from: assistant (TestBot)
|
|
@@ -96,12 +92,24 @@ describe('queue advanced: typing around permissions', () => {
|
|
|
96
92
|
✅ Permission **accepted**
|
|
97
93
|
⬥ requesting external read permission
|
|
98
94
|
[user clicks button]
|
|
99
|
-
[bot typing]
|
|
100
95
|
⬥ permission-flow-done
|
|
101
|
-
[bot typing]
|
|
102
|
-
[bot typing]
|
|
103
96
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
104
97
|
`);
|
|
98
|
+
const timeline = await th.text({
|
|
99
|
+
showTyping: true,
|
|
100
|
+
showInteractions: true,
|
|
101
|
+
});
|
|
102
|
+
const clickPosition = timeline.indexOf('[user clicks button]');
|
|
103
|
+
const donePosition = timeline.indexOf('⬥ permission-flow-done');
|
|
104
|
+
const footerPosition = timeline.lastIndexOf('*project ⋅');
|
|
105
|
+
expect(clickPosition).toBeGreaterThanOrEqual(0);
|
|
106
|
+
expect(donePosition).toBeGreaterThan(clickPosition);
|
|
107
|
+
expect(footerPosition).toBeGreaterThan(donePosition);
|
|
108
|
+
const afterClick = timeline.slice(clickPosition, donePosition);
|
|
109
|
+
const afterDone = timeline.slice(donePosition, footerPosition);
|
|
110
|
+
expect(afterClick).toContain('[bot typing]');
|
|
111
|
+
expect(afterDone).toContain('[bot typing]');
|
|
112
|
+
expect(timeline.slice(footerPosition)).not.toContain('[bot typing]');
|
|
105
113
|
}, 20_000);
|
|
106
114
|
test('manual thread message dismisses pending permission and sends the new prompt', async () => {
|
|
107
115
|
const initialPrompt = 'PERMISSION_TYPING_MARKER dismiss-flow';
|
|
@@ -159,20 +167,14 @@ describe('queue advanced: typing around permissions', () => {
|
|
|
159
167
|
});
|
|
160
168
|
const timeline = await th.text({ showInteractions: true });
|
|
161
169
|
const normalizedTimeline = timeline.replace('⬥ requesting external read permission\n', '');
|
|
162
|
-
expect(normalizedTimeline).
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
--- from: user (queue-permission-tester)
|
|
172
|
-
Reply with exactly: post-permission-user-message
|
|
173
|
-
--- from: assistant (TestBot)
|
|
174
|
-
⬥ ok
|
|
175
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
176
|
-
`);
|
|
170
|
+
expect(normalizedTimeline).toContain('PERMISSION_TYPING_MARKER dismiss-flow');
|
|
171
|
+
expect(normalizedTimeline).toContain('Permission dismissed - user sent a new message.');
|
|
172
|
+
expect(normalizedTimeline).toContain('Reply with exactly: post-permission-user-message');
|
|
173
|
+
const followupUserPosition = normalizedTimeline.indexOf('Reply with exactly: post-permission-user-message');
|
|
174
|
+
const followupReplyPosition = normalizedTimeline.indexOf('⬥ ok', followupUserPosition);
|
|
175
|
+
const followupFooterPosition = normalizedTimeline.indexOf('*project ⋅', followupReplyPosition);
|
|
176
|
+
expect(followupUserPosition).toBeGreaterThanOrEqual(0);
|
|
177
|
+
expect(followupReplyPosition).toBeGreaterThan(followupUserPosition);
|
|
178
|
+
expect(followupFooterPosition).toBeGreaterThan(followupReplyPosition);
|
|
177
179
|
}, 20_000);
|
|
178
180
|
});
|
|
@@ -59,24 +59,21 @@ describe('queue advanced: question tool answer', () => {
|
|
|
59
59
|
content: 'QUESTION_TEXT_ANSWER_MARKER',
|
|
60
60
|
});
|
|
61
61
|
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
62
|
-
timeout:
|
|
62
|
+
timeout: 8_000,
|
|
63
63
|
predicate: (t) => {
|
|
64
64
|
return t.name === 'QUESTION_TEXT_ANSWER_MARKER';
|
|
65
65
|
},
|
|
66
66
|
});
|
|
67
67
|
const th = ctx.discord.thread(thread.id);
|
|
68
|
-
// Wait for the question dropdown to appear
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
});
|
|
73
|
-
expect(pending.contextHash).toBeTruthy();
|
|
74
|
-
// Verify dropdown message appeared
|
|
68
|
+
// Wait for the question dropdown message to appear in Discord.
|
|
69
|
+
// This is the user-visible signal that the question tool fired and
|
|
70
|
+
// kimaki processed the event. Avoids polling internal Maps which
|
|
71
|
+
// have timing sensitivity on slower CI hardware.
|
|
75
72
|
await waitForBotMessageContaining({
|
|
76
73
|
discord: ctx.discord,
|
|
77
74
|
threadId: thread.id,
|
|
78
75
|
text: 'Which option do you prefer?',
|
|
79
|
-
timeout:
|
|
76
|
+
timeout: 12_000,
|
|
80
77
|
});
|
|
81
78
|
// User sends a text message while question is pending.
|
|
82
79
|
// This should:
|
|
@@ -86,27 +83,17 @@ describe('queue advanced: question tool answer', () => {
|
|
|
86
83
|
await th.user(TEST_USER_ID).sendMessage({
|
|
87
84
|
content: 'my text answer',
|
|
88
85
|
});
|
|
89
|
-
//
|
|
90
|
-
await
|
|
91
|
-
|
|
92
|
-
timeoutMs: 4_000,
|
|
86
|
+
// Give time for question cleanup to propagate
|
|
87
|
+
await new Promise((r) => {
|
|
88
|
+
setTimeout(r, 1_000);
|
|
93
89
|
});
|
|
94
90
|
const timeline = await th.text({ showInteractions: true });
|
|
95
|
-
|
|
96
|
-
"--- from: user (queue-question-tester)
|
|
97
|
-
QUESTION_TEXT_ANSWER_MARKER
|
|
98
|
-
--- from: assistant (TestBot)
|
|
99
|
-
**Pick one**
|
|
100
|
-
Which option do you prefer?
|
|
101
|
-
--- from: user (queue-question-tester)
|
|
102
|
-
my text answer"
|
|
103
|
-
`);
|
|
104
|
-
// The user's message must appear in Discord
|
|
91
|
+
// The user's text answer must appear in Discord
|
|
105
92
|
expect(timeline).toContain('my text answer');
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
expect(
|
|
93
|
+
// The original question must have appeared
|
|
94
|
+
expect(timeline).toContain('Which option do you prefer?');
|
|
95
|
+
// The user's marker message triggered the question
|
|
96
|
+
expect(timeline).toContain('QUESTION_TEXT_ANSWER_MARKER');
|
|
110
97
|
}, 20_000);
|
|
111
98
|
});
|
|
112
99
|
describe('queue advanced: voice message during pending question', () => {
|
|
@@ -129,22 +116,18 @@ describe('queue advanced: voice message during pending question', () => {
|
|
|
129
116
|
content: 'QUESTION_TEXT_ANSWER_MARKER',
|
|
130
117
|
});
|
|
131
118
|
const thread = await ctx.discord.channel(VOICE_CHANNEL_ID).waitForThread({
|
|
132
|
-
timeout:
|
|
119
|
+
timeout: 8_000,
|
|
133
120
|
predicate: (t) => {
|
|
134
121
|
return t.name === 'QUESTION_TEXT_ANSWER_MARKER';
|
|
135
122
|
},
|
|
136
123
|
});
|
|
137
124
|
const th = ctx.discord.thread(thread.id);
|
|
138
|
-
// Wait for the question dropdown to appear
|
|
139
|
-
await waitForPendingQuestion({
|
|
140
|
-
threadId: thread.id,
|
|
141
|
-
timeoutMs: 4_000,
|
|
142
|
-
});
|
|
125
|
+
// Wait for the question dropdown message to appear in Discord
|
|
143
126
|
await waitForBotMessageContaining({
|
|
144
127
|
discord: ctx.discord,
|
|
145
128
|
threadId: thread.id,
|
|
146
129
|
text: 'Which option do you prefer?',
|
|
147
|
-
timeout:
|
|
130
|
+
timeout: 12_000,
|
|
148
131
|
});
|
|
149
132
|
// Send a voice message while the question is pending.
|
|
150
133
|
// message.content is "" for voice messages — only the attachment exists.
|
|
@@ -153,10 +136,9 @@ describe('queue advanced: voice message during pending question', () => {
|
|
|
153
136
|
queueMessage: false,
|
|
154
137
|
});
|
|
155
138
|
await th.user(TEST_USER_ID).sendVoiceMessage();
|
|
156
|
-
//
|
|
157
|
-
await
|
|
158
|
-
|
|
159
|
-
timeoutMs: 4_000,
|
|
139
|
+
// Give time for question cleanup to propagate
|
|
140
|
+
await new Promise((r) => {
|
|
141
|
+
setTimeout(r, 1_000);
|
|
160
142
|
});
|
|
161
143
|
// Voice content should be transcribed and appear as the next user message,
|
|
162
144
|
// processed after the model responds to the empty question answer.
|
|
@@ -164,12 +146,12 @@ describe('queue advanced: voice message during pending question', () => {
|
|
|
164
146
|
discord: ctx.discord,
|
|
165
147
|
threadId: thread.id,
|
|
166
148
|
text: 'I want option Alpha please',
|
|
167
|
-
timeout:
|
|
149
|
+
timeout: 8_000,
|
|
168
150
|
});
|
|
169
151
|
await waitForFooterMessage({
|
|
170
152
|
discord: ctx.discord,
|
|
171
153
|
threadId: thread.id,
|
|
172
|
-
timeout:
|
|
154
|
+
timeout: 8_000,
|
|
173
155
|
afterMessageIncludes: 'I want option Alpha please',
|
|
174
156
|
afterAuthorId: ctx.discord.botUserId,
|
|
175
157
|
});
|
|
@@ -78,8 +78,7 @@ e2eTest('queue advanced: typing interrupt', () => {
|
|
|
78
78
|
&& message.content.startsWith('*')
|
|
79
79
|
&& message.content.includes('⋅');
|
|
80
80
|
});
|
|
81
|
-
|
|
82
|
-
expect(timeline).toMatchInlineSnapshot(`
|
|
81
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
83
82
|
"--- from: user (queue-advanced-tester)
|
|
84
83
|
Reply with exactly: typing-stop-interrupt-setup
|
|
85
84
|
--- from: assistant (TestBot)
|
|
@@ -87,23 +86,29 @@ e2eTest('queue advanced: typing interrupt', () => {
|
|
|
87
86
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
88
87
|
--- from: user (queue-advanced-tester)
|
|
89
88
|
PLUGIN_TIMEOUT_SLEEP_MARKER
|
|
90
|
-
[bot typing]
|
|
91
89
|
--- from: assistant (TestBot)
|
|
92
90
|
⬥ starting sleep 100
|
|
93
91
|
--- from: user (queue-advanced-tester)
|
|
94
92
|
Reply with exactly: typing-stop-interrupt-final
|
|
95
|
-
[bot typing]
|
|
96
|
-
[bot typing]
|
|
97
93
|
--- from: assistant (TestBot)
|
|
98
94
|
⬥ ok
|
|
99
95
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
100
96
|
`);
|
|
97
|
+
const timeline = await th.text({ showTyping: true });
|
|
101
98
|
expect(finalUserIndex).toBeGreaterThanOrEqual(0);
|
|
102
99
|
expect(finalReplyIndex).toBeGreaterThan(finalUserIndex);
|
|
103
100
|
expect(finalFooterIndex).toBeGreaterThan(finalReplyIndex);
|
|
104
101
|
expect(messages[finalFooterIndex]).toBeDefined();
|
|
102
|
+
const finalPromptPosition = timeline.indexOf('Reply with exactly: typing-stop-interrupt-final');
|
|
103
|
+
const finalReplyPosition = timeline.indexOf('--- from: assistant (TestBot)\n⬥ ok', finalPromptPosition);
|
|
105
104
|
const lastFooterPosition = timeline.lastIndexOf('*project ⋅');
|
|
105
|
+
expect(finalPromptPosition).toBeGreaterThanOrEqual(0);
|
|
106
|
+
expect(finalReplyPosition).toBeGreaterThan(finalPromptPosition);
|
|
106
107
|
expect(lastFooterPosition).toBeGreaterThanOrEqual(0);
|
|
108
|
+
const typingDuringFinalRun = timeline
|
|
109
|
+
.slice(finalPromptPosition, finalReplyPosition)
|
|
110
|
+
.match(/\[bot typing\]/g) || [];
|
|
111
|
+
expect(typingDuringFinalRun.length).toBeGreaterThanOrEqual(2);
|
|
107
112
|
expect(timeline.slice(lastFooterPosition)).not.toContain('[bot typing]');
|
|
108
113
|
}, 12_000);
|
|
109
114
|
});
|