@moxxy/plugin-provider-acp 0.0.0-stage → 0.45.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/LICENSE +21 -0
- package/dist/acp-session.d.ts +201 -0
- package/dist/acp-session.d.ts.map +1 -0
- package/dist/acp-session.js +812 -0
- package/dist/acp-session.js.map +1 -0
- package/dist/agent-provider.d.ts +71 -0
- package/dist/agent-provider.d.ts.map +1 -0
- package/dist/agent-provider.js +203 -0
- package/dist/agent-provider.js.map +1 -0
- package/dist/agents.d.ts +23 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +21 -0
- package/dist/agents.js.map +1 -0
- package/dist/constants.d.ts +31 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +31 -0
- package/dist/constants.js.map +1 -0
- package/dist/executable.d.ts +47 -0
- package/dist/executable.d.ts.map +1 -0
- package/dist/executable.js +106 -0
- package/dist/executable.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +69 -0
- package/dist/index.js.map +1 -0
- package/dist/login.d.ts +48 -0
- package/dist/login.d.ts.map +1 -0
- package/dist/login.js +180 -0
- package/dist/login.js.map +1 -0
- package/dist/opencode-api.d.ts +32 -0
- package/dist/opencode-api.d.ts.map +1 -0
- package/dist/opencode-api.js +86 -0
- package/dist/opencode-api.js.map +1 -0
- package/dist/opencode-helpers.d.ts +49 -0
- package/dist/opencode-helpers.d.ts.map +1 -0
- package/dist/opencode-helpers.js +175 -0
- package/dist/opencode-helpers.js.map +1 -0
- package/dist/protocol.d.ts +143 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +7 -0
- package/dist/protocol.js.map +1 -0
- package/dist/rules.d.ts +47 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +84 -0
- package/dist/rules.js.map +1 -0
- package/dist/translate.d.ts +50 -0
- package/dist/translate.d.ts.map +1 -0
- package/dist/translate.js +290 -0
- package/dist/translate.js.map +1 -0
- package/package.json +81 -3
- package/src/acp-session.ts +892 -0
- package/src/agent-cli.test.ts +76 -0
- package/src/agent-provider.test.ts +1137 -0
- package/src/agent-provider.ts +234 -0
- package/src/agents.ts +37 -0
- package/src/constants.ts +36 -0
- package/src/event-coverage.test.ts +65 -0
- package/src/executable.test.ts +89 -0
- package/src/executable.ts +143 -0
- package/src/fake-opencode.testkit.ts +755 -0
- package/src/fixtures/acp-session-updates.json +19 -0
- package/src/index.test.ts +69 -0
- package/src/index.ts +83 -0
- package/src/login.test.ts +155 -0
- package/src/login.ts +222 -0
- package/src/opencode-api.ts +105 -0
- package/src/opencode-helpers.test.ts +76 -0
- package/src/opencode-helpers.ts +195 -0
- package/src/protocol.ts +104 -0
- package/src/rules.test.ts +107 -0
- package/src/rules.ts +102 -0
- package/src/translate.test.ts +253 -0
- package/src/translate.ts +318 -0
- package/README.md +0 -3
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OpenCode agent connection, end to end: a real Moxxy session and loop
|
|
3
|
+
* drive `agentStep`, and OpenCode itself is the stand-in at the `opencode acp`
|
|
4
|
+
* boundary (fake-opencode.testkit.ts). Nothing between them is replaced.
|
|
5
|
+
*/
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { z } from 'zod/v3';
|
|
8
|
+
import { AGENT_CONNECTION_PLUGIN_ID, AUTO_APPROVE_PLUGIN_ID, AUTO_APPROVE_SUBTYPE, autoApproveFromEvents, definePlugin, defineTool, type MoxxyEvent, type PermissionResolver, type Skill, type ToolDef } from '@moxxy/sdk';
|
|
9
|
+
import { buildSynthesizeSkillPlugin, collectTurn, newTurnId, type Session } from '@moxxy/core';
|
|
10
|
+
import { FakeProvider, addFakeProvider, createFakeSession, textReply } from '@moxxy/testing';
|
|
11
|
+
import { defaultModePlugin } from '@moxxy/mode-default';
|
|
12
|
+
import { planModePlugin } from '@moxxy/mode-plan';
|
|
13
|
+
import { goalModePlugin } from '@moxxy/mode-goal';
|
|
14
|
+
import { AgentSessionPool } from '@moxxy/agent-connection-kit';
|
|
15
|
+
import { AcpAgentProvider } from './agent-provider.js';
|
|
16
|
+
import { toolsLentTo, type AcpAgentSession } from './acp-session.js';
|
|
17
|
+
import { OPENCODE_AGENT } from './agents.js';
|
|
18
|
+
import { FakeOpenCode, OPENCODE_DEFAULT_RULES, type FakeBackgroundTask, type FakeOpenCodeDefaults, type OpenCodeScene } from './fake-opencode.testkit.js';
|
|
19
|
+
|
|
20
|
+
const sessions: Session[] = [];
|
|
21
|
+
const pools: AgentSessionPool<AcpAgentSession>[] = [];
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await Promise.all(pools.splice(0).map((pool) => pool.closeAll()));
|
|
24
|
+
await Promise.all(sessions.splice(0).map((session) => session.close()));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function connect(scene: OpenCodeScene, defaults: FakeOpenCodeDefaults = {}, model = 'opencode/big-pickle') {
|
|
28
|
+
const opencode = new FakeOpenCode(scene, defaults);
|
|
29
|
+
const pool = new AgentSessionPool<AcpAgentSession>({ limit: 4 });
|
|
30
|
+
pools.push(pool);
|
|
31
|
+
const provider = new AcpAgentProvider({ startAgent: opencode.start, readRules: opencode.readRules, readMcpServers: opencode.readMcpServers, sessions: pool, model });
|
|
32
|
+
const session = createFakeSession({ provider, cwd: '/work/app' });
|
|
33
|
+
sessions.push(session);
|
|
34
|
+
session.pluginHost.registerStatic(defaultModePlugin);
|
|
35
|
+
return { opencode, session, provider, pool };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const said = (events: ReadonlyArray<MoxxyEvent>) =>
|
|
39
|
+
events.flatMap((e) => (e.type === 'assistant_message' && e.source === 'model' ? [e.content] : []));
|
|
40
|
+
const shownCalls = (events: ReadonlyArray<MoxxyEvent>) =>
|
|
41
|
+
events.flatMap((e) => (e.type === 'tool_call_requested' ? [{ name: e.name, input: e.input }] : []));
|
|
42
|
+
const allowAll: PermissionResolver = { name: 'allow-all', check: async () => ({ mode: 'allow' }) };
|
|
43
|
+
const allowForSession: PermissionResolver = { name: 'allow-session', check: async () => ({ mode: 'allow_session' }) };
|
|
44
|
+
const personSays = (reason: string): PermissionResolver => ({ name: 'person', check: async () => ({ mode: 'deny', reason }) });
|
|
45
|
+
|
|
46
|
+
describe('a turn on the OpenCode agent connection', () => {
|
|
47
|
+
it('starts OpenCode in the conversation folder with only Moxxy MCP server added, and shows what OpenCode did', async () => {
|
|
48
|
+
const { opencode, session } = connect(async (turn) => {
|
|
49
|
+
await turn.tool('bash', 'execute', { command: 'echo spike-ok', cwd: '/work/app', description: 'Print a word' }, 'spike-ok\n', { title: 'echo spike-ok' });
|
|
50
|
+
turn.usage({ inputTokens: 200, outputTokens: 30, thoughtTokens: 10, cachedReadTokens: 1000, totalTokens: 1240 });
|
|
51
|
+
turn.say(`You said: ${turn.text}`);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const events = await collectTurn(session, 'Run echo spike-ok');
|
|
55
|
+
|
|
56
|
+
const server = opencode.last;
|
|
57
|
+
expect(server.cwd).toBe('/work/app');
|
|
58
|
+
expect(server.sent('initialize')[0]).toEqual({
|
|
59
|
+
protocolVersion: 1,
|
|
60
|
+
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
|
|
61
|
+
clientInfo: { name: 'moxxy', title: 'Moxxy', version: expect.any(String) },
|
|
62
|
+
});
|
|
63
|
+
const [created] = server.sent('session/new');
|
|
64
|
+
expect(created).toEqual({
|
|
65
|
+
cwd: '/work/app',
|
|
66
|
+
mcpServers: [
|
|
67
|
+
{
|
|
68
|
+
type: 'http',
|
|
69
|
+
name: 'moxxy',
|
|
70
|
+
url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/mcp$/),
|
|
71
|
+
headers: [{ name: 'Authorization', value: expect.stringMatching(/^Bearer /) }],
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
// OpenCode runs as the person set it up: Moxxy changes no setting it does not need to.
|
|
76
|
+
expect(server.sent('session/set_config_option')).toEqual([]);
|
|
77
|
+
expect(server.sent('session/prompt')[0]).toEqual({ sessionId: 'ses_1', prompt: [{ type: 'text', text: 'Run echo spike-ok' }] });
|
|
78
|
+
expect(shownCalls(events)).toEqual([{ name: 'Bash', input: { command: 'echo spike-ok', description: 'Print a word' } }]);
|
|
79
|
+
expect(events).toContainEqual(expect.objectContaining({ type: 'tool_call_requested', name: 'Bash', executedBy: 'provider' }));
|
|
80
|
+
expect(events).toContainEqual(expect.objectContaining({ type: 'tool_result', ok: true, output: 'spike-ok\n' }));
|
|
81
|
+
expect(said(events)).toEqual(['You said: Run echo spike-ok']);
|
|
82
|
+
expect(events).toContainEqual(expect.objectContaining({ type: 'provider_response', inputTokens: 200, outputTokens: 40, cacheReadTokens: 1000 }));
|
|
83
|
+
expect(events).toContainEqual(expect.objectContaining({ type: 'plugin_event', subtype: 'agent_session', payload: { provider: 'opencode', sessionId: 'ses_1' } }));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('shows OpenCode file, search and web tools under Moxxy names, and its message before each tool as its own', async () => {
|
|
87
|
+
const { session } = connect(async (turn) => {
|
|
88
|
+
turn.think('Look first.');
|
|
89
|
+
turn.say('Reading.');
|
|
90
|
+
await turn.tool('read', 'read', { filePath: '/work/app/math.js', limit: 20 }, 'export const add = (a, b) => a - b;', { title: 'math.js' });
|
|
91
|
+
await turn.tool('glob', 'search', { pattern: '**/*.js' }, '/work/app/math.js', { title: '**/*.js' });
|
|
92
|
+
await turn.tool('grep', 'search', { pattern: 'add\\(', path: '/work/app', include: '*.js' }, 'Found 1 match', { title: 'add\\(' });
|
|
93
|
+
await turn.tool('edit', 'edit', { filePath: '/work/app/math.js', oldString: 'a - b', newString: 'a + b' }, 'Edit applied successfully.', { title: 'math.js' });
|
|
94
|
+
await turn.tool('write', 'edit', { filePath: '/work/app/README.md', content: '# app' }, 'Wrote file successfully.', { title: 'README.md' });
|
|
95
|
+
await turn.tool('webfetch', 'fetch', { url: 'https://example.com', format: 'markdown' }, '# Example', { title: 'https://example.com' });
|
|
96
|
+
await turn.tool('todowrite', 'other', { todos: [{ content: 'fix add', status: 'completed' }] }, '[]', { title: '1 todos' });
|
|
97
|
+
turn.say('Fixed add.');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const events = await collectTurn(session, 'fix add');
|
|
101
|
+
|
|
102
|
+
expect(shownCalls(events)).toEqual([
|
|
103
|
+
{ name: 'Read', input: { file_path: '/work/app/math.js', limit: 20 } },
|
|
104
|
+
{ name: 'Glob', input: { pattern: '**/*.js' } },
|
|
105
|
+
{ name: 'Grep', input: { pattern: 'add\\(', path: '/work/app', glob: '*.js' } },
|
|
106
|
+
{ name: 'Edit', input: { file_path: '/work/app/math.js', old_string: 'a - b', new_string: 'a + b' } },
|
|
107
|
+
{ name: 'Write', input: { file_path: '/work/app/README.md', content: '# app' } },
|
|
108
|
+
{ name: 'web_fetch', input: { url: 'https://example.com', format: 'markdown' } },
|
|
109
|
+
{ name: 'TodoWrite', input: { todos: [{ content: 'fix add', status: 'completed' }] } },
|
|
110
|
+
]);
|
|
111
|
+
expect(events).toContainEqual(expect.objectContaining({ type: 'reasoning_message', content: 'Look first.' }));
|
|
112
|
+
const messages = events.filter((e) => e.type === 'assistant_message' && e.source === 'model');
|
|
113
|
+
expect(messages).toEqual([
|
|
114
|
+
expect.objectContaining({ content: 'Reading.', stopReason: 'tool_use' }),
|
|
115
|
+
expect.objectContaining({ content: 'Fixed add.', stopReason: 'end_turn' }),
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('reports a tool OpenCode saw fail as failed', async () => {
|
|
120
|
+
const { session } = connect(async (turn) => {
|
|
121
|
+
await turn.tool('bash', 'execute', { command: 'false' }, '', { fails: 'exit code 1' });
|
|
122
|
+
turn.say('It failed.');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const events = await collectTurn(session, 'run false');
|
|
126
|
+
|
|
127
|
+
expect(events).toContainEqual(expect.objectContaining({ type: 'tool_result', ok: false, error: expect.objectContaining({ message: 'exit code 1' }) }));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('runs the model and effort the person picked, and changes them only when they differ', async () => {
|
|
131
|
+
const { opencode, session } = connect(async (turn) => turn.say(`${turn.session.model} ${turn.session.effort ?? '-'}`), {}, 'opencode/space-bunny-free');
|
|
132
|
+
session.reasoning = { effort: 'medium' };
|
|
133
|
+
|
|
134
|
+
const first = await collectTurn(session, 'which model?');
|
|
135
|
+
const second = await collectTurn(session, 'again');
|
|
136
|
+
|
|
137
|
+
expect(opencode.last.sent('session/set_config_option')).toEqual([
|
|
138
|
+
{ sessionId: 'ses_1', configId: 'model', value: 'opencode/space-bunny-free' },
|
|
139
|
+
{ sessionId: 'ses_1', configId: 'effort', value: 'medium' },
|
|
140
|
+
]);
|
|
141
|
+
expect([...said(first), ...said(second)]).toEqual(['opencode/space-bunny-free medium', 'opencode/space-bunny-free medium']);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('leaves the effort alone when the model offers no such level', async () => {
|
|
145
|
+
const { opencode, session } = connect(async (turn) => turn.say('ok'));
|
|
146
|
+
session.reasoning = { effort: 'medium' };
|
|
147
|
+
|
|
148
|
+
await collectTurn(session, 'hi');
|
|
149
|
+
|
|
150
|
+
expect(opencode.last.sent('session/set_config_option')).toEqual([]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('says so when OpenCode has no such model', async () => {
|
|
154
|
+
const { session } = connect(async (turn) => turn.say('ok'), {}, 'openai/gpt-0');
|
|
155
|
+
|
|
156
|
+
const events = await collectTurn(session, 'hi');
|
|
157
|
+
|
|
158
|
+
expect(events).toContainEqual(expect.objectContaining({ type: 'error', kind: 'fatal', message: expect.stringContaining('openai/gpt-0') }));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('hands OpenCode the person images with the message', async () => {
|
|
162
|
+
const { opencode, session } = connect(async (turn) => turn.say(`${turn.prompt.length} parts`));
|
|
163
|
+
|
|
164
|
+
await collectTurn(session, 'what is this?', {
|
|
165
|
+
attachments: [{ kind: 'image', mediaType: 'image/png', content: 'iVBORw0KGgo=', name: 'shot.png' }],
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const prompt = opencode.last.sent('session/prompt')[0]?.prompt;
|
|
169
|
+
expect(prompt).toEqual(expect.arrayContaining([{ type: 'image', mimeType: 'image/png', data: 'iVBORw0KGgo=' }]));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('keeps the same OpenCode session for the next message of the conversation', async () => {
|
|
173
|
+
const { opencode, session } = connect(async (turn) => turn.say(turn.text === 'first' ? 'one' : 'two'));
|
|
174
|
+
|
|
175
|
+
await collectTurn(session, 'first');
|
|
176
|
+
const second = await collectTurn(session, 'second');
|
|
177
|
+
|
|
178
|
+
expect(opencode.servers).toHaveLength(1);
|
|
179
|
+
expect(opencode.last.sent('session/new')).toHaveLength(1);
|
|
180
|
+
expect(opencode.last.sent('session/prompt')).toHaveLength(2);
|
|
181
|
+
expect(said(second)).toEqual(['two']);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('loads the session in a new OpenCode when the old one was closed, without showing the history OpenCode replays', async () => {
|
|
185
|
+
const { opencode, session, pool } = connect(async (turn) => turn.say(turn.text === 'remember MARMOT-55' ? 'noted' : 'MARMOT-55'));
|
|
186
|
+
await collectTurn(session, 'remember MARMOT-55');
|
|
187
|
+
await pool.close(String(session.id));
|
|
188
|
+
|
|
189
|
+
const next = await collectTurn(session, 'which word?');
|
|
190
|
+
|
|
191
|
+
expect(opencode.servers).toHaveLength(2);
|
|
192
|
+
expect(opencode.servers[0]?.closed).toBe(true);
|
|
193
|
+
expect(opencode.last.sent('session/load')[0]).toMatchObject({ sessionId: 'ses_1', cwd: '/work/app', mcpServers: [expect.objectContaining({ name: 'moxxy' })] });
|
|
194
|
+
expect(opencode.last.sent('session/new')).toHaveLength(0);
|
|
195
|
+
expect(said(next)).toEqual(['MARMOT-55']);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('hears, when the person comes back to it, what the conversation did on another connection meanwhile', async () => {
|
|
199
|
+
const { opencode, session, provider } = connect(async (turn) => turn.say('ok'));
|
|
200
|
+
addFakeProvider(session, new FakeProvider({ name: 'stand-in', script: [textReply('Renamed it to orders.')] }));
|
|
201
|
+
|
|
202
|
+
await collectTurn(session, 'use port 5433');
|
|
203
|
+
session.providers.setActive('stand-in');
|
|
204
|
+
await collectTurn(session, 'rename the table to orders');
|
|
205
|
+
session.providers.setActive(provider.name);
|
|
206
|
+
await collectTurn(session, 'what did you rename?');
|
|
207
|
+
await collectTurn(session, 'thanks');
|
|
208
|
+
|
|
209
|
+
expect(opencode.last.sent('session/new')).toHaveLength(1);
|
|
210
|
+
const [, back, after] = opencode.last.sent('session/prompt');
|
|
211
|
+
const [note, prompt] = (back?.prompt ?? []) as Array<{ text: string }>;
|
|
212
|
+
expect(note?.text).toMatch(/another connection/);
|
|
213
|
+
expect(note?.text).toContain('### Turn 2\nPerson: rename the table to orders\nAgent: Renamed it to orders.');
|
|
214
|
+
expect(note?.text).not.toContain('use port 5433');
|
|
215
|
+
expect(prompt?.text).toBe('what did you rename?');
|
|
216
|
+
expect(after?.prompt).toEqual([{ type: 'text', text: 'thanks' }]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('starts a new session with the conversation so far when OpenCode no longer has the old one', async () => {
|
|
220
|
+
const { opencode, session, pool } = connect(async (turn) => turn.say('port 5433 it is'));
|
|
221
|
+
await collectTurn(session, 'use port 5433');
|
|
222
|
+
await pool.close(String(session.id));
|
|
223
|
+
opencode.saved.clear();
|
|
224
|
+
|
|
225
|
+
const events = await collectTurn(session, 'which port?');
|
|
226
|
+
await collectTurn(session, 'thanks');
|
|
227
|
+
|
|
228
|
+
expect(opencode.last.sent('session/list')[0]).toMatchObject({ cwd: '/work/app' });
|
|
229
|
+
expect(opencode.last.sent('session/new')).toHaveLength(1);
|
|
230
|
+
expect(events).toContainEqual(expect.objectContaining({ type: 'plugin_event', subtype: 'agent_session', payload: { provider: 'opencode', sessionId: 'ses_2' } }));
|
|
231
|
+
const [first, second] = opencode.last.sent('session/prompt');
|
|
232
|
+
const [note, prompt] = (first?.prompt ?? []) as Array<{ text: string }>;
|
|
233
|
+
expect(note?.text).toMatch(/^Your earlier session for this Moxxy conversation no longer exists/);
|
|
234
|
+
expect(note?.text).toContain('### Turn 1\nPerson: use port 5433\nAgent: port 5433 it is');
|
|
235
|
+
expect(prompt?.text).toBe('which port?');
|
|
236
|
+
expect(second?.prompt).toEqual([{ type: 'text', text: 'thanks' }]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('lends OpenCode recall, which searches the whole conversation, beside the tools it lacks', () => {
|
|
240
|
+
const tools = ['Read', 'recall', 'session_recall', 'ask_user'].map((name) => ({ name }) as ToolDef);
|
|
241
|
+
|
|
242
|
+
expect(toolsLentTo(OPENCODE_AGENT, tools).map((tool) => tool.name)).toEqual(['recall', 'ask_user']);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('finds the session on a later page of OpenCode sessions before deciding it is gone', async () => {
|
|
246
|
+
const { opencode, session, pool } = connect(async (turn) => turn.say('ok'));
|
|
247
|
+
await collectTurn(session, 'hello');
|
|
248
|
+
await pool.close(String(session.id));
|
|
249
|
+
for (const id of ['ses_a', 'ses_b']) opencode.saved.set(id, { ...[...opencode.saved.values()][0], id } as never);
|
|
250
|
+
const ours = opencode.saved.get('ses_1');
|
|
251
|
+
opencode.saved.delete('ses_1');
|
|
252
|
+
if (ours) opencode.saved.set('ses_1', ours);
|
|
253
|
+
opencode.loadFailures = 1;
|
|
254
|
+
|
|
255
|
+
await collectTurn(session, 'hello again');
|
|
256
|
+
|
|
257
|
+
expect(opencode.last.sent('session/list').length).toBeGreaterThan(1);
|
|
258
|
+
expect(opencode.last.sent('session/load')).toHaveLength(2);
|
|
259
|
+
expect(opencode.last.sent('session/new')).toHaveLength(0);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('loads the session when OpenCode fails to once, instead of starting another', async () => {
|
|
263
|
+
const { opencode, session, pool } = connect(async (turn) => turn.say(turn.text === 'remember MARMOT-55' ? 'noted' : 'MARMOT-55'));
|
|
264
|
+
await collectTurn(session, 'remember MARMOT-55');
|
|
265
|
+
await pool.close(String(session.id));
|
|
266
|
+
opencode.loadFailures = 1;
|
|
267
|
+
|
|
268
|
+
const next = await collectTurn(session, 'which word?');
|
|
269
|
+
|
|
270
|
+
expect(opencode.last.sent('session/load')).toHaveLength(2);
|
|
271
|
+
expect(opencode.last.sent('session/new')).toHaveLength(0);
|
|
272
|
+
expect(said(next)).toEqual(['MARMOT-55']);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('starts no other session when OpenCode keeps failing to load it, and says what happened', async () => {
|
|
276
|
+
const { opencode, session, pool } = connect(async (turn) => turn.say('ok'));
|
|
277
|
+
await collectTurn(session, 'remember MARMOT-55');
|
|
278
|
+
await pool.close(String(session.id));
|
|
279
|
+
opencode.loadFailures = 2;
|
|
280
|
+
|
|
281
|
+
const events = await collectTurn(session, 'which word?');
|
|
282
|
+
|
|
283
|
+
expect(opencode.last.sent('session/new')).toHaveLength(0);
|
|
284
|
+
expect(events).toContainEqual(
|
|
285
|
+
expect.objectContaining({ type: 'error', message: expect.stringContaining("OpenCode could not load this conversation's earlier session") }),
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe('the context window OpenCode reports', () => {
|
|
291
|
+
it('replaces the 200k starting window of the turn model with the size OpenCode gives', async () => {
|
|
292
|
+
const { session } = connect(async (turn) => {
|
|
293
|
+
turn.server.update(turn.session, { sessionUpdate: 'usage_update', used: 12_000, size: 256_000 });
|
|
294
|
+
turn.say('Hi.');
|
|
295
|
+
});
|
|
296
|
+
const windowOf = () =>
|
|
297
|
+
session.getInfo().providers.find((p) => p.name === 'opencode')?.models.find((m) => m.id === 'opencode/big-pickle')?.contextWindow;
|
|
298
|
+
expect(windowOf()).toBe(200_000);
|
|
299
|
+
|
|
300
|
+
await collectTurn(session, 'hi');
|
|
301
|
+
|
|
302
|
+
expect(windowOf()).toBe(256_000);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('shows how full OpenCode says its context is, not the usage its turn added up', async () => {
|
|
306
|
+
const { session } = connect(async (turn) => {
|
|
307
|
+
turn.server.update(turn.session, { sessionUpdate: 'usage_update', used: 12_000, size: 256_000 });
|
|
308
|
+
turn.say('Hi.');
|
|
309
|
+
turn.usage({ inputTokens: 40_000, outputTokens: 300, totalTokens: 40_300 });
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const events = await collectTurn(session, 'hi');
|
|
313
|
+
|
|
314
|
+
expect(events.find((e) => e.type === 'provider_response')).toMatchObject({ contextTokens: 12_000 });
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe('approvals: OpenCode asks, Moxxy answers', () => {
|
|
319
|
+
it('asks the person when OpenCode asks, and tells OpenCode the answer', async () => {
|
|
320
|
+
let allowed: boolean | undefined;
|
|
321
|
+
const { opencode, session } = connect(async (turn) => {
|
|
322
|
+
({ allowed } = await turn.tool('read', 'read', { filePath: '/elsewhere/notes.md' }, '# Notes', { title: '/elsewhere/notes.md', ask: true }));
|
|
323
|
+
if (allowed) turn.say('Read it.');
|
|
324
|
+
});
|
|
325
|
+
session.setPermissionResolver(allowAll);
|
|
326
|
+
|
|
327
|
+
const events = await collectTurn(session, 'read /elsewhere/notes.md');
|
|
328
|
+
|
|
329
|
+
expect(allowed).toBe(true);
|
|
330
|
+
expect(opencode.last.permissionAnswers).toEqual([{ outcome: { outcome: 'selected', optionId: 'once' } }]);
|
|
331
|
+
const call = events.find((e) => e.type === 'tool_call_requested' && e.name === 'Read');
|
|
332
|
+
if (call?.type !== 'tool_call_requested') throw new Error('expected the read to be shown');
|
|
333
|
+
const order = events.filter((e) => 'callId' in e && e.callId === call.callId).map((e) => e.type);
|
|
334
|
+
expect(order).toEqual(['tool_call_requested', 'tool_call_approved', 'tool_result']);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('lets OpenCode remember an allow for the rest of the session when the person allows it for the session', async () => {
|
|
338
|
+
const { opencode, session } = connect(async (turn) => {
|
|
339
|
+
await turn.tool('bash', 'execute', { command: 'npm test' }, 'ok', { ask: true });
|
|
340
|
+
turn.say('Done.');
|
|
341
|
+
});
|
|
342
|
+
session.setPermissionResolver(allowForSession);
|
|
343
|
+
|
|
344
|
+
await collectTurn(session, 'test it');
|
|
345
|
+
|
|
346
|
+
expect(opencode.last.permissionAnswers).toEqual([{ outcome: { outcome: 'selected', optionId: 'always' } }]);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('refuses when the person declines, and gives OpenCode their reason in the same turn, since its answer carries none', async () => {
|
|
350
|
+
const { opencode, session } = connect(async (turn) => {
|
|
351
|
+
if (turn.text === 'build it') {
|
|
352
|
+
await turn.tool('write', 'edit', { filePath: '/work/app/app.js', content: '' }, '', { ask: true });
|
|
353
|
+
// OpenCode ends its turn after a refusal.
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
turn.say(`Understood: ${turn.text}`);
|
|
357
|
+
});
|
|
358
|
+
session.setPermissionResolver(personSays('not now, build it in goal mode'));
|
|
359
|
+
|
|
360
|
+
const events = await collectTurn(session, 'build it');
|
|
361
|
+
|
|
362
|
+
expect(opencode.last.permissionAnswers).toEqual([{ outcome: { outcome: 'selected', optionId: 'reject' } }]);
|
|
363
|
+
const prompts = opencode.last.sent('session/prompt');
|
|
364
|
+
expect(prompts).toHaveLength(2);
|
|
365
|
+
expect(prompts[1]?.prompt).toEqual([{ type: 'text', text: expect.stringContaining('not now, build it in goal mode') }]);
|
|
366
|
+
expect(said(events)[0]).toContain('Write');
|
|
367
|
+
expect(events).toContainEqual(expect.objectContaining({ type: 'tool_call_denied', reason: 'not now, build it in goal mode' }));
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('stops sending the reason after a few refusals in one turn, and keeps it for the next message', async () => {
|
|
371
|
+
const { opencode, session } = connect(async (turn) => {
|
|
372
|
+
if (turn.text.endsWith('and now?')) return turn.say('ok');
|
|
373
|
+
await turn.tool('bash', 'execute', { command: 'git push --force' }, '', { ask: true });
|
|
374
|
+
});
|
|
375
|
+
session.setPermissionResolver(personSays('no force pushes'));
|
|
376
|
+
|
|
377
|
+
await collectTurn(session, 'push it');
|
|
378
|
+
const sentInTurn = opencode.last.sent('session/prompt').length;
|
|
379
|
+
await collectTurn(session, 'and now?');
|
|
380
|
+
|
|
381
|
+
expect(sentInTurn).toBe(4);
|
|
382
|
+
const next = opencode.last.sent('session/prompt').at(-1)?.prompt;
|
|
383
|
+
expect(next).toEqual([{ type: 'text', text: expect.stringContaining('no force pushes') }, { type: 'text', text: 'and now?' }]);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('answers OpenCode asks itself while auto-approve is on, but a standing deny rule still refuses', async () => {
|
|
387
|
+
let asked = 0;
|
|
388
|
+
const { opencode, session } = connect(async (turn) => {
|
|
389
|
+
if (opencode.promptCount > 1) return turn.say('I will not.');
|
|
390
|
+
await turn.tool('bash', 'execute', { command: 'npm test' }, 'ok', { ask: true });
|
|
391
|
+
await turn.tool('bash', 'execute', { command: 'rm -rf /' }, '', { ask: true });
|
|
392
|
+
});
|
|
393
|
+
session.setPermissionResolver({
|
|
394
|
+
name: 'person',
|
|
395
|
+
check: async () => {
|
|
396
|
+
asked += 1;
|
|
397
|
+
return { mode: 'deny', reason: 'asked' };
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
await session.permissions.addDeny({ name: 'Bash', inputMatches: { command: 'rm -rf' }, reason: 'never rm -rf' });
|
|
401
|
+
await session.setAutoApprove(true);
|
|
402
|
+
|
|
403
|
+
await collectTurn(session, 'test it');
|
|
404
|
+
|
|
405
|
+
expect(asked).toBe(0);
|
|
406
|
+
expect(opencode.last.permissionAnswers).toEqual([
|
|
407
|
+
{ outcome: { outcome: 'selected', optionId: 'once' } },
|
|
408
|
+
{ outcome: { outcome: 'selected', optionId: 'reject' } },
|
|
409
|
+
]);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('keeps auto-approve the person turned off when a new OpenCode loads the session', async () => {
|
|
413
|
+
const { session, opencode, pool } = connect(async (turn) => turn.say('ok'), { rules: { build: OPENCODE_DEFAULT_RULES } });
|
|
414
|
+
await collectTurn(session, 'one');
|
|
415
|
+
await session.setAutoApprove(false);
|
|
416
|
+
await collectTurn(session, 'two');
|
|
417
|
+
await pool.close(String(session.id));
|
|
418
|
+
|
|
419
|
+
await collectTurn(session, 'three');
|
|
420
|
+
|
|
421
|
+
expect(opencode.last.sent('session/load')).toHaveLength(1);
|
|
422
|
+
expect(autoApproveFromEvents(session.log.ofType('plugin_event'))).toBe(false);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('leaves auto-approve off when OpenCode own rules ask before edits and commands', async () => {
|
|
426
|
+
const { session, opencode } = connect(async (turn) => turn.say('ok'));
|
|
427
|
+
|
|
428
|
+
await collectTurn(session, 'hi');
|
|
429
|
+
|
|
430
|
+
expect(opencode.rulesRead).toEqual(['build']);
|
|
431
|
+
expect(autoApproveFromEvents(session.log.ofType('plugin_event'))).toBe(false);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('lends Moxxy tools over MCP, run through Moxxy permissions and shown once', async () => {
|
|
435
|
+
let ran = 0;
|
|
436
|
+
const browserOpen = defineTool({
|
|
437
|
+
name: 'browser_open',
|
|
438
|
+
description: 'Open a page in the visible browser.',
|
|
439
|
+
inputSchema: z.object({ url: z.string() }),
|
|
440
|
+
handler: async ({ url }) => {
|
|
441
|
+
ran += 1;
|
|
442
|
+
return `opened ${url}`;
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
let lent: unknown;
|
|
446
|
+
const { session } = connect(async (turn) => {
|
|
447
|
+
lent = await turn.callMoxxyTool('browser_open', { url: 'http://localhost:8765' });
|
|
448
|
+
turn.say('Opened.');
|
|
449
|
+
});
|
|
450
|
+
session.pluginHost.registerStatic(definePlugin({ name: 'test-browser', tools: [browserOpen] }));
|
|
451
|
+
|
|
452
|
+
const events = await collectTurn(session, 'open the app');
|
|
453
|
+
|
|
454
|
+
expect(ran).toBe(1);
|
|
455
|
+
expect(lent).toMatchObject({ content: [{ type: 'text', text: 'opened http://localhost:8765' }] });
|
|
456
|
+
expect(shownCalls(events).map((call) => call.name)).toEqual(['browser_open']);
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
describe('auto-approve shows how OpenCode really runs', () => {
|
|
461
|
+
const pushDenied = { build: [...OPENCODE_DEFAULT_RULES, { permission: 'bash', pattern: 'git push *', action: 'deny' as const }] };
|
|
462
|
+
const autoApprove = (session: Session) => autoApproveFromEvents(session.log.ofType('plugin_event'));
|
|
463
|
+
|
|
464
|
+
it('is on for a new conversation when OpenCode runs without asking, as it does on its defaults', async () => {
|
|
465
|
+
const { session, opencode } = connect(async (turn) => turn.say('ok'), { rules: { build: OPENCODE_DEFAULT_RULES } });
|
|
466
|
+
|
|
467
|
+
await collectTurn(session, 'hi');
|
|
468
|
+
|
|
469
|
+
expect(autoApprove(session)).toBe(true);
|
|
470
|
+
expect(opencode.servers).toHaveLength(1);
|
|
471
|
+
expect(opencode.last.env.OPENCODE_CONFIG_CONTENT).toBeUndefined();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('keeps auto-approve off the person chose before the first message, over OpenCode running without asking', async () => {
|
|
475
|
+
const { session } = connect(async (turn) => turn.say('ok'), { rules: { build: OPENCODE_DEFAULT_RULES } });
|
|
476
|
+
await session.setAutoApprove(true);
|
|
477
|
+
await session.setAutoApprove(false);
|
|
478
|
+
|
|
479
|
+
await collectTurn(session, 'hi');
|
|
480
|
+
|
|
481
|
+
expect(autoApprove(session)).toBe(false);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('has OpenCode ask before changes once the person turns it off, keeping what OpenCode own rules deny', async () => {
|
|
485
|
+
const asked: string[] = [];
|
|
486
|
+
const { session, opencode } = connect(
|
|
487
|
+
async (turn) => {
|
|
488
|
+
if (opencode.promptCount === 1) return turn.say('ok');
|
|
489
|
+
const write = await turn.tool('write', 'edit', { filePath: '/work/app/a.txt', content: 'x' }, 'Wrote file successfully.');
|
|
490
|
+
const push = await turn.tool('bash', 'execute', { command: 'git push origin main' }, 'pushed');
|
|
491
|
+
turn.say(`write ${write.allowed}, push ${push.allowed}`);
|
|
492
|
+
},
|
|
493
|
+
{ rules: pushDenied },
|
|
494
|
+
);
|
|
495
|
+
session.setPermissionResolver({ name: 'person', check: async (call) => (asked.push(call.name), { mode: 'allow' }) });
|
|
496
|
+
await collectTurn(session, 'hi');
|
|
497
|
+
|
|
498
|
+
await session.setAutoApprove(false);
|
|
499
|
+
const events = await collectTurn(session, 'write it');
|
|
500
|
+
|
|
501
|
+
expect(opencode.servers).toHaveLength(2);
|
|
502
|
+
expect(JSON.parse(String(opencode.last.env.OPENCODE_CONFIG_CONTENT))).toEqual({
|
|
503
|
+
agent: { build: { permission: { edit: { '*': 'ask' }, bash: { '*': 'ask', 'git push *': 'deny' }, webfetch: 'ask', websearch: 'ask' } } },
|
|
504
|
+
});
|
|
505
|
+
expect(opencode.last.sent('session/load')).toEqual([expect.objectContaining({ sessionId: 'ses_1' })]);
|
|
506
|
+
expect(asked).toEqual(['Write']);
|
|
507
|
+
expect(shownCalls(events)[0]).toEqual({ name: 'Write', input: { file_path: '/work/app/a.txt', diff: expect.stringContaining('+x') } });
|
|
508
|
+
expect(said(events)).toEqual(['write true, push false']);
|
|
509
|
+
expect(autoApprove(session)).toBe(false);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('answers OpenCode asks itself again once the person turns it back on, without starting OpenCode again', async () => {
|
|
513
|
+
const asked: string[] = [];
|
|
514
|
+
const { session, opencode } = connect(
|
|
515
|
+
async (turn) => {
|
|
516
|
+
if (opencode.promptCount === 1) return turn.say('ok');
|
|
517
|
+
await turn.tool('write', 'edit', { filePath: '/work/app/a.txt', content: 'x' }, 'Wrote file successfully.');
|
|
518
|
+
turn.say('written');
|
|
519
|
+
},
|
|
520
|
+
{ rules: { build: OPENCODE_DEFAULT_RULES } },
|
|
521
|
+
);
|
|
522
|
+
session.setPermissionResolver({ name: 'person', check: async (call) => (asked.push(call.name), { mode: 'allow' }) });
|
|
523
|
+
await collectTurn(session, 'hi');
|
|
524
|
+
await session.setAutoApprove(false);
|
|
525
|
+
await collectTurn(session, 'write it');
|
|
526
|
+
|
|
527
|
+
await session.setAutoApprove(true);
|
|
528
|
+
await collectTurn(session, 'write it again');
|
|
529
|
+
|
|
530
|
+
expect(opencode.servers).toHaveLength(2);
|
|
531
|
+
expect(opencode.last.permissionAnswers).toHaveLength(2);
|
|
532
|
+
expect(asked).toEqual(['Write']);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('still asks the person before OpenCode works outside the project while auto-approve turned itself on', async () => {
|
|
536
|
+
const asked: string[] = [];
|
|
537
|
+
const { session, opencode } = connect(
|
|
538
|
+
async (turn) => {
|
|
539
|
+
const write = await turn.tool('write', 'edit', { filePath: '/elsewhere/a.txt', content: 'x' }, 'Wrote file successfully.', { outside: '/elsewhere/a.txt' });
|
|
540
|
+
const list = await turn.tool('bash', 'execute', { command: 'ls /elsewhere' }, 'a.txt', { outside: '/elsewhere' });
|
|
541
|
+
const local = await turn.tool('write', 'edit', { filePath: '/work/app/b.txt', content: 'y' }, 'Wrote file successfully.');
|
|
542
|
+
turn.say(`outside ${write.allowed} ${list.allowed}, local ${local.allowed}`);
|
|
543
|
+
},
|
|
544
|
+
{ rules: { build: OPENCODE_DEFAULT_RULES } },
|
|
545
|
+
);
|
|
546
|
+
session.setPermissionResolver({ name: 'person', check: async (call) => (asked.push(call.name), { mode: 'allow' }) });
|
|
547
|
+
|
|
548
|
+
const events = await collectTurn(session, 'write it');
|
|
549
|
+
|
|
550
|
+
expect(autoApprove(session)).toBe(true);
|
|
551
|
+
expect(asked).toHaveLength(2);
|
|
552
|
+
expect(opencode.last.permissionAnswers).toHaveLength(2);
|
|
553
|
+
expect(said(events)).toEqual(['outside true true, local true']);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('still asks about outside the project when Moxxy took on OpenCode running without asking as the conversation opened', async () => {
|
|
557
|
+
const asked: string[] = [];
|
|
558
|
+
const { session } = connect(
|
|
559
|
+
async (turn) => {
|
|
560
|
+
await turn.tool('write', 'edit', { filePath: '/elsewhere/a.txt', content: 'x' }, 'Wrote file successfully.', { outside: '/elsewhere/a.txt' });
|
|
561
|
+
turn.say('written');
|
|
562
|
+
},
|
|
563
|
+
{ rules: { build: OPENCODE_DEFAULT_RULES } },
|
|
564
|
+
);
|
|
565
|
+
session.setPermissionResolver({ name: 'person', check: async (call) => (asked.push(call.name), { mode: 'allow' }) });
|
|
566
|
+
await session.log.append({
|
|
567
|
+
type: 'plugin_event',
|
|
568
|
+
sessionId: session.id,
|
|
569
|
+
turnId: newTurnId(),
|
|
570
|
+
source: 'system',
|
|
571
|
+
pluginId: AUTO_APPROVE_PLUGIN_ID,
|
|
572
|
+
subtype: AUTO_APPROVE_SUBTYPE,
|
|
573
|
+
payload: { enabled: true },
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
await collectTurn(session, 'write it');
|
|
577
|
+
|
|
578
|
+
expect(asked).toEqual(['Write']);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('answers those asks itself once the person chose auto-approve', async () => {
|
|
582
|
+
const asked: string[] = [];
|
|
583
|
+
const { session, opencode } = connect(
|
|
584
|
+
async (turn) => {
|
|
585
|
+
if (opencode.promptCount < 3) return turn.say('ok');
|
|
586
|
+
await turn.tool('write', 'edit', { filePath: '/elsewhere/a.txt', content: 'x' }, 'Wrote file successfully.', { outside: '/elsewhere/a.txt' });
|
|
587
|
+
turn.say('written');
|
|
588
|
+
},
|
|
589
|
+
{ rules: { build: OPENCODE_DEFAULT_RULES } },
|
|
590
|
+
);
|
|
591
|
+
session.setPermissionResolver({ name: 'person', check: async (call) => (asked.push(call.name), { mode: 'allow' }) });
|
|
592
|
+
await collectTurn(session, 'hi');
|
|
593
|
+
await session.setAutoApprove(false);
|
|
594
|
+
await collectTurn(session, 'hi again');
|
|
595
|
+
await session.setAutoApprove(true);
|
|
596
|
+
|
|
597
|
+
await collectTurn(session, 'write it');
|
|
598
|
+
|
|
599
|
+
expect(asked).toEqual([]);
|
|
600
|
+
expect(opencode.last.permissionAnswers.length).toBeGreaterThan(0);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('keeps asking about outside the project on later messages while auto-approve is still the one OpenCode turned on', async () => {
|
|
604
|
+
const asked: string[] = [];
|
|
605
|
+
const { session } = connect(
|
|
606
|
+
async (turn) => {
|
|
607
|
+
await turn.tool('write', 'edit', { filePath: '/elsewhere/a.txt', content: 'x' }, 'Wrote file successfully.', { outside: '/elsewhere/a.txt' });
|
|
608
|
+
turn.say('written');
|
|
609
|
+
},
|
|
610
|
+
{ rules: { build: OPENCODE_DEFAULT_RULES } },
|
|
611
|
+
);
|
|
612
|
+
session.setPermissionResolver({ name: 'person', check: async (call) => (asked.push(call.name), { mode: 'allow' }) });
|
|
613
|
+
await collectTurn(session, 'one');
|
|
614
|
+
|
|
615
|
+
await collectTurn(session, 'two');
|
|
616
|
+
|
|
617
|
+
expect(asked).toHaveLength(2);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it('keeps asking about outside the project after a new OpenCode loads the session, since it cannot tell who turned auto-approve on', async () => {
|
|
621
|
+
const asked: string[] = [];
|
|
622
|
+
const { session, opencode, pool } = connect(
|
|
623
|
+
async (turn) => {
|
|
624
|
+
await turn.tool('write', 'edit', { filePath: '/elsewhere/a.txt', content: 'x' }, 'Wrote file successfully.', { outside: '/elsewhere/a.txt' });
|
|
625
|
+
turn.say('written');
|
|
626
|
+
},
|
|
627
|
+
{ rules: { build: OPENCODE_DEFAULT_RULES } },
|
|
628
|
+
);
|
|
629
|
+
session.setPermissionResolver({ name: 'person', check: async (call) => (asked.push(call.name), { mode: 'allow' }) });
|
|
630
|
+
await collectTurn(session, 'one');
|
|
631
|
+
await pool.close(String(session.id));
|
|
632
|
+
|
|
633
|
+
await collectTurn(session, 'two');
|
|
634
|
+
|
|
635
|
+
expect(opencode.last.sent('session/load')).toHaveLength(1);
|
|
636
|
+
expect(autoApprove(session)).toBe(true);
|
|
637
|
+
expect(asked).toHaveLength(2);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('answers itself after a new OpenCode loads the session when the person had chosen auto-approve', async () => {
|
|
641
|
+
const asked: string[] = [];
|
|
642
|
+
const { session, pool } = connect(
|
|
643
|
+
async (turn) => {
|
|
644
|
+
await turn.tool('write', 'edit', { filePath: '/elsewhere/a.txt', content: 'x' }, 'Wrote file successfully.', { outside: '/elsewhere/a.txt' });
|
|
645
|
+
turn.say('written');
|
|
646
|
+
},
|
|
647
|
+
{ rules: { build: OPENCODE_DEFAULT_RULES } },
|
|
648
|
+
);
|
|
649
|
+
session.setPermissionResolver({ name: 'person', check: async (call) => (asked.push(call.name), { mode: 'allow' }) });
|
|
650
|
+
await session.setAutoApprove(true);
|
|
651
|
+
await collectTurn(session, 'one');
|
|
652
|
+
await pool.close(String(session.id));
|
|
653
|
+
|
|
654
|
+
await collectTurn(session, 'two');
|
|
655
|
+
|
|
656
|
+
expect(asked).toEqual([]);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it('keeps the conversation when the asking OpenCode cannot load the session, and loads it on the next message', async () => {
|
|
660
|
+
const { session, opencode } = connect(
|
|
661
|
+
async (turn) => turn.say(turn.text === 'remember MARMOT-55' ? 'noted' : 'MARMOT-55'),
|
|
662
|
+
{ rules: { build: OPENCODE_DEFAULT_RULES } },
|
|
663
|
+
);
|
|
664
|
+
await collectTurn(session, 'remember MARMOT-55');
|
|
665
|
+
await session.setAutoApprove(false);
|
|
666
|
+
opencode.loadFailures = 2;
|
|
667
|
+
|
|
668
|
+
const failed = await collectTurn(session, 'which word?');
|
|
669
|
+
const next = await collectTurn(session, 'which word?');
|
|
670
|
+
|
|
671
|
+
expect(failed).toContainEqual(expect.objectContaining({ type: 'error', message: expect.stringContaining('could not load') }));
|
|
672
|
+
// The next agent loads the session, then starts again asking, since auto-approve stays off.
|
|
673
|
+
expect(opencode.servers).toHaveLength(4);
|
|
674
|
+
expect(opencode.servers.flatMap((server) => server.sent('session/new'))).toHaveLength(1);
|
|
675
|
+
expect(autoApprove(session)).toBe(false);
|
|
676
|
+
expect(said(next)).toEqual(['MARMOT-55']);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it('leaves auto-approve and OpenCode as they are when OpenCode cannot say how it runs', async () => {
|
|
680
|
+
const { session, opencode } = connect(async (turn) => turn.say('ok'), { rules: null });
|
|
681
|
+
|
|
682
|
+
await collectTurn(session, 'hi');
|
|
683
|
+
await session.setAutoApprove(false);
|
|
684
|
+
await collectTurn(session, 'again');
|
|
685
|
+
|
|
686
|
+
expect(autoApprove(session)).toBe(false);
|
|
687
|
+
expect(opencode.servers).toHaveLength(1);
|
|
688
|
+
});
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
describe("the person's Moxxy skills on OpenCode", () => {
|
|
692
|
+
const skill = (name: string): Skill => ({
|
|
693
|
+
id: name as Skill['id'],
|
|
694
|
+
path: `/skills/${name}.md`,
|
|
695
|
+
scope: 'user',
|
|
696
|
+
frontmatter: { name, description: `About ${name}` },
|
|
697
|
+
body: `Steps for ${name}.`,
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
function withSkills(scene: OpenCodeScene, defaults: FakeOpenCodeDefaults = {}) {
|
|
701
|
+
const connected = connect(scene, defaults);
|
|
702
|
+
connected.session.pluginHost.registerStatic(buildSynthesizeSkillPlugin(connected.session));
|
|
703
|
+
connected.session.setPermissionResolver(allowAll);
|
|
704
|
+
return connected;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
it("lets OpenCode read one, leaving a skill OpenCode has itself to OpenCode's", async () => {
|
|
708
|
+
const read: Array<{ content: unknown; isError?: boolean }> = [];
|
|
709
|
+
const { session } = withSkills(
|
|
710
|
+
async (turn) => {
|
|
711
|
+
read.push(await turn.callMoxxyTool('load_skill', { name: 'monthly-invoices' }));
|
|
712
|
+
read.push(await turn.callMoxxyTool('load_skill', { name: 'deploy-staging' }));
|
|
713
|
+
turn.say('ok');
|
|
714
|
+
},
|
|
715
|
+
{ ownSkills: ['deploy-staging'] },
|
|
716
|
+
);
|
|
717
|
+
session.skills.register(skill('monthly-invoices'));
|
|
718
|
+
session.skills.register(skill('deploy-staging'));
|
|
719
|
+
|
|
720
|
+
const events = await collectTurn(session, 'do the invoices');
|
|
721
|
+
|
|
722
|
+
expect(read[0]).toEqual({ content: [{ type: 'text', text: expect.stringContaining('Steps for monthly-invoices.') }] });
|
|
723
|
+
expect(read[1]).toMatchObject({ isError: true, content: [{ text: expect.stringContaining('No Moxxy skill is named "deploy-staging"') }] });
|
|
724
|
+
expect(events).toContainEqual(expect.objectContaining({ type: 'skill_invoked', name: 'monthly-invoices' }));
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it('reach OpenCode at its next turn when one is saved during the conversation', async () => {
|
|
728
|
+
const read: Array<{ content: unknown; isError?: boolean }> = [];
|
|
729
|
+
const { opencode, session } = withSkills(async (turn) => {
|
|
730
|
+
if (turn.text === 'use it') read.push(await turn.callMoxxyTool('load_skill', { name: 'monthly-invoices' }));
|
|
731
|
+
turn.say('ok');
|
|
732
|
+
});
|
|
733
|
+
await collectTurn(session, 'hello');
|
|
734
|
+
session.skills.register(skill('monthly-invoices'));
|
|
735
|
+
|
|
736
|
+
await collectTurn(session, 'use it');
|
|
737
|
+
|
|
738
|
+
expect(opencode.servers).toHaveLength(1);
|
|
739
|
+
expect(read).toEqual([{ content: [{ type: 'text', text: expect.stringContaining('Steps for monthly-invoices.') }] }]);
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
describe('plan and goal mode', () => {
|
|
744
|
+
const plan = {
|
|
745
|
+
title: 'Fix add',
|
|
746
|
+
objective: 'add returns the sum.',
|
|
747
|
+
approach: 'Change the operator and cover it with a test.',
|
|
748
|
+
steps: [{ id: 'P1', title: 'Fix the operator', outcome: 'add(2, 2) is 4', actions: ['Use a + b'] }],
|
|
749
|
+
recommendedMode: 'goal',
|
|
750
|
+
handoff: 'Apply P1.',
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
it('plans in OpenCode plan mode and shows the plan it submits through plan_complete in the Moxxy plan card', async () => {
|
|
754
|
+
const { opencode, session } = connect(async (turn) => {
|
|
755
|
+
if (turn.session.mode !== 'plan') return turn.say('Implemented.');
|
|
756
|
+
await turn.tool('read', 'read', { filePath: '/work/app/math.js' }, 'a - b');
|
|
757
|
+
await turn.callMoxxyTool('plan_complete', plan);
|
|
758
|
+
turn.say('Plan submitted.');
|
|
759
|
+
});
|
|
760
|
+
session.pluginHost.registerStatic(planModePlugin);
|
|
761
|
+
session.modes.setActive('plan');
|
|
762
|
+
|
|
763
|
+
const planned = await collectTurn(session, 'plan the fix');
|
|
764
|
+
session.modes.setActive('default');
|
|
765
|
+
const done = await collectTurn(session, 'go');
|
|
766
|
+
|
|
767
|
+
expect(opencode.last.sent('session/set_config_option')).toEqual([
|
|
768
|
+
{ sessionId: 'ses_1', configId: 'mode', value: 'plan' },
|
|
769
|
+
{ sessionId: 'ses_1', configId: 'mode', value: 'build' },
|
|
770
|
+
]);
|
|
771
|
+
expect(planned).toContainEqual(expect.objectContaining({ type: 'plugin_event', subtype: 'plan_completed', payload: expect.objectContaining({ title: 'Fix add' }) }));
|
|
772
|
+
expect(said(done)).toEqual(['Implemented.']);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it('has OpenCode, which reads the Moxxy tools once when the session opens, read them again when a turn brings others', async () => {
|
|
776
|
+
const read: Array<ReadonlyArray<string>> = [];
|
|
777
|
+
const { session } = connect(async (turn) => {
|
|
778
|
+
read.push(turn.session.moxxyTools)
|
|
779
|
+
if (turn.session.mode === 'plan') await turn.callMoxxyTool('plan_complete', plan);
|
|
780
|
+
turn.say('ok');
|
|
781
|
+
});
|
|
782
|
+
session.pluginHost.registerStatic(planModePlugin);
|
|
783
|
+
|
|
784
|
+
await collectTurn(session, 'hello');
|
|
785
|
+
session.modes.setActive('plan');
|
|
786
|
+
const planned = await collectTurn(session, 'plan the fix');
|
|
787
|
+
|
|
788
|
+
expect(read[0]).not.toContain('plan_complete');
|
|
789
|
+
expect(read[1]).toContain('plan_complete');
|
|
790
|
+
expect(planned).toContainEqual(expect.objectContaining({ type: 'plugin_event', subtype: 'plan_completed' }));
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it('works on a goal until OpenCode declares it met through goal_complete, then hands back the previous mode', async () => {
|
|
794
|
+
const { opencode, session } = connect(async (turn) => {
|
|
795
|
+
if (opencode.promptCount === 1) {
|
|
796
|
+
await turn.tool('write', 'edit', { filePath: '/work/app/goal.txt', content: 'GOAL-DONE' }, 'Wrote file successfully.');
|
|
797
|
+
return turn.say('goal.txt is written.');
|
|
798
|
+
}
|
|
799
|
+
await turn.callMoxxyTool('goal_complete', { summary: 'goal.txt says GOAL-DONE', evidence: ['cat goal.txt'] });
|
|
800
|
+
turn.say('Done.');
|
|
801
|
+
});
|
|
802
|
+
session.pluginHost.registerStatic(goalModePlugin);
|
|
803
|
+
session.modes.setActive('goal');
|
|
804
|
+
|
|
805
|
+
const events = await collectTurn(session, 'goal.txt says GOAL-DONE');
|
|
806
|
+
|
|
807
|
+
const prompts = opencode.last.sent('session/prompt');
|
|
808
|
+
expect(prompts).toHaveLength(2);
|
|
809
|
+
expect(prompts[1]?.prompt).toEqual([{ type: 'text', text: expect.stringContaining('goal_complete') }]);
|
|
810
|
+
expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed')).toBe(true);
|
|
811
|
+
expect(session.modes.getActiveName()).toBe('default');
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
describe('stopping and failing', () => {
|
|
816
|
+
it('cancels the OpenCode prompt on Stop, and the next message works as usual', async () => {
|
|
817
|
+
const controller = new AbortController();
|
|
818
|
+
const { opencode, session } = connect(async (turn) => {
|
|
819
|
+
if (turn.text === 'wait') {
|
|
820
|
+
turn.server.update(turn.session, { sessionUpdate: 'tool_call', toolCallId: 'call_wait', title: 'bash', kind: 'execute', status: 'pending', rawInput: {} });
|
|
821
|
+
turn.server.update(turn.session, { sessionUpdate: 'tool_call_update', toolCallId: 'call_wait', title: 'sleep 100', kind: 'execute', status: 'in_progress', rawInput: { command: 'sleep 100' } });
|
|
822
|
+
controller.abort();
|
|
823
|
+
await new Promise(() => undefined);
|
|
824
|
+
}
|
|
825
|
+
turn.say('back');
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
const stopped = await collectTurn(session, 'wait', { signal: controller.signal });
|
|
829
|
+
const next = await collectTurn(session, 'again');
|
|
830
|
+
|
|
831
|
+
expect(opencode.last.sent('session/cancel')).toEqual([{ sessionId: 'ses_1' }]);
|
|
832
|
+
expect(stopped.map((e) => e.type)).toContain('abort');
|
|
833
|
+
expect(stopped).toContainEqual(expect.objectContaining({ type: 'tool_result', callId: 'call_wait', ok: false }));
|
|
834
|
+
expect(said(next)).toEqual(['back']);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
it('answers an ask OpenCode is waiting on as cancelled when the person stops the turn', async () => {
|
|
838
|
+
const controller = new AbortController();
|
|
839
|
+
const { opencode, session } = connect(async (turn) => {
|
|
840
|
+
await turn.tool('bash', 'execute', { command: 'npm publish' }, '', { ask: true });
|
|
841
|
+
});
|
|
842
|
+
session.setPermissionResolver({
|
|
843
|
+
name: 'person',
|
|
844
|
+
check: async () => {
|
|
845
|
+
controller.abort();
|
|
846
|
+
return new Promise(() => undefined);
|
|
847
|
+
},
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
await collectTurn(session, 'publish', { signal: controller.signal });
|
|
851
|
+
|
|
852
|
+
expect(opencode.last.permissionAnswers).toEqual([{ outcome: { outcome: 'cancelled' } }]);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it('tells the person to sign in again when OpenCode sign-in to a model provider expired', async () => {
|
|
856
|
+
const { session } = connect(async () => ({ error: { code: -32603, message: 'Token refresh failed: 401' } }));
|
|
857
|
+
|
|
858
|
+
const events = await collectTurn(session, 'hi');
|
|
859
|
+
|
|
860
|
+
expect(events).toContainEqual(expect.objectContaining({ type: 'error', kind: 'fatal', message: expect.stringContaining('opencode auth login') }));
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
it('ends the turn when OpenCode stops, and starts OpenCode again for the next message', async () => {
|
|
864
|
+
let calls = 0;
|
|
865
|
+
const { opencode, session } = connect(async (turn) => {
|
|
866
|
+
calls += 1;
|
|
867
|
+
if (calls === 1) {
|
|
868
|
+
turn.server.crash();
|
|
869
|
+
await new Promise(() => undefined);
|
|
870
|
+
}
|
|
871
|
+
turn.say('fine now');
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
const failed = await collectTurn(session, 'hi');
|
|
875
|
+
const next = await collectTurn(session, 'hi again');
|
|
876
|
+
|
|
877
|
+
expect(failed).toContainEqual(expect.objectContaining({ type: 'error', kind: 'fatal', message: expect.stringContaining('OpenCode stopped') }));
|
|
878
|
+
expect(opencode.servers).toHaveLength(2);
|
|
879
|
+
expect(said(next)).toEqual(['fine now']);
|
|
880
|
+
});
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
describe('an MCP server set up both in Moxxy and in OpenCode', () => {
|
|
884
|
+
it("reaches OpenCode once, through OpenCode's own setup", async () => {
|
|
885
|
+
const mcpTool = (name: string) => defineTool({ name, description: name, inputSchema: z.object({}), handler: async () => 'ok' });
|
|
886
|
+
const { opencode, session } = connect(async (turn) => turn.say('ok'), { mcpServers: ['vault'] });
|
|
887
|
+
session.pluginHost.registerStatic(definePlugin({ name: 'mcp-servers', tools: [mcpTool('mcp__vault__secret_word'), mcpTool('mcp__notes__read')] }));
|
|
888
|
+
|
|
889
|
+
await collectTurn(session, 'which tools?');
|
|
890
|
+
const fake = [...opencode.last.sessions.values()].at(-1);
|
|
891
|
+
await fake?.reading;
|
|
892
|
+
|
|
893
|
+
expect(fake?.moxxyTools).toContain('mcp__notes__read');
|
|
894
|
+
expect(fake?.moxxyTools).not.toContain('mcp__vault__secret_word');
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
describe('one-off text calls', () => {
|
|
899
|
+
it('asks OpenCode for plain text in a session of its own, without Moxxy tools', async () => {
|
|
900
|
+
const opencode = new FakeOpenCode(async (turn) => turn.say(`summary of: ${turn.text}`));
|
|
901
|
+
const provider = new AcpAgentProvider({ startAgent: opencode.start, cwd: '/work/app' });
|
|
902
|
+
|
|
903
|
+
const out: string[] = [];
|
|
904
|
+
for await (const event of provider.stream({
|
|
905
|
+
model: 'opencode/big-pickle',
|
|
906
|
+
system: 'Summarize.',
|
|
907
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: 'A long conversation' }] }],
|
|
908
|
+
})) {
|
|
909
|
+
if (event.type === 'text_delta') out.push(event.delta);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
expect(out.join('')).toBe('summary of: Summarize.\n\nA long conversation');
|
|
913
|
+
expect(opencode.last.sent('session/new')[0]).toEqual({ cwd: '/work/app', mcpServers: [] });
|
|
914
|
+
expect(opencode.last.closed).toBe(true);
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
it('puts the instructions of the request first, not as words the person said', async () => {
|
|
918
|
+
const opencode = new FakeOpenCode(async (turn) => turn.say(`asked: ${turn.text}`));
|
|
919
|
+
const provider = new AcpAgentProvider({ startAgent: opencode.start, cwd: '/work/app' });
|
|
920
|
+
|
|
921
|
+
const out: string[] = [];
|
|
922
|
+
for await (const event of provider.stream({
|
|
923
|
+
model: 'opencode/big-pickle',
|
|
924
|
+
messages: [
|
|
925
|
+
{ role: 'system', content: [{ type: 'text', text: 'Plan search queries.' }] },
|
|
926
|
+
{ role: 'user', content: [{ type: 'text', text: 'Find the release date.' }] },
|
|
927
|
+
],
|
|
928
|
+
})) {
|
|
929
|
+
if (event.type === 'text_delta') out.push(event.delta);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
expect(out.join('')).toBe('asked: Plan search queries.\n\nFind the release date.');
|
|
933
|
+
});
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
describe('subagents OpenCode runs in the background', () => {
|
|
937
|
+
/** A promise the test settles: the subagent works until the test lets it finish. */
|
|
938
|
+
function gate() {
|
|
939
|
+
let open: () => void = () => undefined;
|
|
940
|
+
const opened = new Promise<void>((resolve) => (open = resolve));
|
|
941
|
+
return { open: () => open(), opened };
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** OpenCode 1.19 with background subagents on: the turn starts one and ends at once, the subagent works on. */
|
|
945
|
+
async function leaveRunning(task: Partial<FakeBackgroundTask> = {}) {
|
|
946
|
+
const finish = gate();
|
|
947
|
+
let childId = '';
|
|
948
|
+
const { opencode, session } = connect(
|
|
949
|
+
async (turn) => {
|
|
950
|
+
if (turn.text === '2+2?') return turn.say('4');
|
|
951
|
+
childId = await turn.background({
|
|
952
|
+
description: 'inspect folder',
|
|
953
|
+
prompt: 'List the files, then reply DONE-7.',
|
|
954
|
+
work: async (child) => {
|
|
955
|
+
child.tool('bash', { command: 'ls', description: 'List files' }, 'a.txt\n');
|
|
956
|
+
child.say('Listing done.');
|
|
957
|
+
await finish.opened;
|
|
958
|
+
return 'DONE-7';
|
|
959
|
+
},
|
|
960
|
+
...task,
|
|
961
|
+
});
|
|
962
|
+
turn.say('STARTED');
|
|
963
|
+
},
|
|
964
|
+
{ api: true },
|
|
965
|
+
);
|
|
966
|
+
session.setPermissionResolver(allowAll);
|
|
967
|
+
const first = await collectTurn(session, 'Start a helper in the background');
|
|
968
|
+
return { opencode, session, first, finish, childId: () => childId };
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const opened = (session: Session) => session.log.ofType('user_prompt').filter((e) => e.origin?.kind === 'agent');
|
|
972
|
+
const helperEvents = (session: Session, subtype: string) =>
|
|
973
|
+
session.log.ofType('plugin_event').filter((e) => e.subtype === subtype).map((e) => e.payload as Record<string, unknown>);
|
|
974
|
+
const workOf = (session: Session, subtype: string) =>
|
|
975
|
+
session.log.ofType('plugin_event').filter((e) => e.pluginId === AGENT_CONNECTION_PLUGIN_ID && e.subtype === subtype).map((e) => e.payload);
|
|
976
|
+
const turnEnded = (session: Session, turnId: string) => session.log.slice().some((e) => e.type === 'provider_response' && e.turnId === turnId);
|
|
977
|
+
|
|
978
|
+
it('lists the subagent while it works, with its steps as they happen, and shows the turn OpenCode starts once it finished', async () => {
|
|
979
|
+
const { opencode, session, first, finish, childId } = await leaveRunning();
|
|
980
|
+
|
|
981
|
+
// In the turn the subagent only starts; it is not reported done.
|
|
982
|
+
expect(first.filter((e) => e.type === 'plugin_event' && e.subtype === 'subagent_started')).toHaveLength(1);
|
|
983
|
+
expect(first.filter((e) => e.type === 'plugin_event' && e.subtype === 'subagent_completed')).toEqual([]);
|
|
984
|
+
await vi.waitFor(() => expect(session.getInfo().backgroundWork).toEqual([{ id: 'call_ses_1_1', kind: 'agent', label: 'inspect folder' }]));
|
|
985
|
+
await vi.waitFor(() => expect(helperEvents(session, 'subagent_message')).toEqual([expect.objectContaining({ childSessionId: 'call_ses_1_1', text: 'Listing done.' })]));
|
|
986
|
+
expect(helperEvents(session, 'subagent_tool_call')).toEqual([expect.objectContaining({ childSessionId: 'call_ses_1_1', name: 'Bash', input: { command: 'ls', description: 'List files' } })]);
|
|
987
|
+
expect(opened(session)).toHaveLength(0);
|
|
988
|
+
|
|
989
|
+
finish.open();
|
|
990
|
+
await vi.waitFor(() => expect(opened(session)).toHaveLength(1));
|
|
991
|
+
const turn = opened(session)[0];
|
|
992
|
+
expect(turn).toMatchObject({ origin: { kind: 'agent', name: 'inspect folder finished in the background', workId: 'call_ses_1_1' } });
|
|
993
|
+
await vi.waitFor(() => expect(turn && turnEnded(session, turn.turnId)).toBe(true));
|
|
994
|
+
expect(helperEvents(session, 'subagent_completed')).toEqual([expect.objectContaining({ childSessionId: 'call_ses_1_1', text: 'DONE-7' })]);
|
|
995
|
+
expect(session.getInfo().backgroundWork).toEqual([]);
|
|
996
|
+
const inIt = session.log.slice().filter((e) => turn && e.turnId === turn.turnId);
|
|
997
|
+
expect(said(inIt)).toEqual(['Background task finished: DONE-7']);
|
|
998
|
+
// OpenCode answered by itself: Moxxy sent it nothing for that turn.
|
|
999
|
+
expect(opencode.last.sent('session/prompt')).toHaveLength(1);
|
|
1000
|
+
expect(opencode.last.childEnds.get(childId())).toBe('completed');
|
|
1001
|
+
|
|
1002
|
+
expect(said(await collectTurn(session, '2+2?'))).toEqual(['4']);
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
it('reads OpenCode HTTP API with the password it was started with', async () => {
|
|
1006
|
+
const { opencode } = await leaveRunning();
|
|
1007
|
+
|
|
1008
|
+
expect(opencode.last.httpRequests).toContain('GET /event');
|
|
1009
|
+
expect(opencode.last.refusedHttp).toBe(0);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it('stops the subagent through OpenCode HTTP API, records it as stopped, and opens no turn for it', async () => {
|
|
1013
|
+
const { opencode, session, childId } = await leaveRunning();
|
|
1014
|
+
await vi.waitFor(() => expect(session.getInfo().backgroundWork).toHaveLength(1));
|
|
1015
|
+
|
|
1016
|
+
expect(await session.stopBackgroundWork('call_ses_1_1')).toBe(true);
|
|
1017
|
+
expect(await session.stopBackgroundWork('call_ses_1_9')).toBe(false);
|
|
1018
|
+
|
|
1019
|
+
expect(opencode.last.httpRequests).toContain(`POST /session/${childId()}/abort`);
|
|
1020
|
+
await vi.waitFor(() => expect(session.getInfo().backgroundWork).toEqual([]));
|
|
1021
|
+
expect(helperEvents(session, 'subagent_completed')).toEqual([expect.objectContaining({ childSessionId: 'call_ses_1_1', stopReason: 'stopped' })]);
|
|
1022
|
+
expect(opencode.last.childEnds.get(childId())).toBe('aborted');
|
|
1023
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1024
|
+
expect(opened(session)).toHaveLength(0);
|
|
1025
|
+
expect(session.log.ofType('user_prompt')).toHaveLength(1);
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it('records a subagent OpenCode cancelled with the conversation turn as stopped, not failed', async () => {
|
|
1029
|
+
const finish = gate();
|
|
1030
|
+
const { session } = connect(
|
|
1031
|
+
async (turn) => {
|
|
1032
|
+
await turn.background({ description: 'long job', prompt: 'Sleep.', work: async () => (await finish.opened, 'never') });
|
|
1033
|
+
turn.say('STARTED');
|
|
1034
|
+
await new Promise((resolve) => turn.signal.addEventListener('abort', resolve, { once: true }));
|
|
1035
|
+
},
|
|
1036
|
+
{ api: true },
|
|
1037
|
+
);
|
|
1038
|
+
session.setPermissionResolver(allowAll);
|
|
1039
|
+
const stop = new AbortController();
|
|
1040
|
+
const running = collectTurn(session, 'Start it, then wait', { signal: stop.signal });
|
|
1041
|
+
await vi.waitFor(() => expect(session.getInfo().backgroundWork).toHaveLength(1));
|
|
1042
|
+
|
|
1043
|
+
stop.abort();
|
|
1044
|
+
await running;
|
|
1045
|
+
|
|
1046
|
+
await vi.waitFor(() => expect(session.getInfo().backgroundWork).toEqual([]));
|
|
1047
|
+
expect(helperEvents(session, 'subagent_completed')).toEqual([expect.objectContaining({ stopReason: 'stopped' })]);
|
|
1048
|
+
expect(workOf(session, 'background_work').at(-1)).toMatchObject({ work: [] });
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
it("shows a subagent's steps as it works in the turn that waits for it", async () => {
|
|
1052
|
+
const { session } = connect(
|
|
1053
|
+
async (turn) => {
|
|
1054
|
+
await turn.subagent({
|
|
1055
|
+
description: 'read readme',
|
|
1056
|
+
prompt: 'Read the README.',
|
|
1057
|
+
work: async (child) => {
|
|
1058
|
+
child.tool('read', { filePath: '/work/app/README.md' }, '# app');
|
|
1059
|
+
return 'It is about the app.';
|
|
1060
|
+
},
|
|
1061
|
+
});
|
|
1062
|
+
turn.say('Done.');
|
|
1063
|
+
},
|
|
1064
|
+
{ api: true },
|
|
1065
|
+
);
|
|
1066
|
+
|
|
1067
|
+
const events = await collectTurn(session, 'Have a helper read the README');
|
|
1068
|
+
|
|
1069
|
+
const helper = events.flatMap((e) => (e.type === 'plugin_event' && e.subtype.startsWith('subagent_') ? [[e.subtype, e.payload]] : []));
|
|
1070
|
+
expect(helper).toEqual([
|
|
1071
|
+
['subagent_started', expect.objectContaining({ childSessionId: 'call_ses_1_1', label: 'read readme' })],
|
|
1072
|
+
['subagent_tool_call', expect.objectContaining({ childSessionId: 'call_ses_1_1', name: 'Read', input: { file_path: '/work/app/README.md' } })],
|
|
1073
|
+
['subagent_message', expect.objectContaining({ childSessionId: 'call_ses_1_1', text: 'It is about the app.' })],
|
|
1074
|
+
['subagent_completed', expect.objectContaining({ childSessionId: 'call_ses_1_1', text: 'It is about the app.' })],
|
|
1075
|
+
]);
|
|
1076
|
+
expect(session.getInfo().backgroundWork).toEqual([]);
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
it('reports no background work from an OpenCode that serves no HTTP API', async () => {
|
|
1080
|
+
const { session } = connect(async (turn) => {
|
|
1081
|
+
await turn.background({ description: 'inspect folder', prompt: 'List.', work: async () => 'DONE' });
|
|
1082
|
+
turn.say('STARTED');
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
await collectTurn(session, 'Start a helper in the background');
|
|
1086
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1087
|
+
|
|
1088
|
+
expect(session.getInfo().backgroundWork).toEqual([]);
|
|
1089
|
+
expect(opened(session)).toHaveLength(0);
|
|
1090
|
+
});
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
describe("OpenCode's account limit", () => {
|
|
1094
|
+
it('shows the limit as reached when OpenCode stops to wait for it, and ends the turn instead of letting it wait for hours', async () => {
|
|
1095
|
+
const { opencode, session } = connect(
|
|
1096
|
+
async (turn) => {
|
|
1097
|
+
if (turn.text === '2+2?') return turn.say('4');
|
|
1098
|
+
turn.server.emit('session.status', {
|
|
1099
|
+
sessionID: turn.session.id,
|
|
1100
|
+
status: {
|
|
1101
|
+
type: 'retry',
|
|
1102
|
+
attempt: 1,
|
|
1103
|
+
message: 'Free limit reached',
|
|
1104
|
+
next: Date.now() + 3_600_000,
|
|
1105
|
+
action: { reason: 'free_tier_limit', provider: 'opencode', title: 'Free limit reached', message: 'Subscribe to OpenCode Go.', label: 'subscribe' },
|
|
1106
|
+
},
|
|
1107
|
+
});
|
|
1108
|
+
await new Promise((resolve) => turn.signal.addEventListener('abort', resolve, { once: true }));
|
|
1109
|
+
},
|
|
1110
|
+
{ api: true },
|
|
1111
|
+
);
|
|
1112
|
+
|
|
1113
|
+
const events = await collectTurn(session, 'Hello');
|
|
1114
|
+
|
|
1115
|
+
expect(events).toContainEqual(expect.objectContaining({ type: 'plugin_event', subtype: 'account_limit', payload: expect.objectContaining({ limit: { state: 'reached' } }) }));
|
|
1116
|
+
expect(opencode.last.sent('session/cancel')).toEqual([{ sessionId: 'ses_1' }]);
|
|
1117
|
+
expect(events.filter((e) => e.type === 'error')).toEqual([]);
|
|
1118
|
+
expect(said(await collectTurn(session, '2+2?'))).toEqual(['4']);
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
it('leaves a retry for any other reason to OpenCode', async () => {
|
|
1122
|
+
const { opencode, session } = connect(
|
|
1123
|
+
async (turn) => {
|
|
1124
|
+
turn.server.emit('session.status', { sessionID: turn.session.id, status: { type: 'retry', attempt: 1, message: 'Provider is overloaded', next: Date.now() + 1000 } });
|
|
1125
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1126
|
+
turn.say('done');
|
|
1127
|
+
},
|
|
1128
|
+
{ api: true },
|
|
1129
|
+
);
|
|
1130
|
+
|
|
1131
|
+
const events = await collectTurn(session, 'Hello');
|
|
1132
|
+
|
|
1133
|
+
expect(events.filter((e) => e.type === 'plugin_event' && e.subtype === 'account_limit')).toEqual([]);
|
|
1134
|
+
expect(opencode.last.sent('session/cancel')).toEqual([]);
|
|
1135
|
+
expect(said(events)).toEqual(['done']);
|
|
1136
|
+
});
|
|
1137
|
+
});
|