@losclaws/cli 0.1.1 → 0.1.3

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.
@@ -0,0 +1,231 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ import { loadConfig } from '../src/config.js';
9
+ import {
10
+ buildTaskPrompt,
11
+ resolveAcpRuntime,
12
+ resolveServerAcpDescriptor,
13
+ runWorkshopAcpTask,
14
+ } from '../src/acp-runtime.js';
15
+
16
+ test('resolveServerAcpDescriptor reads the server-composed task.acp', () => {
17
+ const descriptor = resolveServerAcpDescriptor({
18
+ acp: { key: 'opencode', command: 'opencode', args: ['acp'] },
19
+ });
20
+ assert.deepEqual(descriptor, { key: 'opencode', command: 'opencode', args: ['acp'] });
21
+ });
22
+
23
+ test('resolveAcpRuntime builds a runtime from task.acp', () => {
24
+ const resolved = resolveAcpRuntime({
25
+ task: { acp: { key: 'opencode', command: 'opencode', args: ['acp'], model: 'gpt-5.4' } },
26
+ options: {},
27
+ });
28
+ assert.equal(resolved.name, 'opencode');
29
+ assert.equal(resolved.runtime.command, 'opencode');
30
+ assert.deepEqual(resolved.runtime.args, ['acp']);
31
+ assert.equal(resolved.runtime.model, 'gpt-5.4');
32
+ assert.equal(resolved.source, 'server');
33
+ });
34
+
35
+ test('resolveAcpRuntime throws when the server returns no coder runtime', () => {
36
+ assert.throws(() => resolveAcpRuntime({ task: {}, options: {} }), /no coder runtime/);
37
+ });
38
+
39
+ test('buildTaskPrompt includes artifact and skill disclosure', () => {
40
+ const result = buildTaskPrompt({
41
+ role: 'worker',
42
+ task: {
43
+ id: 'tsk_123',
44
+ title: 'Write docs',
45
+ description: 'Update the project guide.',
46
+ inputArtifacts: [{ id: 'art_1' }],
47
+ },
48
+ localState: {
49
+ rootPath: '/repo',
50
+ artifacts: [
51
+ { id: 'art_1', relativePath: 'artifacts/project/guide.md' },
52
+ ],
53
+ skills: [
54
+ { role: 'worker', path: 'skills/worker/docs-skill' },
55
+ ],
56
+ },
57
+ });
58
+
59
+ assert.match(result.prompt, /Task title: Write docs/);
60
+ assert.match(result.prompt, /artifacts\/project\/guide.md/);
61
+ assert.match(result.prompt, /skills\/worker\/docs-skill/);
62
+ });
63
+
64
+ test('runWorkshopAcpTask executes a real ACP prompt against a local runtime mapping', async () => {
65
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'losclaws-acp-run-'));
66
+ const metaRoot = path.join(tempRoot, '.losclaws');
67
+ await fs.mkdir(path.join(tempRoot, 'artifacts', 'project'), { recursive: true });
68
+ await fs.mkdir(path.join(tempRoot, 'skills', 'worker', 'docs-skill'), { recursive: true });
69
+ await fs.mkdir(metaRoot, { recursive: true });
70
+ await fs.writeFile(path.join(tempRoot, 'artifacts', 'project', 'guide.md'), 'hello', 'utf8');
71
+ await fs.writeFile(
72
+ path.join(metaRoot, 'state.json'),
73
+ `${JSON.stringify(
74
+ {
75
+ version: 1,
76
+ rootType: 'project',
77
+ rootPath: tempRoot,
78
+ role: 'worker',
79
+ workspace: { id: 'wrk_1', name: 'Workspace', slug: 'workspace', version: 1 },
80
+ project: { id: 'prj_1', name: 'Project', slug: 'project', version: 1 },
81
+ artifacts: [
82
+ {
83
+ id: 'art_1',
84
+ scope: 'project',
85
+ relativePath: 'artifacts/project/guide.md',
86
+ contentKind: 'text',
87
+ mimeType: 'text/plain',
88
+ lastSyncedHash: 'hash',
89
+ remoteRevisionNo: 1,
90
+ remoteVersion: 1,
91
+ },
92
+ ],
93
+ skills: [
94
+ {
95
+ name: 'docs-skill',
96
+ role: 'worker',
97
+ path: 'skills/worker/docs-skill',
98
+ source: 'bundled',
99
+ revision: 'bundled',
100
+ },
101
+ ],
102
+ },
103
+ null,
104
+ 2,
105
+ )}\n`,
106
+ 'utf8',
107
+ );
108
+
109
+ const configPath = path.join(tempRoot, 'config.json');
110
+ const state = await loadConfig(configPath);
111
+ const fixturePath = fileURLToPath(new URL('../testdata/mock-acp-agent.js', import.meta.url));
112
+
113
+ const worklogs = [];
114
+ const requestWorkshop = async ({ method, path: requestPath, body }) => {
115
+ if (requestPath === '/api/v1/tasks/tsk_123' && (!method || method === 'GET')) {
116
+ return {
117
+ task: {
118
+ id: 'tsk_123',
119
+ title: 'Write docs',
120
+ description: 'Update the project guide.',
121
+ assigneeRole: 'worker',
122
+ version: 4,
123
+ acp: {
124
+ key: 'mock-agent',
125
+ command: process.execPath,
126
+ args: [fixturePath],
127
+ authMethodId: 'local-auth',
128
+ },
129
+ inputArtifacts: [{ id: 'art_1' }],
130
+ },
131
+ projectId: 'prj_1',
132
+ };
133
+ }
134
+ if (requestPath === '/api/v1/tasks/tsk_123/worklogs' && method === 'POST') {
135
+ worklogs.push(body);
136
+ return { id: `wl_${worklogs.length}`, version: 4 };
137
+ }
138
+ throw new Error(`Unexpected request: ${method} ${requestPath}`);
139
+ };
140
+
141
+ const result = await runWorkshopAcpTask({
142
+ requestWorkshop,
143
+ state,
144
+ options: {
145
+ id: 'tsk_123',
146
+ root: tempRoot,
147
+ approvalPolicy: 'auto-allow',
148
+ closeSession: true,
149
+ },
150
+ });
151
+
152
+ assert.equal(result.agent.name, 'mock-agent');
153
+ assert.equal(result.acp.stopReason, 'end_turn');
154
+ assert.match(result.acp.sessionId, /^sess_/);
155
+ const logText = await fs.readFile(result.logPath, 'utf8');
156
+ assert.match(logText, /session_update/);
157
+ assert.ok(worklogs.length >= 1, 'expected at least one worklog to be recorded');
158
+ assert.ok(worklogs.every((entry) => typeof entry.updateType === 'string'));
159
+ });
160
+
161
+ test('runWorkshopAcpTask routes a remote permission request through feedback and back', async () => {
162
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'losclaws-acp-remote-'));
163
+ const metaRoot = path.join(tempRoot, '.losclaws');
164
+ await fs.mkdir(metaRoot, { recursive: true });
165
+ await fs.writeFile(
166
+ path.join(metaRoot, 'state.json'),
167
+ `${JSON.stringify({ version: 1, rootType: 'project', rootPath: tempRoot, role: 'worker', artifacts: [], skills: [] }, null, 2)}\n`,
168
+ 'utf8',
169
+ );
170
+
171
+ const configPath = path.join(tempRoot, 'config.json');
172
+ const state = await loadConfig(configPath);
173
+ const fixturePath = fileURLToPath(new URL('../testdata/mock-acp-agent.js', import.meta.url));
174
+
175
+ let feedbackRequested = null;
176
+ let feedbackResolved = false;
177
+ const requestWorkshop = async ({ method, path: requestPath, body }) => {
178
+ if (requestPath === '/api/v1/tasks/tsk_remote' && (!method || method === 'GET')) {
179
+ // Once a feedback request exists, report it as submitted so the poll resolves.
180
+ const feedbackSession = feedbackRequested
181
+ ? {
182
+ id: 'fb_1',
183
+ status: feedbackResolved ? 'submitted' : 'open',
184
+ kind: 'single_select',
185
+ version: 1,
186
+ entries: feedbackResolved ? [{ id: 'e1', response: { optionId: 'allow' }, body: 'Allow' }] : [],
187
+ }
188
+ : null;
189
+ if (feedbackRequested) {
190
+ feedbackResolved = true;
191
+ }
192
+ return {
193
+ task: {
194
+ id: 'tsk_remote',
195
+ title: 'Remote task',
196
+ version: 7,
197
+ acp: { key: 'mock-agent', command: process.execPath, args: [fixturePath], authMethodId: 'local-auth' },
198
+ },
199
+ feedbackSession,
200
+ };
201
+ }
202
+ if (requestPath === '/api/v1/tasks/tsk_remote/feedback-requests' && method === 'POST') {
203
+ feedbackRequested = body;
204
+ return { version: 8 };
205
+ }
206
+ if (requestPath === '/api/v1/tasks/tsk_remote/worklogs' && method === 'POST') {
207
+ return { id: 'wl', version: 8 };
208
+ }
209
+ throw new Error(`Unexpected request: ${method} ${requestPath}`);
210
+ };
211
+
212
+ const result = await runWorkshopAcpTask({
213
+ requestWorkshop,
214
+ state,
215
+ options: {
216
+ id: 'tsk_remote',
217
+ root: tempRoot,
218
+ approvalPolicy: 'remote',
219
+ feedbackTimeout: 20000,
220
+ closeSession: true,
221
+ // The mock agent issues a permission request when this env var is set on
222
+ // the spawned coder; --env is merged into the coder's process environment.
223
+ env: { MOCK_ACP_REQUEST_PERMISSION: '1' },
224
+ },
225
+ });
226
+
227
+ assert.equal(result.acp.stopReason, 'end_turn');
228
+ assert.ok(feedbackRequested, 'expected a feedback request to be posted');
229
+ assert.equal(feedbackRequested.kind, 'single_select');
230
+ assert.ok(Array.isArray(feedbackRequested.options) && feedbackRequested.options.length === 2);
231
+ });
@@ -6,7 +6,12 @@ import path from 'node:path';
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
8
 
9
- import { clearedAuthProfile, configForDisplay, resolveRuntime } from '../src/config.js';
9
+ import {
10
+ clearedAuthProfile,
11
+ configForDisplay,
12
+ loadConfig,
13
+ resolveRuntime,
14
+ } from '../src/config.js';
10
15
 
11
16
  test('resolveRuntime includes clawworkshop base URL from profile', () => {
12
17
  const runtime = resolveRuntime(
@@ -26,3 +26,29 @@ error: ignored
26
26
 
27
27
  assert.deepEqual(models, ['claude-sonnet-4.6', 'gpt-5.4']);
28
28
  });
29
+
30
+ test('parseModelListOutput extracts slash-delimited OpenCode model names', () => {
31
+ const models = parseModelListOutput(`
32
+ opencode/big-pickle
33
+ opencode/deepseek-v4-flash-free
34
+ opencode-go/qwen3.7-plus
35
+ `);
36
+
37
+ assert.deepEqual(models, [
38
+ 'opencode-go/qwen3.7-plus',
39
+ 'opencode/big-pickle',
40
+ 'opencode/deepseek-v4-flash-free',
41
+ ]);
42
+ });
43
+
44
+ test('parseModelListOutput ignores shell prompts around OpenCode model output', () => {
45
+ const models = parseModelListOutput(`
46
+ openvscode-server@remoteide:~/workspace/supremelosclaws/losclaws-cli$ opencode models
47
+ Usage: opencode models
48
+ opencode/mimo-v2.5-free
49
+ opencode-go/minimax-m3
50
+ error: ignored
51
+ `);
52
+
53
+ assert.deepEqual(models, ['opencode-go/minimax-m3', 'opencode/mimo-v2.5-free']);
54
+ });
@@ -0,0 +1,83 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { createHash } from 'node:crypto';
7
+
8
+ import { buildArtifactIndex, summarizeIndex } from '../src/workshop-local.js';
9
+
10
+ test('summarizeIndex returns status counters', () => {
11
+ const summary = summarizeIndex({
12
+ entries: [
13
+ { status: 'clean' },
14
+ { status: 'clean' },
15
+ { status: 'modified' },
16
+ { status: 'missing' },
17
+ { status: 'untracked' },
18
+ ],
19
+ });
20
+
21
+ assert.deepEqual(summary, {
22
+ clean: 2,
23
+ modified: 1,
24
+ missing: 1,
25
+ untracked: 1,
26
+ total: 5,
27
+ });
28
+ });
29
+
30
+ test('buildArtifactIndex detects modified/missing/untracked artifacts', async () => {
31
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'losclaws-workshop-local-test-'));
32
+ const artifactsDir = path.join(tempRoot, 'artifacts', 'project');
33
+ await fs.mkdir(artifactsDir, { recursive: true });
34
+
35
+ const cleanPath = path.join(artifactsDir, 'clean.txt');
36
+ const modifiedPath = path.join(artifactsDir, 'modified.txt');
37
+ const untrackedPath = path.join(artifactsDir, 'untracked.txt');
38
+ await fs.writeFile(cleanPath, 'clean', 'utf8');
39
+ await fs.writeFile(modifiedPath, 'new-value', 'utf8');
40
+ await fs.writeFile(untrackedPath, 'untracked', 'utf8');
41
+
42
+ const state = {
43
+ rootPath: tempRoot,
44
+ artifacts: [
45
+ {
46
+ id: 'art_clean',
47
+ relativePath: path.join('artifacts', 'project', 'clean.txt'),
48
+ lastSyncedHash: hashText('clean'),
49
+ remoteRevisionNo: 1,
50
+ remoteVersion: 10,
51
+ contentKind: 'text',
52
+ },
53
+ {
54
+ id: 'art_modified',
55
+ relativePath: path.join('artifacts', 'project', 'modified.txt'),
56
+ lastSyncedHash: hashText('old-value'),
57
+ remoteRevisionNo: 2,
58
+ remoteVersion: 20,
59
+ contentKind: 'text',
60
+ },
61
+ {
62
+ id: 'art_missing',
63
+ relativePath: path.join('artifacts', 'project', 'missing.txt'),
64
+ lastSyncedHash: hashText('missing'),
65
+ remoteRevisionNo: 3,
66
+ remoteVersion: 30,
67
+ contentKind: 'text',
68
+ },
69
+ ],
70
+ };
71
+
72
+ const index = await buildArtifactIndex(state);
73
+ const byPath = new Map(index.entries.map((entry) => [entry.relativePath.replaceAll('\\', '/'), entry]));
74
+
75
+ assert.equal(byPath.get('artifacts/project/clean.txt').status, 'clean');
76
+ assert.equal(byPath.get('artifacts/project/modified.txt').status, 'modified');
77
+ assert.equal(byPath.get('artifacts/project/missing.txt').status, 'missing');
78
+ assert.equal(byPath.get('artifacts/project/untracked.txt').status, 'untracked');
79
+ });
80
+
81
+ function hashText(value) {
82
+ return createHash('sha256').update(Buffer.from(value, 'utf8')).digest('hex');
83
+ }
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Readable, Writable } from 'node:stream';
4
+ import * as acp from '@agentclientprotocol/sdk';
5
+
6
+ class MockAgent {
7
+ constructor(connection) {
8
+ this.connection = connection;
9
+ this.sessions = new Map();
10
+ }
11
+
12
+ async initialize() {
13
+ return {
14
+ protocolVersion: acp.PROTOCOL_VERSION,
15
+ agentCapabilities: {
16
+ loadSession: true,
17
+ sessionCapabilities: {
18
+ close: {},
19
+ resume: {},
20
+ },
21
+ },
22
+ authMethods: [
23
+ {
24
+ id: 'local-auth',
25
+ name: 'Local auth',
26
+ description: 'Mock authentication method',
27
+ },
28
+ ],
29
+ agentInfo: {
30
+ name: 'mock-acp-agent',
31
+ title: 'Mock ACP Agent',
32
+ version: '1.0.0',
33
+ },
34
+ };
35
+ }
36
+
37
+ async authenticate() {
38
+ return {};
39
+ }
40
+
41
+ async newSession() {
42
+ const sessionId = `sess_${Math.random().toString(16).slice(2)}`;
43
+ this.sessions.set(sessionId, true);
44
+ return { sessionId };
45
+ }
46
+
47
+ async loadSession(params) {
48
+ this.sessions.set(params.sessionId, true);
49
+ return {};
50
+ }
51
+
52
+ async resumeSession(params) {
53
+ this.sessions.set(params.sessionId, true);
54
+ return {};
55
+ }
56
+
57
+ async closeSession() {
58
+ return {};
59
+ }
60
+
61
+ async prompt(params) {
62
+ const sessionId = params.sessionId;
63
+
64
+ // Reasoning / plan / tool-call surface so the bridge maps several update types.
65
+ await this.connection.sessionUpdate({
66
+ sessionId,
67
+ update: {
68
+ sessionUpdate: 'agent_thought_chunk',
69
+ content: { type: 'text', text: 'Planning the work.' },
70
+ },
71
+ });
72
+ await this.connection.sessionUpdate({
73
+ sessionId,
74
+ update: {
75
+ sessionUpdate: 'plan',
76
+ entries: [
77
+ { content: 'Read the task', status: 'completed', priority: 'high' },
78
+ { content: 'Make the change', status: 'in_progress', priority: 'high' },
79
+ ],
80
+ },
81
+ });
82
+ await this.connection.sessionUpdate({
83
+ sessionId,
84
+ update: {
85
+ sessionUpdate: 'tool_call',
86
+ toolCallId: 'call_1',
87
+ title: 'Edit guide.md',
88
+ kind: 'edit',
89
+ status: 'in_progress',
90
+ },
91
+ });
92
+
93
+ // Optionally exercise the human-in-the-loop permission round-trip.
94
+ if (process.env.MOCK_ACP_REQUEST_PERMISSION === '1') {
95
+ const decision = await this.connection.requestPermission({
96
+ sessionId,
97
+ toolCall: { toolCallId: 'call_1', title: 'Apply the edit to guide.md', kind: 'edit' },
98
+ options: [
99
+ { optionId: 'allow', name: 'Allow', kind: 'allow_once' },
100
+ { optionId: 'deny', name: 'Deny', kind: 'reject_once' },
101
+ ],
102
+ });
103
+ await this.connection.sessionUpdate({
104
+ sessionId,
105
+ update: {
106
+ sessionUpdate: 'agent_message_chunk',
107
+ content: { type: 'text', text: `permission outcome: ${JSON.stringify(decision.outcome)}` },
108
+ },
109
+ });
110
+ }
111
+
112
+ await this.connection.sessionUpdate({
113
+ sessionId,
114
+ update: {
115
+ sessionUpdate: 'agent_message_chunk',
116
+ content: {
117
+ type: 'text',
118
+ text: `mock-agent received: ${params.prompt[0].text.slice(0, 40)}`,
119
+ },
120
+ },
121
+ });
122
+
123
+ return {
124
+ stopReason: 'end_turn',
125
+ };
126
+ }
127
+
128
+ async cancel() {}
129
+ }
130
+
131
+ const stream = acp.ndJsonStream(
132
+ Writable.toWeb(process.stdout),
133
+ Readable.toWeb(process.stdin),
134
+ );
135
+ new acp.AgentSideConnection((connection) => new MockAgent(connection), stream);