kimaki 0.4.103 → 0.4.104
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/anthropic-auth-plugin.test.js +100 -122
- package/dist/cli-parsing.test.js +62 -81
- package/dist/queue-question-select-drain.e2e.test.js +26 -15
- package/dist/session-handler/event-stream-state.js +28 -1
- package/dist/session-handler/event-stream-state.test.js +3 -3
- package/dist/session-handler/thread-session-runtime.js +68 -25
- package/package.json +4 -4
- package/src/cli-parsing.test.ts +69 -98
- package/src/queue-question-select-drain.e2e.test.ts +27 -16
- package/src/session-handler/event-stream-state.test.ts +3 -5
- package/src/session-handler/event-stream-state.ts +50 -4
- package/src/session-handler/thread-session-runtime.ts +92 -38
|
@@ -1,131 +1,109 @@
|
|
|
1
|
-
// Tests
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const firstAccount = {
|
|
8
|
-
type: 'oauth',
|
|
9
|
-
refresh: 'refresh-first',
|
|
10
|
-
access: 'access-first',
|
|
11
|
-
expires: 1,
|
|
12
|
-
};
|
|
13
|
-
const secondAccount = {
|
|
14
|
-
type: 'oauth',
|
|
15
|
-
refresh: 'refresh-second',
|
|
16
|
-
access: 'access-second',
|
|
17
|
-
expires: 2,
|
|
18
|
-
};
|
|
19
|
-
let originalXdgDataHome;
|
|
20
|
-
let tempDir = '';
|
|
21
|
-
beforeEach(async () => {
|
|
22
|
-
originalXdgDataHome = process.env.XDG_DATA_HOME;
|
|
23
|
-
tempDir = await mkdtemp(path.join(tmpdir(), 'anthropic-auth-plugin-'));
|
|
24
|
-
process.env.XDG_DATA_HOME = tempDir;
|
|
25
|
-
});
|
|
26
|
-
afterEach(async () => {
|
|
27
|
-
if (originalXdgDataHome === undefined) {
|
|
28
|
-
delete process.env.XDG_DATA_HOME;
|
|
29
|
-
}
|
|
30
|
-
else {
|
|
31
|
-
process.env.XDG_DATA_HOME = originalXdgDataHome;
|
|
1
|
+
// Tests Anthropic request-time prompt rewriting and transform fallback behavior.
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { replacer, rewriteAnthropicRequestPayload, } from './anthropic-auth-plugin.js';
|
|
4
|
+
function parseRewrittenBody(body) {
|
|
5
|
+
if (!body) {
|
|
6
|
+
throw new Error('Expected rewritten body');
|
|
32
7
|
}
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
describe('
|
|
36
|
-
test('
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
8
|
+
return JSON.parse(body);
|
|
9
|
+
}
|
|
10
|
+
describe('rewriteAnthropicRequestPayload', () => {
|
|
11
|
+
test('sanitizes raw opencode system text at request time', () => {
|
|
12
|
+
const rewritten = rewriteAnthropicRequestPayload(JSON.stringify({
|
|
13
|
+
model: 'claude-sonnet-4-5',
|
|
14
|
+
system: "You are OpenCode, the best coding agent on the planet.\nOS: macOS\nCWD: /repo\nSkills provide specialized instructions\nUse opencode tools carefully.",
|
|
15
|
+
tool_choice: { type: 'tool', name: 'read' },
|
|
16
|
+
tools: [{ name: 'read' }],
|
|
17
|
+
}));
|
|
18
|
+
const payload = parseRewrittenBody(rewritten.body);
|
|
19
|
+
expect(payload).toMatchInlineSnapshot(`
|
|
20
|
+
{
|
|
21
|
+
"model": "claude-sonnet-4-5",
|
|
22
|
+
"system": [
|
|
23
|
+
{
|
|
24
|
+
"text": "You are Claude Code, Anthropic's official CLI for Claude.",
|
|
25
|
+
"type": "text",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"text": "
|
|
29
|
+
<environment>
|
|
30
|
+
<cwd>/Users/morse/Documents/GitHub/kimakivoice/cli</cwd>
|
|
31
|
+
</environment>
|
|
32
|
+
Skills provide specialized instructions
|
|
33
|
+
Use openc0de tools carefully.",
|
|
34
|
+
"type": "text",
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
"tool_choice": {
|
|
38
|
+
"name": "Read",
|
|
39
|
+
"type": "tool",
|
|
40
|
+
},
|
|
41
|
+
"tools": [
|
|
42
|
+
{
|
|
43
|
+
"name": "Read",
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
}
|
|
47
|
+
`);
|
|
47
48
|
});
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
{ ...firstAccount, addedAt: 1, lastUsed: 1 },
|
|
56
|
-
{ ...secondAccount, addedAt: 2, lastUsed: 2 },
|
|
57
|
-
],
|
|
58
|
-
});
|
|
59
|
-
const authSetCalls = [];
|
|
60
|
-
const client = {
|
|
61
|
-
auth: {
|
|
62
|
-
set: async (input) => {
|
|
63
|
-
authSetCalls.push(input);
|
|
49
|
+
test('does not duplicate claude code identity when request was already sanitized', () => {
|
|
50
|
+
const rewritten = rewriteAnthropicRequestPayload(JSON.stringify({
|
|
51
|
+
model: 'claude-sonnet-4-5',
|
|
52
|
+
system: [
|
|
53
|
+
{
|
|
54
|
+
type: 'text',
|
|
55
|
+
text: "You are Claude Code, Anthropic's official CLI for Claude.",
|
|
64
56
|
},
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const store = await loadAccountStore();
|
|
69
|
-
const authJson = JSON.parse(await readFile(authFilePath(), 'utf8'));
|
|
70
|
-
expect(rotated).toMatchObject({
|
|
71
|
-
auth: { refresh: 'refresh-second' },
|
|
72
|
-
fromLabel: '#1 (refresh-...irst)',
|
|
73
|
-
toLabel: '#2 (refresh-...cond)',
|
|
74
|
-
fromIndex: 0,
|
|
75
|
-
toIndex: 1,
|
|
76
|
-
});
|
|
77
|
-
expect(store.activeIndex).toBe(1);
|
|
78
|
-
expect(authJson.anthropic?.refresh).toBe('refresh-second');
|
|
79
|
-
expect(authSetCalls).toEqual([
|
|
80
|
-
{
|
|
81
|
-
path: { id: 'anthropic' },
|
|
82
|
-
body: {
|
|
83
|
-
type: 'oauth',
|
|
84
|
-
refresh: 'refresh-second',
|
|
85
|
-
access: 'access-second',
|
|
86
|
-
expires: 2,
|
|
57
|
+
{
|
|
58
|
+
type: 'text',
|
|
59
|
+
text: '<environment>\n<cwd>/repo</cwd>\n</environment>\nSkills provide specialized instructions',
|
|
87
60
|
},
|
|
88
|
-
},
|
|
89
|
-
]);
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
describe('removeAccount', () => {
|
|
93
|
-
test('removing the active account promotes the next stored account', async () => {
|
|
94
|
-
await saveAccountStore({
|
|
95
|
-
version: 1,
|
|
96
|
-
activeIndex: 1,
|
|
97
|
-
accounts: [
|
|
98
|
-
{ ...firstAccount, addedAt: 1, lastUsed: 1 },
|
|
99
|
-
{ ...secondAccount, addedAt: 2, lastUsed: 2 },
|
|
100
61
|
],
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
await removeAccount(0);
|
|
119
|
-
const store = await loadAccountStore();
|
|
120
|
-
const authJson = JSON.parse(await readFile(authFilePath(), 'utf8'));
|
|
121
|
-
expect(store.accounts).toHaveLength(0);
|
|
122
|
-
expect(authJson.anthropic).toBeUndefined();
|
|
62
|
+
}));
|
|
63
|
+
const payload = parseRewrittenBody(rewritten.body);
|
|
64
|
+
expect(payload.system).toMatchInlineSnapshot(`
|
|
65
|
+
[
|
|
66
|
+
{
|
|
67
|
+
"text": "You are Claude Code, Anthropic's official CLI for Claude.",
|
|
68
|
+
"type": "text",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"text": "<environment>
|
|
72
|
+
<cwd>/repo</cwd>
|
|
73
|
+
</environment>
|
|
74
|
+
Skills provide specialized instructions",
|
|
75
|
+
"type": "text",
|
|
76
|
+
},
|
|
77
|
+
]
|
|
78
|
+
`);
|
|
123
79
|
});
|
|
124
80
|
});
|
|
125
|
-
describe('
|
|
126
|
-
test('
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
81
|
+
describe('replacer', () => {
|
|
82
|
+
test('sanitizes system text only for anthropic provider metadata', async () => {
|
|
83
|
+
const plugin = await replacer({});
|
|
84
|
+
const transform = plugin['experimental.chat.system.transform'];
|
|
85
|
+
if (!transform) {
|
|
86
|
+
throw new Error('Expected experimental.chat.system.transform hook');
|
|
87
|
+
}
|
|
88
|
+
const output = {
|
|
89
|
+
system: [
|
|
90
|
+
"You are OpenCode, the best coding agent on the planet.\nOS: macOS\nSkills provide specialized instructions\nUse opencode tools carefully.",
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
await transform({
|
|
94
|
+
model: {
|
|
95
|
+
providerID: 'anthropic',
|
|
96
|
+
},
|
|
97
|
+
}, output);
|
|
98
|
+
expect(output.system).toMatchInlineSnapshot(`
|
|
99
|
+
[
|
|
100
|
+
"
|
|
101
|
+
<environment>
|
|
102
|
+
<cwd>/Users/morse/Documents/GitHub/kimakivoice/cli</cwd>
|
|
103
|
+
</environment>
|
|
104
|
+
Skills provide specialized instructions
|
|
105
|
+
Use openc0de tools carefully.",
|
|
106
|
+
]
|
|
107
|
+
`);
|
|
130
108
|
});
|
|
131
109
|
});
|
package/dist/cli-parsing.test.js
CHANGED
|
@@ -1,84 +1,84 @@
|
|
|
1
1
|
// Regression tests for CLI argument parsing around Discord ID string preservation.
|
|
2
2
|
import { describe, expect, test } from 'vitest';
|
|
3
|
-
import {
|
|
4
|
-
function
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
.option('-c, --channel <channelId>', 'Discord channel ID')
|
|
9
|
-
.
|
|
10
|
-
.option('--
|
|
11
|
-
.option('--
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
.command('
|
|
15
|
-
.
|
|
16
|
-
.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
3
|
+
import { execAsync } from './exec-async.js';
|
|
4
|
+
async function parseWithGoke(argv) {
|
|
5
|
+
const script = [
|
|
6
|
+
"import { goke } from 'goke'",
|
|
7
|
+
'const cli = goke(\'kimaki\')',
|
|
8
|
+
"cli.command('send', 'Send a message').option('-c, --channel <channelId>', 'Discord channel ID').option('--thread <threadId>', 'Thread ID').option('--session <sessionId>', 'Session ID').option('--send-at <schedule>', 'Schedule')",
|
|
9
|
+
"cli.command('session archive <threadId>', 'Archive a thread')",
|
|
10
|
+
"cli.command('session search <query>', 'Search sessions').option('--channel <channelId>', 'Discord channel ID').option('--project <path>', 'Project path')",
|
|
11
|
+
"cli.command('session export-events-jsonl', 'Export in-memory events to JSONL').option('--session <sessionId>', 'Session ID').option('--out <file>', 'Output path')",
|
|
12
|
+
"cli.command('add-project', 'Add a project').option('-g, --guild <guildId>', 'Discord guild/server ID')",
|
|
13
|
+
"cli.command('task delete <id>', 'Delete task')",
|
|
14
|
+
"cli.command('anthropic-accounts list', 'List stored Anthropic accounts')",
|
|
15
|
+
"cli.command('anthropic-accounts remove <indexOrEmail>', 'Remove stored Anthropic account')",
|
|
16
|
+
`const result = cli.parse(${JSON.stringify(argv)}, { run: false })`,
|
|
17
|
+
'process.stdout.write(JSON.stringify({ args: result.args, options: result.options }))',
|
|
18
|
+
].join(';');
|
|
19
|
+
const { stdout } = await execAsync(`node --input-type=module -e ${JSON.stringify(script)}`, {
|
|
20
|
+
cwd: import.meta.dirname,
|
|
21
|
+
timeout: 10_000,
|
|
22
|
+
});
|
|
23
|
+
return JSON.parse(stdout);
|
|
24
|
+
}
|
|
25
|
+
async function getHelpOutput() {
|
|
26
|
+
const script = [
|
|
27
|
+
"import { goke } from 'goke'",
|
|
28
|
+
'const stdout = { text: \'\', write(data) { this.text += String(data) } }',
|
|
29
|
+
"const cli = goke('kimaki', { stdout })",
|
|
30
|
+
"cli.command('send', 'Send a message')",
|
|
31
|
+
"cli.command('anthropic-accounts list', 'List stored Anthropic accounts')",
|
|
32
|
+
'cli.help()',
|
|
33
|
+
"cli.parse(['node', 'kimaki', '--help'], { run: false })",
|
|
34
|
+
'process.stdout.write(stdout.text)',
|
|
35
|
+
].join(';');
|
|
36
|
+
const { stdout } = await execAsync(`node --input-type=module -e ${JSON.stringify(script)}`, {
|
|
37
|
+
cwd: import.meta.dirname,
|
|
38
|
+
timeout: 10_000,
|
|
39
|
+
});
|
|
40
|
+
return stdout;
|
|
28
41
|
}
|
|
29
42
|
describe('goke CLI ID parsing', () => {
|
|
30
|
-
test('keeps large Discord IDs as strings', () => {
|
|
31
|
-
const cli = createCliForIdParsing();
|
|
43
|
+
test('keeps large Discord IDs as strings', async () => {
|
|
32
44
|
const channelId = '1234567890123456789';
|
|
33
45
|
const threadId = '9876543210987654321';
|
|
34
46
|
const sessionId = '1111222233334444555';
|
|
35
|
-
const channelResult =
|
|
36
|
-
run: false,
|
|
37
|
-
});
|
|
47
|
+
const channelResult = await parseWithGoke(['node', 'kimaki', 'send', '--channel', channelId]);
|
|
38
48
|
expect(channelResult.options.channel).toBe(channelId);
|
|
39
49
|
expect(typeof channelResult.options.channel).toBe('string');
|
|
40
|
-
const threadResult =
|
|
50
|
+
const threadResult = await parseWithGoke(['node', 'kimaki', 'send', '--thread', threadId]);
|
|
41
51
|
expect(threadResult.options.thread).toBe(threadId);
|
|
42
52
|
expect(typeof threadResult.options.thread).toBe('string');
|
|
43
|
-
const sessionResult =
|
|
44
|
-
run: false,
|
|
45
|
-
});
|
|
53
|
+
const sessionResult = await parseWithGoke(['node', 'kimaki', 'send', '--session', sessionId]);
|
|
46
54
|
expect(sessionResult.options.session).toBe(sessionId);
|
|
47
55
|
expect(typeof sessionResult.options.session).toBe('string');
|
|
48
56
|
});
|
|
49
|
-
test('preserves leading zeros in Discord IDs', () => {
|
|
50
|
-
const cli = createCliForIdParsing();
|
|
57
|
+
test('preserves leading zeros in Discord IDs', async () => {
|
|
51
58
|
const guildId = '001230045600789';
|
|
52
|
-
const result =
|
|
59
|
+
const result = await parseWithGoke(['node', 'kimaki', 'add-project', '--guild', guildId]);
|
|
53
60
|
expect(result.options.guild).toBe(guildId);
|
|
54
61
|
expect(typeof result.options.guild).toBe('string');
|
|
55
62
|
});
|
|
56
|
-
test('keeps session archive thread ID as string', () => {
|
|
57
|
-
const cli = createCliForIdParsing();
|
|
63
|
+
test('keeps session archive thread ID as string', async () => {
|
|
58
64
|
const threadId = '0098765432109876543';
|
|
59
|
-
const result =
|
|
60
|
-
run: false,
|
|
61
|
-
});
|
|
65
|
+
const result = await parseWithGoke(['node', 'kimaki', 'session', 'archive', threadId]);
|
|
62
66
|
expect(result.args[0]).toBe(threadId);
|
|
63
67
|
expect(typeof result.args[0]).toBe('string');
|
|
64
68
|
});
|
|
65
|
-
test('keeps session search regex and channel ID as strings', () => {
|
|
66
|
-
const cli = createCliForIdParsing();
|
|
69
|
+
test('keeps session search regex and channel ID as strings', async () => {
|
|
67
70
|
const channelId = '0012345678901234567';
|
|
68
71
|
const query = '/error\\s+42/i';
|
|
69
|
-
const result =
|
|
70
|
-
run: false,
|
|
71
|
-
});
|
|
72
|
+
const result = await parseWithGoke(['node', 'kimaki', 'session', 'search', query, '--channel', channelId]);
|
|
72
73
|
expect(result.args[0]).toBe(query);
|
|
73
74
|
expect(typeof result.args[0]).toBe('string');
|
|
74
75
|
expect(result.options.channel).toBe(channelId);
|
|
75
76
|
expect(typeof result.options.channel).toBe('string');
|
|
76
77
|
});
|
|
77
|
-
test('keeps session export options as strings', () => {
|
|
78
|
-
const cli = createCliForIdParsing();
|
|
78
|
+
test('keeps session export options as strings', async () => {
|
|
79
79
|
const sessionId = '001111222233334444';
|
|
80
80
|
const outPath = './tmp/session-events.jsonl';
|
|
81
|
-
const result =
|
|
81
|
+
const result = await parseWithGoke([
|
|
82
82
|
'node',
|
|
83
83
|
'kimaki',
|
|
84
84
|
'session',
|
|
@@ -87,54 +87,35 @@ describe('goke CLI ID parsing', () => {
|
|
|
87
87
|
sessionId,
|
|
88
88
|
'--out',
|
|
89
89
|
outPath,
|
|
90
|
-
]
|
|
91
|
-
run: false,
|
|
92
|
-
});
|
|
90
|
+
]);
|
|
93
91
|
expect(result.options.session).toBe(sessionId);
|
|
94
92
|
expect(typeof result.options.session).toBe('string');
|
|
95
93
|
expect(result.options.out).toBe(outPath);
|
|
96
94
|
expect(typeof result.options.out).toBe('string');
|
|
97
95
|
});
|
|
98
|
-
test('keeps --send-at cron string intact', () => {
|
|
99
|
-
const cli = createCliForIdParsing();
|
|
96
|
+
test('keeps --send-at cron string intact', async () => {
|
|
100
97
|
const cron = '0 9 * * 1';
|
|
101
|
-
const result =
|
|
102
|
-
run: false,
|
|
103
|
-
});
|
|
98
|
+
const result = await parseWithGoke(['node', 'kimaki', 'send', '--send-at', cron]);
|
|
104
99
|
expect(result.options.sendAt).toBe(cron);
|
|
105
100
|
expect(typeof result.options.sendAt).toBe('string');
|
|
106
101
|
});
|
|
107
|
-
test('keeps task delete ID as string before validation', () => {
|
|
108
|
-
const cli = createCliForIdParsing();
|
|
102
|
+
test('keeps task delete ID as string before validation', async () => {
|
|
109
103
|
const taskId = '0012345';
|
|
110
|
-
const result =
|
|
111
|
-
run: false,
|
|
112
|
-
});
|
|
104
|
+
const result = await parseWithGoke(['node', 'kimaki', 'task', 'delete', taskId]);
|
|
113
105
|
expect(result.args[0]).toBe(taskId);
|
|
114
106
|
expect(typeof result.args[0]).toBe('string');
|
|
115
107
|
});
|
|
116
|
-
test('anthropic account remove parses index and email as strings', () => {
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
const emailResult = cli.parse(['node', 'kimaki', 'anthropic-accounts', 'remove', 'user@example.com'], { run: false });
|
|
108
|
+
test('anthropic account remove parses index and email as strings', async () => {
|
|
109
|
+
const indexResult = await parseWithGoke(['node', 'kimaki', 'anthropic-accounts', 'remove', '2']);
|
|
110
|
+
const emailResult = await parseWithGoke(['node', 'kimaki', 'anthropic-accounts', 'remove', 'user@example.com']);
|
|
120
111
|
expect(indexResult.args[0]).toBe('2');
|
|
121
112
|
expect(typeof indexResult.args[0]).toBe('string');
|
|
122
113
|
expect(emailResult.args[0]).toBe('user@example.com');
|
|
123
114
|
expect(typeof emailResult.args[0]).toBe('string');
|
|
124
115
|
});
|
|
125
|
-
test('anthropic account commands are included in help output', () => {
|
|
126
|
-
const stdout =
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
this.text += String(data);
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
const cli = goke('kimaki', { stdout: stdout });
|
|
133
|
-
cli.command('send', 'Send a message');
|
|
134
|
-
cli.command('anthropic-accounts list', 'List stored Anthropic accounts');
|
|
135
|
-
cli.help();
|
|
136
|
-
cli.parse(['node', 'kimaki', '--help'], { run: false });
|
|
137
|
-
expect(stdout.text).toContain('send');
|
|
138
|
-
expect(stdout.text).toContain('anthropic-accounts');
|
|
116
|
+
test('anthropic account commands are included in help output', async () => {
|
|
117
|
+
const stdout = await getHelpOutput();
|
|
118
|
+
expect(stdout).toContain('send');
|
|
119
|
+
expect(stdout).toContain('anthropic-accounts');
|
|
139
120
|
});
|
|
140
121
|
});
|
|
@@ -89,7 +89,16 @@ describe('queue drain after question select answer', () => {
|
|
|
89
89
|
if (!queueAck.messageId) {
|
|
90
90
|
throw new Error('Expected /queue response message id');
|
|
91
91
|
}
|
|
92
|
-
// 4.
|
|
92
|
+
// 4. The first queued item should be handed off immediately even while
|
|
93
|
+
// the question is still pending, so the visible dispatch indicator
|
|
94
|
+
// appears before the user answers the dropdown.
|
|
95
|
+
await waitForBotMessageContaining({
|
|
96
|
+
discord: ctx.discord,
|
|
97
|
+
threadId: thread.id,
|
|
98
|
+
text: '» **question-select-tester:** Reply with exactly: post-question-drain',
|
|
99
|
+
timeout: 8_000,
|
|
100
|
+
});
|
|
101
|
+
// 5. Answer the question via dropdown select (pick first option "Alpha")
|
|
93
102
|
const interaction = await th.user(TEST_USER_ID).selectMenu({
|
|
94
103
|
messageId: questionMsg.id,
|
|
95
104
|
customId: `ask_question:${pending.contextHash}:0`,
|
|
@@ -99,15 +108,6 @@ describe('queue drain after question select answer', () => {
|
|
|
99
108
|
interactionId: interaction.id,
|
|
100
109
|
timeout: 8_000,
|
|
101
110
|
});
|
|
102
|
-
// 5. Queued message should be handed off to OpenCode's own prompt queue
|
|
103
|
-
// after the question reply, so the dispatch indicator appears without
|
|
104
|
-
// waiting for a later natural idle.
|
|
105
|
-
await waitForBotMessageContaining({
|
|
106
|
-
discord: ctx.discord,
|
|
107
|
-
threadId: thread.id,
|
|
108
|
-
text: '» **question-select-tester:** Reply with exactly: post-question-drain',
|
|
109
|
-
timeout: 8_000,
|
|
110
|
-
});
|
|
111
111
|
// 6. Wait for footer from the drained queued message
|
|
112
112
|
await waitForFooterMessage({
|
|
113
113
|
discord: ctx.discord,
|
|
@@ -116,10 +116,21 @@ describe('queue drain after question select answer', () => {
|
|
|
116
116
|
afterMessageIncludes: '» **question-select-tester:**',
|
|
117
117
|
afterAuthorId: ctx.discord.botUserId,
|
|
118
118
|
});
|
|
119
|
-
// Assert key invariants instead of exact snapshot — on CI the deterministic
|
|
120
|
-
// matcher can fire a second time after the drained message (rawPromptIncludes
|
|
121
|
-
// scans full history), adding an extra question to the timeline.
|
|
122
119
|
const timeline = await th.text({ showInteractions: true });
|
|
120
|
+
expect(timeline).toMatchInlineSnapshot(`
|
|
121
|
+
"--- from: user (question-select-tester)
|
|
122
|
+
QUESTION_SELECT_QUEUE_MARKER
|
|
123
|
+
--- from: assistant (TestBot)
|
|
124
|
+
**Select action**
|
|
125
|
+
How to proceed?
|
|
126
|
+
✓ _Alpha_
|
|
127
|
+
[user interaction]
|
|
128
|
+
» **question-select-tester:** Reply with exactly: post-question-drain
|
|
129
|
+
Queued message (position 1)
|
|
130
|
+
[user selects dropdown: 0]
|
|
131
|
+
⬥ ok
|
|
132
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
133
|
+
`);
|
|
123
134
|
expect(timeline).toContain('QUESTION_SELECT_QUEUE_MARKER');
|
|
124
135
|
expect(timeline).toContain('How to proceed?');
|
|
125
136
|
expect(timeline).toContain('[user selects dropdown: 0]');
|
|
@@ -226,11 +237,11 @@ describe('queue drain after question select answer', () => {
|
|
|
226
237
|
How to proceed?
|
|
227
238
|
✓ _Alpha_
|
|
228
239
|
[user interaction]
|
|
240
|
+
» **question-select-tester:** SLOW_ABORT_MARKER run long response
|
|
229
241
|
Queued message (position 1)
|
|
230
242
|
[user interaction]
|
|
231
|
-
Queued message (position
|
|
243
|
+
Queued message (position 1)
|
|
232
244
|
[user selects dropdown: 0]
|
|
233
|
-
» **question-select-tester:** SLOW_ABORT_MARKER run long response
|
|
234
245
|
⬥ slow-response-started
|
|
235
246
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
236
247
|
» **question-select-tester:** Reply with exactly: post-question-second
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
// Zero imports from thread-session-runtime.ts, store.ts, or state.ts.
|
|
4
4
|
// Only types from @opencode-ai/sdk/v2 and the getOpencodeEventSessionId helper.
|
|
5
5
|
import { getOpencodeEventSessionId } from './opencode-session-event-log.js';
|
|
6
|
+
export function getEventBufferSessionId(event) {
|
|
7
|
+
if (event.type === 'queue.question-handoff-started') {
|
|
8
|
+
return event.properties.sessionID;
|
|
9
|
+
}
|
|
10
|
+
return getOpencodeEventSessionId(event);
|
|
11
|
+
}
|
|
6
12
|
function getTaskChildSessionId({ part, }) {
|
|
7
13
|
// Event-shape reference:
|
|
8
14
|
// - cli/src/session-handler/event-stream-fixtures/real-session-task-three-parallel-sleeps.jsonl
|
|
@@ -51,7 +57,7 @@ export function isSessionBusy({ events, sessionId, upToIndex, }) {
|
|
|
51
57
|
continue;
|
|
52
58
|
}
|
|
53
59
|
const e = entry.event;
|
|
54
|
-
const eid =
|
|
60
|
+
const eid = getEventBufferSessionId(e);
|
|
55
61
|
if (eid !== sessionId) {
|
|
56
62
|
continue;
|
|
57
63
|
}
|
|
@@ -64,6 +70,27 @@ export function isSessionBusy({ events, sessionId, upToIndex, }) {
|
|
|
64
70
|
}
|
|
65
71
|
return false;
|
|
66
72
|
}
|
|
73
|
+
export function didQuestionQueueHandoffSinceLatestQuestionAsked({ events, sessionId, upToIndex, }) {
|
|
74
|
+
const end = upToIndex ?? events.length - 1;
|
|
75
|
+
for (let i = end; i >= 0; i--) {
|
|
76
|
+
const entry = events[i];
|
|
77
|
+
if (!entry) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const event = entry.event;
|
|
81
|
+
const eventSessionId = getEventBufferSessionId(event);
|
|
82
|
+
if (eventSessionId !== sessionId) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (event.type === 'queue.question-handoff-started') {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
if (event.type === 'question.asked') {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
67
94
|
export function isAssistantMessageNaturalCompletion({ message, }) {
|
|
68
95
|
if (typeof message.time.completed !== 'number') {
|
|
69
96
|
return false;
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { describe, expect, test } from 'vitest';
|
|
6
|
-
import {
|
|
7
|
-
import { getAssistantMessageIdsForLatestUserTurn, getCurrentTurnStartTime, getDerivedSubtaskIndex, getLatestAssistantMessageIdForLatestUserTurn, getLatestRunInfo, hasAssistantMessageCompletedBefore, doesLatestUserTurnHaveNaturalCompletion, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, isSessionBusy, } from './event-stream-state.js';
|
|
6
|
+
import {} from './opencode-session-event-log.js';
|
|
7
|
+
import { getAssistantMessageIdsForLatestUserTurn, getEventBufferSessionId, getCurrentTurnStartTime, getDerivedSubtaskIndex, getLatestAssistantMessageIdForLatestUserTurn, getLatestRunInfo, hasAssistantMessageCompletedBefore, doesLatestUserTurnHaveNaturalCompletion, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, isSessionBusy, } from './event-stream-state.js';
|
|
8
8
|
const fixturesDir = path.join(import.meta.dirname, 'event-stream-fixtures');
|
|
9
9
|
function loadFixture(filename) {
|
|
10
10
|
const content = fs.readFileSync(path.join(fixturesDir, filename), 'utf8');
|
|
@@ -18,7 +18,7 @@ function loadFixture(filename) {
|
|
|
18
18
|
}
|
|
19
19
|
function getSessionId(events) {
|
|
20
20
|
for (const entry of events) {
|
|
21
|
-
const sessionId =
|
|
21
|
+
const sessionId = getEventBufferSessionId(entry.event);
|
|
22
22
|
if (sessionId) {
|
|
23
23
|
return sessionId;
|
|
24
24
|
}
|