@brianli/kimaki 0.4.72-brianli.5 → 0.4.73-brianli.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-model.e2e.test.js +414 -0
- package/dist/bot-token.js +10 -1
- package/dist/cli.js +308 -92
- package/dist/commands/abort.js +27 -32
- package/dist/commands/action-buttons.js +11 -21
- package/dist/commands/create-new-project.js +11 -3
- package/dist/commands/login.js +50 -8
- package/dist/commands/merge-worktree.js +12 -25
- package/dist/commands/model.js +7 -9
- package/dist/commands/queue.js +62 -72
- package/dist/commands/restart-opencode-server.js +10 -47
- package/dist/commands/session.js +11 -3
- package/dist/commands/unset-model.js +6 -10
- package/dist/commands/user-command.js +27 -11
- package/dist/commands/verbosity.js +100 -35
- package/dist/config.js +16 -47
- package/dist/database.js +87 -9
- package/dist/db.js +18 -0
- package/dist/db.test.js +1 -40
- package/dist/discord-bot.js +127 -144
- package/dist/discord-urls.js +70 -0
- package/dist/discord-utils.js +2 -13
- package/dist/gateway-proxy.e2e.test.js +423 -0
- package/dist/generated/internal/class.js +2 -2
- package/dist/generated/internal/prismaNamespace.js +4 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js +4 -0
- package/dist/interaction-handler.js +9 -7
- package/dist/kimaki-digital-twin.e2e.test.js +7 -11
- package/dist/logger.js +17 -2
- package/dist/markdown.test.js +215 -210
- package/dist/opencode-plugin.js +117 -8
- package/dist/opencode.js +17 -8
- package/dist/runtime-lifecycle.e2e.test.js +388 -0
- package/dist/session-handler/agent-utils.js +68 -0
- package/dist/session-handler/model-utils.js +125 -0
- package/dist/session-handler/opencode-session-event-log.js +86 -0
- package/dist/session-handler/state.js +43 -141
- package/dist/session-handler/state.test.js +52 -0
- package/dist/session-handler/thread-runtime-state.js +167 -0
- package/dist/session-handler/thread-session-runtime.js +2370 -0
- package/dist/session-handler.js +10 -1874
- package/dist/store.js +22 -0
- package/dist/system-message.js +7 -3
- package/dist/task-runner.js +2 -2
- package/dist/test-utils.js +214 -0
- package/dist/thread-message-queue.e2e.test.js +317 -325
- package/dist/thread-queue-advanced.e2e.test.js +671 -0
- package/dist/tools.js +2 -4
- package/dist/utils.js +10 -1
- package/dist/voice-handler.js +34 -6
- package/dist/voice-message.e2e.test.js +729 -0
- package/package.json +7 -6
- package/schema.prisma +7 -3
- package/skills/zustand-centralized-state/SKILL.md +426 -4
- package/src/agent-model.e2e.test.ts +542 -0
- package/src/cli.ts +373 -108
- package/src/commands/abort.ts +26 -38
- package/src/commands/action-buttons.ts +12 -27
- package/src/commands/create-new-project.ts +12 -4
- package/src/commands/login.ts +56 -10
- package/src/commands/merge-worktree.ts +14 -35
- package/src/commands/model.ts +7 -9
- package/src/commands/queue.ts +68 -92
- package/src/commands/restart-opencode-server.ts +10 -59
- package/src/commands/session.ts +11 -3
- package/src/commands/unset-model.ts +6 -10
- package/src/commands/user-command.ts +28 -12
- package/src/commands/verbosity.ts +123 -38
- package/src/config.ts +17 -72
- package/src/database.ts +122 -11
- package/src/db.test.ts +1 -51
- package/src/db.ts +18 -0
- package/src/discord-bot.ts +139 -160
- package/src/discord-urls.ts +76 -0
- package/src/discord-utils.ts +2 -18
- package/src/gateway-proxy.e2e.test.ts +567 -0
- package/src/generated/internal/class.ts +2 -2
- package/src/generated/internal/prismaNamespace.ts +4 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +4 -0
- package/src/generated/models/bot_tokens.ts +181 -1
- package/src/generated/models/channel_directories.ts +0 -4
- package/src/hrana-server.ts +1 -0
- package/src/interaction-handler.ts +15 -7
- package/src/kimaki-digital-twin.e2e.test.ts +8 -12
- package/src/logger.ts +20 -2
- package/src/markdown.test.ts +236 -287
- package/src/opencode-plugin.ts +134 -6
- package/src/opencode.ts +17 -8
- package/src/runtime-lifecycle.e2e.test.ts +485 -0
- package/src/schema.sql +4 -0
- package/src/session-handler/agent-utils.ts +98 -0
- package/src/session-handler/model-utils.ts +184 -0
- package/src/session-handler/opencode-session-event-log.ts +115 -0
- package/src/session-handler/state.test.ts +64 -0
- package/src/session-handler/state.ts +60 -209
- package/src/session-handler/thread-runtime-state.ts +383 -0
- package/src/session-handler/thread-session-runtime.ts +3083 -0
- package/src/session-handler.ts +16 -2668
- package/src/store.ts +117 -0
- package/src/system-message.ts +7 -3
- package/src/task-runner.ts +2 -2
- package/src/test-utils.ts +323 -0
- package/src/thread-message-queue.e2e.test.ts +381 -410
- package/src/thread-queue-advanced.e2e.test.ts +833 -0
- package/src/tools.ts +2 -4
- package/src/utils.ts +19 -1
- package/src/voice-handler.ts +40 -6
- package/src/voice-message.e2e.test.ts +907 -0
- package/src/__snapshots__/compact-session-context-no-system.md +0 -35
- package/src/__snapshots__/compact-session-context.md +0 -41
- package/src/__snapshots__/first-session-no-info.md +0 -17
- package/src/__snapshots__/first-session-with-info.md +0 -23
- package/src/__snapshots__/session-1.md +0 -17
- package/src/__snapshots__/session-2.md +0 -5871
- package/src/__snapshots__/session-3.md +0 -17
- package/src/__snapshots__/session-with-tools.md +0 -5871
- package/src/bot-token.test.ts +0 -171
- package/src/bot-token.ts +0 -159
- package/src/discord-api.ts +0 -35
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
// E2e test for agent model resolution in new threads.
|
|
2
|
+
// Reproduces a bug where /agent channel preference is ignored by the
|
|
3
|
+
// promptAsync path: submitViaOpencodeQueue only passes input.agent/input.model
|
|
4
|
+
// (undefined for normal Discord messages) instead of resolving channel agent
|
|
5
|
+
// preferences from DB like dispatchPrompt does.
|
|
6
|
+
//
|
|
7
|
+
// The test sets a channel agent with a custom model, sends a message,
|
|
8
|
+
// and verifies the footer contains the agent's model — not the default.
|
|
9
|
+
//
|
|
10
|
+
// Uses opencode-deterministic-provider (no real LLM calls).
|
|
11
|
+
// Poll timeouts: 4s max, 100ms interval.
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import url from 'node:url';
|
|
15
|
+
import { describe, beforeAll, afterAll, beforeEach, onTestFailed, test, expect, } from 'vitest';
|
|
16
|
+
import { ChannelType, Client, GatewayIntentBits, Partials } from 'discord.js';
|
|
17
|
+
import { DigitalDiscord } from 'discord-digital-twin/src';
|
|
18
|
+
import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provider';
|
|
19
|
+
import { setDataDir } from './config.js';
|
|
20
|
+
import { store } from './store.js';
|
|
21
|
+
import { startDiscordBot } from './discord-bot.js';
|
|
22
|
+
import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, setChannelAgent, setChannelModel, } from './database.js';
|
|
23
|
+
import { getPrisma } from './db.js';
|
|
24
|
+
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
25
|
+
import { initializeOpencodeForDirectory } from './opencode.js';
|
|
26
|
+
import { cleanupOpencodeServers, cleanupTestSessions, waitForBotMessageContaining, } from './test-utils.js';
|
|
27
|
+
import { getLogEntryCount, getLogEntriesSince } from './logger.js';
|
|
28
|
+
const TEST_USER_ID = '200000000000000920';
|
|
29
|
+
const TEXT_CHANNEL_ID = '200000000000000921';
|
|
30
|
+
const AGENT_MODEL = 'agent-model-v2';
|
|
31
|
+
const CHANNEL_MODEL = 'channel-model-v2';
|
|
32
|
+
const DEFAULT_MODEL = 'deterministic-v2';
|
|
33
|
+
const PROVIDER_NAME = 'deterministic-provider';
|
|
34
|
+
function createRunDirectories() {
|
|
35
|
+
const root = path.resolve(process.cwd(), 'tmp', 'agent-model-e2e');
|
|
36
|
+
fs.mkdirSync(root, { recursive: true });
|
|
37
|
+
const dataDir = fs.mkdtempSync(path.join(root, 'data-'));
|
|
38
|
+
const projectDirectory = path.join(root, 'project');
|
|
39
|
+
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
40
|
+
return { root, dataDir, projectDirectory };
|
|
41
|
+
}
|
|
42
|
+
function chooseLockPort() {
|
|
43
|
+
return 53_000 + (Date.now() % 2_000);
|
|
44
|
+
}
|
|
45
|
+
function createDiscordJsClient({ restUrl }) {
|
|
46
|
+
return new Client({
|
|
47
|
+
intents: [
|
|
48
|
+
GatewayIntentBits.Guilds,
|
|
49
|
+
GatewayIntentBits.GuildMessages,
|
|
50
|
+
GatewayIntentBits.MessageContent,
|
|
51
|
+
GatewayIntentBits.GuildVoiceStates,
|
|
52
|
+
],
|
|
53
|
+
partials: [
|
|
54
|
+
Partials.Channel,
|
|
55
|
+
Partials.Message,
|
|
56
|
+
Partials.User,
|
|
57
|
+
Partials.ThreadMember,
|
|
58
|
+
],
|
|
59
|
+
rest: {
|
|
60
|
+
api: restUrl,
|
|
61
|
+
version: '10',
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function createDeterministicMatchers() {
|
|
66
|
+
const systemContextMatcher = {
|
|
67
|
+
id: 'system-context-check',
|
|
68
|
+
priority: 20,
|
|
69
|
+
when: {
|
|
70
|
+
lastMessageRole: 'user',
|
|
71
|
+
latestUserTextIncludes: 'Reply with exactly: system-context-check',
|
|
72
|
+
rawPromptIncludes: `Current Discord user ID is: ${TEST_USER_ID}`,
|
|
73
|
+
},
|
|
74
|
+
then: {
|
|
75
|
+
parts: [
|
|
76
|
+
{ type: 'stream-start', warnings: [] },
|
|
77
|
+
{ type: 'text-start', id: 'system-context-reply' },
|
|
78
|
+
{
|
|
79
|
+
type: 'text-delta',
|
|
80
|
+
id: 'system-context-reply',
|
|
81
|
+
delta: 'system-context-ok',
|
|
82
|
+
},
|
|
83
|
+
{ type: 'text-end', id: 'system-context-reply' },
|
|
84
|
+
{
|
|
85
|
+
type: 'finish',
|
|
86
|
+
finishReason: 'stop',
|
|
87
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
partDelaysMs: [0, 100, 0, 0, 0],
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
const userReplyMatcher = {
|
|
94
|
+
id: 'user-reply',
|
|
95
|
+
priority: 10,
|
|
96
|
+
when: {
|
|
97
|
+
lastMessageRole: 'user',
|
|
98
|
+
latestUserTextIncludes: 'Reply with exactly:',
|
|
99
|
+
},
|
|
100
|
+
then: {
|
|
101
|
+
parts: [
|
|
102
|
+
{ type: 'stream-start', warnings: [] },
|
|
103
|
+
{ type: 'text-start', id: 'default-reply' },
|
|
104
|
+
{ type: 'text-delta', id: 'default-reply', delta: 'ok' },
|
|
105
|
+
{ type: 'text-end', id: 'default-reply' },
|
|
106
|
+
{
|
|
107
|
+
type: 'finish',
|
|
108
|
+
finishReason: 'stop',
|
|
109
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
partDelaysMs: [0, 100, 0, 0, 0],
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
return [systemContextMatcher, userReplyMatcher];
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Create an opencode agent .md file that uses a specific model.
|
|
119
|
+
* OpenCode discovers agents from .opencode/agent/*.md files.
|
|
120
|
+
*/
|
|
121
|
+
function createAgentFile({ projectDirectory, agentName, model, }) {
|
|
122
|
+
const agentDir = path.join(projectDirectory, '.opencode', 'agent');
|
|
123
|
+
fs.mkdirSync(agentDir, { recursive: true });
|
|
124
|
+
const content = [
|
|
125
|
+
'---',
|
|
126
|
+
`model: ${model}`,
|
|
127
|
+
'mode: primary',
|
|
128
|
+
`description: Test agent with custom model`,
|
|
129
|
+
'---',
|
|
130
|
+
'',
|
|
131
|
+
'You are a test agent. Reply concisely.',
|
|
132
|
+
'',
|
|
133
|
+
].join('\n');
|
|
134
|
+
fs.writeFileSync(path.join(agentDir, `${agentName}.md`), content);
|
|
135
|
+
}
|
|
136
|
+
describe('agent model resolution', () => {
|
|
137
|
+
let directories;
|
|
138
|
+
let discord;
|
|
139
|
+
let botClient;
|
|
140
|
+
let previousDefaultVerbosity = null;
|
|
141
|
+
let testStartTime = Date.now();
|
|
142
|
+
beforeAll(async () => {
|
|
143
|
+
testStartTime = Date.now();
|
|
144
|
+
directories = createRunDirectories();
|
|
145
|
+
const lockPort = chooseLockPort();
|
|
146
|
+
process.env['KIMAKI_LOCK_PORT'] = String(lockPort);
|
|
147
|
+
setDataDir(directories.dataDir);
|
|
148
|
+
previousDefaultVerbosity = store.getState().defaultVerbosity;
|
|
149
|
+
store.setState({ defaultVerbosity: 'tools-and-text' });
|
|
150
|
+
discord = new DigitalDiscord({
|
|
151
|
+
guild: {
|
|
152
|
+
name: 'Agent Model E2E Guild',
|
|
153
|
+
ownerId: TEST_USER_ID,
|
|
154
|
+
},
|
|
155
|
+
channels: [
|
|
156
|
+
{
|
|
157
|
+
id: TEXT_CHANNEL_ID,
|
|
158
|
+
name: 'agent-model-e2e',
|
|
159
|
+
type: ChannelType.GuildText,
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
users: [
|
|
163
|
+
{
|
|
164
|
+
id: TEST_USER_ID,
|
|
165
|
+
username: 'agent-model-tester',
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
await discord.start();
|
|
170
|
+
const providerNpm = url
|
|
171
|
+
.pathToFileURL(path.resolve(process.cwd(), '..', 'opencode-deterministic-provider', 'src', 'index.ts'))
|
|
172
|
+
.toString();
|
|
173
|
+
// Build base config with default model
|
|
174
|
+
const opencodeConfig = buildDeterministicOpencodeConfig({
|
|
175
|
+
providerName: PROVIDER_NAME,
|
|
176
|
+
providerNpm,
|
|
177
|
+
model: DEFAULT_MODEL,
|
|
178
|
+
smallModel: DEFAULT_MODEL,
|
|
179
|
+
settings: {
|
|
180
|
+
strict: false,
|
|
181
|
+
matchers: createDeterministicMatchers(),
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
// Add extra models to the provider so opencode accepts them
|
|
185
|
+
const providerConfig = opencodeConfig.provider[PROVIDER_NAME];
|
|
186
|
+
providerConfig.models[AGENT_MODEL] = { name: AGENT_MODEL };
|
|
187
|
+
providerConfig.models[CHANNEL_MODEL] = { name: CHANNEL_MODEL };
|
|
188
|
+
fs.writeFileSync(path.join(directories.projectDirectory, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2));
|
|
189
|
+
// Create the agent .md file with custom model
|
|
190
|
+
createAgentFile({
|
|
191
|
+
projectDirectory: directories.projectDirectory,
|
|
192
|
+
agentName: 'test-agent',
|
|
193
|
+
model: `${PROVIDER_NAME}/${AGENT_MODEL}`,
|
|
194
|
+
});
|
|
195
|
+
const dbPath = path.join(directories.dataDir, 'discord-sessions.db');
|
|
196
|
+
const hranaResult = await startHranaServer({ dbPath });
|
|
197
|
+
if (hranaResult instanceof Error) {
|
|
198
|
+
throw hranaResult;
|
|
199
|
+
}
|
|
200
|
+
process.env['KIMAKI_DB_URL'] = hranaResult;
|
|
201
|
+
await initDatabase();
|
|
202
|
+
await setBotToken(discord.botUserId, discord.botToken);
|
|
203
|
+
await setChannelDirectory({
|
|
204
|
+
channelId: TEXT_CHANNEL_ID,
|
|
205
|
+
directory: directories.projectDirectory,
|
|
206
|
+
channelType: 'text',
|
|
207
|
+
appId: discord.botUserId,
|
|
208
|
+
});
|
|
209
|
+
await setChannelVerbosity(TEXT_CHANNEL_ID, 'tools-and-text');
|
|
210
|
+
botClient = createDiscordJsClient({ restUrl: discord.restUrl });
|
|
211
|
+
await startDiscordBot({
|
|
212
|
+
token: discord.botToken,
|
|
213
|
+
appId: discord.botUserId,
|
|
214
|
+
discordClient: botClient,
|
|
215
|
+
});
|
|
216
|
+
// Pre-warm the opencode server so agent discovery happens
|
|
217
|
+
const warmup = await initializeOpencodeForDirectory(directories.projectDirectory);
|
|
218
|
+
if (warmup instanceof Error) {
|
|
219
|
+
throw warmup;
|
|
220
|
+
}
|
|
221
|
+
}, 60_000);
|
|
222
|
+
afterAll(async () => {
|
|
223
|
+
if (directories) {
|
|
224
|
+
await cleanupTestSessions({
|
|
225
|
+
projectDirectory: directories.projectDirectory,
|
|
226
|
+
testStartTime,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (botClient) {
|
|
230
|
+
botClient.destroy();
|
|
231
|
+
}
|
|
232
|
+
await cleanupOpencodeServers();
|
|
233
|
+
await Promise.all([
|
|
234
|
+
closeDatabase().catch(() => {
|
|
235
|
+
return;
|
|
236
|
+
}),
|
|
237
|
+
stopHranaServer().catch(() => {
|
|
238
|
+
return;
|
|
239
|
+
}),
|
|
240
|
+
discord?.stop().catch(() => {
|
|
241
|
+
return;
|
|
242
|
+
}),
|
|
243
|
+
]);
|
|
244
|
+
delete process.env['KIMAKI_LOCK_PORT'];
|
|
245
|
+
delete process.env['KIMAKI_DB_URL'];
|
|
246
|
+
if (previousDefaultVerbosity) {
|
|
247
|
+
store.setState({ defaultVerbosity: previousDefaultVerbosity });
|
|
248
|
+
}
|
|
249
|
+
if (directories) {
|
|
250
|
+
fs.rmSync(directories.dataDir, { recursive: true, force: true });
|
|
251
|
+
}
|
|
252
|
+
}, 10_000);
|
|
253
|
+
let logStartIndex = 0;
|
|
254
|
+
beforeEach(() => {
|
|
255
|
+
logStartIndex = getLogEntryCount();
|
|
256
|
+
onTestFailed(() => {
|
|
257
|
+
const logs = getLogEntriesSince(logStartIndex);
|
|
258
|
+
if (logs.length > 0) {
|
|
259
|
+
console.error(`\n--- kimaki logs (${logs.length} lines) ---`);
|
|
260
|
+
console.error(logs.join(''));
|
|
261
|
+
console.error(`--- end ---\n`);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
test('new thread uses agent model when channel agent is set', async () => {
|
|
266
|
+
// Set channel agent preference — this simulates /agent selecting test-agent
|
|
267
|
+
await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
|
|
268
|
+
// Send a message to create a new thread
|
|
269
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
270
|
+
content: 'Reply with exactly: agent-model-check',
|
|
271
|
+
});
|
|
272
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
273
|
+
timeout: 4_000,
|
|
274
|
+
predicate: (t) => {
|
|
275
|
+
return t.name === 'Reply with exactly: agent-model-check';
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
// Wait for the footer (starts with *project) — proves run completed.
|
|
279
|
+
// Then assert which model ID appears in it.
|
|
280
|
+
await waitForBotMessageContaining({
|
|
281
|
+
discord,
|
|
282
|
+
threadId: thread.id,
|
|
283
|
+
userId: TEST_USER_ID,
|
|
284
|
+
text: '*project',
|
|
285
|
+
timeout: 4_000,
|
|
286
|
+
});
|
|
287
|
+
const messages = await discord.thread(thread.id).getMessages();
|
|
288
|
+
// Find the footer message (starts with * italic)
|
|
289
|
+
const footerMessage = messages.find((message) => {
|
|
290
|
+
return (message.author.id === discord.botUserId &&
|
|
291
|
+
message.content.startsWith('*'));
|
|
292
|
+
});
|
|
293
|
+
expect(footerMessage).toBeDefined();
|
|
294
|
+
if (!footerMessage) {
|
|
295
|
+
throw new Error(`Expected footer message but none found. Bot messages: ${messages
|
|
296
|
+
.filter((m) => m.author.id === discord.botUserId)
|
|
297
|
+
.map((m) => m.content.slice(0, 150))
|
|
298
|
+
.join(' | ')}`);
|
|
299
|
+
}
|
|
300
|
+
// The footer should contain the agent's model, not the default
|
|
301
|
+
expect(footerMessage.content).toContain(AGENT_MODEL);
|
|
302
|
+
expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
|
|
303
|
+
}, 15_000);
|
|
304
|
+
test('promptAsync path includes rich system context', async () => {
|
|
305
|
+
await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
|
|
306
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
307
|
+
content: 'Reply with exactly: system-context-check',
|
|
308
|
+
});
|
|
309
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
310
|
+
timeout: 4_000,
|
|
311
|
+
predicate: (t) => {
|
|
312
|
+
return t.name === 'Reply with exactly: system-context-check';
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
await waitForBotMessageContaining({
|
|
316
|
+
discord,
|
|
317
|
+
threadId: thread.id,
|
|
318
|
+
userId: TEST_USER_ID,
|
|
319
|
+
text: 'system-context-ok',
|
|
320
|
+
timeout: 4_000,
|
|
321
|
+
});
|
|
322
|
+
}, 15_000);
|
|
323
|
+
test('new thread uses channel model when channel model preference is set', async () => {
|
|
324
|
+
// Clear channel agent so model resolution falls through to channel model
|
|
325
|
+
const prisma = await getPrisma();
|
|
326
|
+
await prisma.channel_agents.deleteMany({
|
|
327
|
+
where: { channel_id: TEXT_CHANNEL_ID },
|
|
328
|
+
});
|
|
329
|
+
// Set channel model preference — simulates /model selecting a model at channel scope
|
|
330
|
+
await setChannelModel({
|
|
331
|
+
channelId: TEXT_CHANNEL_ID,
|
|
332
|
+
modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
|
|
333
|
+
});
|
|
334
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
335
|
+
content: 'Reply with exactly: channel-model-check',
|
|
336
|
+
});
|
|
337
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
338
|
+
timeout: 4_000,
|
|
339
|
+
predicate: (t) => {
|
|
340
|
+
return t.name === 'Reply with exactly: channel-model-check';
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
await waitForBotMessageContaining({
|
|
344
|
+
discord,
|
|
345
|
+
threadId: thread.id,
|
|
346
|
+
userId: TEST_USER_ID,
|
|
347
|
+
text: '*project',
|
|
348
|
+
timeout: 4_000,
|
|
349
|
+
});
|
|
350
|
+
const messages = await discord.thread(thread.id).getMessages();
|
|
351
|
+
const footerMessage = messages.find((message) => {
|
|
352
|
+
return (message.author.id === discord.botUserId &&
|
|
353
|
+
message.content.startsWith('*'));
|
|
354
|
+
});
|
|
355
|
+
expect(footerMessage).toBeDefined();
|
|
356
|
+
if (!footerMessage) {
|
|
357
|
+
throw new Error(`Expected footer message but none found. Bot messages: ${messages
|
|
358
|
+
.filter((m) => m.author.id === discord.botUserId)
|
|
359
|
+
.map((m) => m.content.slice(0, 150))
|
|
360
|
+
.join(' | ')}`);
|
|
361
|
+
}
|
|
362
|
+
// Footer should contain the channel model, not the default
|
|
363
|
+
expect(footerMessage.content).toContain(CHANNEL_MODEL);
|
|
364
|
+
expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
|
|
365
|
+
}, 15_000);
|
|
366
|
+
test('channel model with variant preference completes without error', async () => {
|
|
367
|
+
// Clear channel agent so model resolution falls through to channel model
|
|
368
|
+
const prisma = await getPrisma();
|
|
369
|
+
await prisma.channel_agents.deleteMany({
|
|
370
|
+
where: { channel_id: TEXT_CHANNEL_ID },
|
|
371
|
+
});
|
|
372
|
+
// Set channel model with a variant (thinking level)
|
|
373
|
+
// The deterministic provider doesn't support thinking, so the variant
|
|
374
|
+
// is resolved but silently dropped (no matching thinking values).
|
|
375
|
+
// This test verifies the variant cascade code path runs without crashing
|
|
376
|
+
// and the correct model still appears in the footer.
|
|
377
|
+
await setChannelModel({
|
|
378
|
+
channelId: TEXT_CHANNEL_ID,
|
|
379
|
+
modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
|
|
380
|
+
variant: 'high',
|
|
381
|
+
});
|
|
382
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
383
|
+
content: 'Reply with exactly: variant-check',
|
|
384
|
+
});
|
|
385
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
386
|
+
timeout: 4_000,
|
|
387
|
+
predicate: (t) => {
|
|
388
|
+
return t.name === 'Reply with exactly: variant-check';
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
await waitForBotMessageContaining({
|
|
392
|
+
discord,
|
|
393
|
+
threadId: thread.id,
|
|
394
|
+
userId: TEST_USER_ID,
|
|
395
|
+
text: '*project',
|
|
396
|
+
timeout: 4_000,
|
|
397
|
+
});
|
|
398
|
+
const messages = await discord.thread(thread.id).getMessages();
|
|
399
|
+
const footerMessage = messages.find((message) => {
|
|
400
|
+
return (message.author.id === discord.botUserId &&
|
|
401
|
+
message.content.startsWith('*'));
|
|
402
|
+
});
|
|
403
|
+
expect(footerMessage).toBeDefined();
|
|
404
|
+
if (!footerMessage) {
|
|
405
|
+
throw new Error(`Expected footer message but none found. Bot messages: ${messages
|
|
406
|
+
.filter((m) => m.author.id === discord.botUserId)
|
|
407
|
+
.map((m) => m.content.slice(0, 150))
|
|
408
|
+
.join(' | ')}`);
|
|
409
|
+
}
|
|
410
|
+
// Footer should still contain the channel model (variant doesn't crash)
|
|
411
|
+
expect(footerMessage.content).toContain(CHANNEL_MODEL);
|
|
412
|
+
expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
|
|
413
|
+
}, 15_000);
|
|
414
|
+
});
|
package/dist/bot-token.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
let dbBotToken = null;
|
|
3
|
+
let cachedAuthKey = null;
|
|
3
4
|
function toBase64(value) {
|
|
4
5
|
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
5
6
|
const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
|
|
@@ -53,9 +54,17 @@ function parsePrivateKey(privateKeyValue) {
|
|
|
53
54
|
}
|
|
54
55
|
throw new Error('Invalid KIMAKI_PRIVATE_KEY for auth mode');
|
|
55
56
|
}
|
|
57
|
+
function getAuthModeKey(privateKeyValue) {
|
|
58
|
+
if (cachedAuthKey && cachedAuthKey.privateKey === privateKeyValue) {
|
|
59
|
+
return cachedAuthKey.key;
|
|
60
|
+
}
|
|
61
|
+
const key = parsePrivateKey(privateKeyValue);
|
|
62
|
+
cachedAuthKey = { privateKey: privateKeyValue, key };
|
|
63
|
+
return key;
|
|
64
|
+
}
|
|
56
65
|
function createAuthModeToken(config) {
|
|
57
66
|
const timestamp = Date.now();
|
|
58
|
-
const key =
|
|
67
|
+
const key = getAuthModeKey(config.privateKey);
|
|
59
68
|
const message = `${config.guildId}\n${timestamp}`;
|
|
60
69
|
const signature = crypto
|
|
61
70
|
.sign(null, Buffer.from(message, 'utf8'), key)
|