@dreki-gg/pi-subagent 0.9.1 → 0.10.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/CHANGELOG.md +12 -0
- package/README.md +30 -0
- package/extensions/subagent/agent-runner.ts +3 -2
- package/extensions/subagent/create-agent.ts +6 -11
- package/extensions/subagent/cursor/acp-runner.ts +143 -0
- package/extensions/subagent/cursor/dispatch.ts +18 -0
- package/extensions/subagent/cursor/model.ts +34 -0
- package/extensions/subagent/cursor/permissions.ts +26 -0
- package/extensions/subagent/cursor/update-mapping.ts +141 -0
- package/extensions/subagent/handoffs.ts +17 -4
- package/extensions/subagent/index.ts +9 -1
- package/extensions/subagent/list-agents.ts +7 -9
- package/extensions/subagent/run-agent-args.ts +6 -4
- package/extensions/subagent/spawn-utils.ts +13 -6
- package/package.json +4 -1
- package/prompts/planner.md +4 -8
- package/prompts/reviewer.md +4 -19
- package/prompts/worker.md +5 -10
- package/skills/write-an-agent/SKILL.md +24 -0
- package/test/cursor-acp-runner.e2e.test.ts +42 -0
- package/test/cursor-model.test.ts +36 -0
- package/test/cursor-permissions.test.ts +48 -0
- package/test/cursor-update-mapping.test.ts +58 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @dreki-gg/pi-subagent
|
|
2
2
|
|
|
3
|
+
## 0.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- db4b119: Add a Cursor ACP backend: set a subagent's model to `cursor:<model>` (e.g. `cursor:composer-2.5`) to run the task on Cursor's agent via the Agent Client Protocol instead of spawning a `pi` process. Routing happens in one shared dispatcher, so it works across single / parallel / chain modes and the `/run-agent` command with an unchanged result shape. Permission requests are auto-approved; the `tools` allowlist and `thinking` level do not apply to `cursor:` models. Requires `cursor-agent` installed and authenticated (`agent login`).
|
|
8
|
+
|
|
9
|
+
## 0.9.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Tighten subagent prompts and the write-an-agent skill. The skill gains a "Prompt hygiene (anti-rot)" section (invariants over incidents, one owner per fact, every line must change behavior) plus matching review-checklist items, and the reviewer/worker/planner prompts state the consult handoff shape once instead of in three duplicated blocks. No behavior change.
|
|
14
|
+
|
|
3
15
|
## 0.9.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -26,6 +26,36 @@ Notes:
|
|
|
26
26
|
- `/run-agent` provides autocomplete for `--model` and `--thinking`
|
|
27
27
|
- the `subagent` tool supports the same fields in its schema, but this package does not currently add custom interactive autocomplete for tool-call JSON parameters
|
|
28
28
|
|
|
29
|
+
## Cursor Composer (ACP backend)
|
|
30
|
+
|
|
31
|
+
Set a subagent's model to `cursor:<model>` to run the task on Cursor's agent via the
|
|
32
|
+
[Agent Client Protocol](https://agentclientprotocol.com/) instead of spawning a `pi`
|
|
33
|
+
process. A bare `cursor:` (or `cursor`) defaults to `composer-2.5`.
|
|
34
|
+
|
|
35
|
+
```jsonc
|
|
36
|
+
// single run on Composer 2.5
|
|
37
|
+
{ "agent": "worker", "task": "…", "model": "cursor:composer-2.5" }
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Works across every mode (single / parallel / chain) and the `/run-agent` command —
|
|
41
|
+
routing happens in one shared dispatcher, so the result shape and rendering are
|
|
42
|
+
identical to pi-backed subagents.
|
|
43
|
+
|
|
44
|
+
**Prerequisites**
|
|
45
|
+
|
|
46
|
+
- `cursor-agent` installed (default `~/.local/bin/cursor-agent`). Override the binary
|
|
47
|
+
with the `CURSOR_AGENT_BIN` env var.
|
|
48
|
+
- Authenticated once: `agent login` (or set `CURSOR_API_KEY`).
|
|
49
|
+
|
|
50
|
+
**Behavior notes**
|
|
51
|
+
|
|
52
|
+
- Cursor runs **its own tools** in ACP headless mode and auto-executes them; the
|
|
53
|
+
subagent `tools` allowlist and `thinking` level do **not** apply to `cursor:` models.
|
|
54
|
+
- Permission prompts (`session/request_permission`) are auto-approved (most permissive
|
|
55
|
+
allow option) so runs never block. Cursor typically does not prompt for normal ops.
|
|
56
|
+
- The model is passed as `cursor-agent --model <model> acp`; see `cursor-agent --list-models`
|
|
57
|
+
for available ids (e.g. `cursor:gpt-5.2`).
|
|
58
|
+
|
|
29
59
|
## Opinionated Defaults
|
|
30
60
|
|
|
31
61
|
This package is intentionally opinionated about orchestration:
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ResolvedPaths } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import { discoverAgents, type AgentScope } from './agents.js';
|
|
3
3
|
import type { AgentResult } from './agent-runner-types.js';
|
|
4
|
-
import { emptyUsage,
|
|
4
|
+
import { emptyUsage, type ToolExecutionStartEvent } from './spawn-utils.js';
|
|
5
|
+
import { dispatchSubagent } from './cursor/dispatch.js';
|
|
5
6
|
|
|
6
7
|
export type OnPhaseUpdate = (phaseName: string, agentName: string, result: AgentResult) => void;
|
|
7
8
|
|
|
@@ -51,7 +52,7 @@ export async function runAgent(
|
|
|
51
52
|
model: selectedModel,
|
|
52
53
|
};
|
|
53
54
|
|
|
54
|
-
const spawnResult = await
|
|
55
|
+
const spawnResult = await dispatchSubagent({
|
|
55
56
|
cwd: options.cwd ?? cwd,
|
|
56
57
|
agentName: agent.name,
|
|
57
58
|
task,
|
|
@@ -58,10 +58,7 @@ export function registerCreateAgentCommand(pi: ExtensionAPI) {
|
|
|
58
58
|
const filePath = path.join(promptsDir, `${name}.md`);
|
|
59
59
|
|
|
60
60
|
if (fs.existsSync(filePath)) {
|
|
61
|
-
ctx.ui.notify(
|
|
62
|
-
`Agent "${name}" already exists at ${filePath}`,
|
|
63
|
-
'warning',
|
|
64
|
-
);
|
|
61
|
+
ctx.ui.notify(`Agent "${name}" already exists at ${filePath}`, 'warning');
|
|
65
62
|
return;
|
|
66
63
|
}
|
|
67
64
|
|
|
@@ -69,16 +66,14 @@ export function registerCreateAgentCommand(pi: ExtensionAPI) {
|
|
|
69
66
|
fs.mkdirSync(promptsDir, { recursive: true });
|
|
70
67
|
|
|
71
68
|
// Write the template
|
|
72
|
-
const content = TEMPLATE
|
|
73
|
-
|
|
74
|
-
|
|
69
|
+
const content = TEMPLATE.replace(/\{\{name\}\}/g, name).replace(
|
|
70
|
+
/\{\{description\}\}/g,
|
|
71
|
+
description,
|
|
72
|
+
);
|
|
75
73
|
|
|
76
74
|
fs.writeFileSync(filePath, content, 'utf-8');
|
|
77
75
|
|
|
78
|
-
ctx.ui.notify(
|
|
79
|
-
`Created agent "${name}" at ${path.relative(ctx.cwd, filePath)}`,
|
|
80
|
-
'info',
|
|
81
|
-
);
|
|
76
|
+
ctx.ui.notify(`Created agent "${name}" at ${path.relative(ctx.cwd, filePath)}`, 'info');
|
|
82
77
|
|
|
83
78
|
// Send a follow-up so the LLM can help refine the prompt
|
|
84
79
|
pi.sendUserMessage(
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a subagent task on Cursor's Composer family via `cursor-agent acp`,
|
|
3
|
+
* driven by the official @agentclientprotocol/sdk library (the current home of
|
|
4
|
+
* the Agent Client Protocol TypeScript SDK, formerly @zed-industries/agent-client-protocol).
|
|
5
|
+
*
|
|
6
|
+
* Returns the same SpawnPiAgentResult shape as spawnPiAgent so it is a drop-in
|
|
7
|
+
* backend behind dispatchSubagent — rendering, synthesis, and handoffs are
|
|
8
|
+
* unchanged.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { Readable, Writable } from 'node:stream';
|
|
13
|
+
import {
|
|
14
|
+
ClientSideConnection,
|
|
15
|
+
ndJsonStream,
|
|
16
|
+
type Client,
|
|
17
|
+
type RequestPermissionRequest,
|
|
18
|
+
type SessionNotification,
|
|
19
|
+
} from '@agentclientprotocol/sdk';
|
|
20
|
+
import type { SpawnPiAgentOptions, SpawnPiAgentResult } from '../spawn-utils.js';
|
|
21
|
+
import { emptyUsage } from '../spawn-utils.js';
|
|
22
|
+
import { parseCursorModel } from './model.js';
|
|
23
|
+
import {
|
|
24
|
+
buildCancelledResponse,
|
|
25
|
+
buildPermissionResponse,
|
|
26
|
+
selectPermissionOption,
|
|
27
|
+
} from './permissions.js';
|
|
28
|
+
import { CursorUpdateReducer } from './update-mapping.js';
|
|
29
|
+
|
|
30
|
+
const CURSOR_BIN = process.env.CURSOR_AGENT_BIN || 'cursor-agent';
|
|
31
|
+
|
|
32
|
+
export async function runCursorAcpAgent(options: SpawnPiAgentOptions): Promise<SpawnPiAgentResult> {
|
|
33
|
+
const { model } = parseCursorModel(options.model);
|
|
34
|
+
const displayModel = `cursor:${model}`;
|
|
35
|
+
|
|
36
|
+
const result: SpawnPiAgentResult = {
|
|
37
|
+
exitCode: 0,
|
|
38
|
+
messages: [],
|
|
39
|
+
stderr: '',
|
|
40
|
+
wasAborted: false,
|
|
41
|
+
usage: emptyUsage(),
|
|
42
|
+
model: displayModel,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const reducer = new CursorUpdateReducer(displayModel);
|
|
46
|
+
const seenToolCalls = new Set<string>();
|
|
47
|
+
|
|
48
|
+
const proc = spawn(CURSOR_BIN, ['--model', model, 'acp'], {
|
|
49
|
+
cwd: options.cwd,
|
|
50
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
proc.stderr.on('data', (d: Buffer) => {
|
|
54
|
+
result.stderr += d.toString();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Bridge Node child stdio to Web streams for ndJsonStream.
|
|
58
|
+
const stream = ndJsonStream(
|
|
59
|
+
Writable.toWeb(proc.stdin) as WritableStream<Uint8Array>,
|
|
60
|
+
Readable.toWeb(proc.stdout) as unknown as ReadableStream<Uint8Array>,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const client: Client = {
|
|
64
|
+
async requestPermission(params: RequestPermissionRequest) {
|
|
65
|
+
const optionId = selectPermissionOption(params.options);
|
|
66
|
+
return optionId ? buildPermissionResponse(optionId) : buildCancelledResponse();
|
|
67
|
+
},
|
|
68
|
+
async sessionUpdate(note: SessionNotification) {
|
|
69
|
+
const update = note.update;
|
|
70
|
+
reducer.apply(update);
|
|
71
|
+
|
|
72
|
+
if (update.sessionUpdate === 'tool_call' && !seenToolCalls.has(update.toolCallId)) {
|
|
73
|
+
seenToolCalls.add(update.toolCallId);
|
|
74
|
+
options.onToolExecutionStart?.({
|
|
75
|
+
toolCallId: update.toolCallId,
|
|
76
|
+
toolName: update.title || update.kind || 'tool',
|
|
77
|
+
args: (update.rawInput as Record<string, unknown>) ?? {},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Live snapshot for streaming TUI updates.
|
|
82
|
+
result.messages = [reducer.current()];
|
|
83
|
+
options.onMessage?.(reducer.current());
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const conn = new ClientSideConnection(() => client, stream);
|
|
88
|
+
|
|
89
|
+
let sessionId: string | undefined;
|
|
90
|
+
const onAbort = () => {
|
|
91
|
+
result.wasAborted = true;
|
|
92
|
+
if (sessionId) conn.cancel({ sessionId }).catch(() => {});
|
|
93
|
+
proc.kill('SIGTERM');
|
|
94
|
+
setTimeout(() => {
|
|
95
|
+
if (!proc.killed) proc.kill('SIGKILL');
|
|
96
|
+
}, 5000);
|
|
97
|
+
};
|
|
98
|
+
if (options.signal) {
|
|
99
|
+
if (options.signal.aborted) onAbort();
|
|
100
|
+
else options.signal.addEventListener('abort', onAbort, { once: true });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await conn.initialize({
|
|
105
|
+
protocolVersion: 1,
|
|
106
|
+
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const session = await conn.newSession({ cwd: options.cwd, mcpServers: [] });
|
|
110
|
+
sessionId = session.sessionId;
|
|
111
|
+
|
|
112
|
+
const promptText = options.systemPrompt?.trim()
|
|
113
|
+
? `${options.systemPrompt}\n\nTask: ${options.task}`
|
|
114
|
+
: `Task: ${options.task}`;
|
|
115
|
+
|
|
116
|
+
const promptResult = await conn.prompt({
|
|
117
|
+
sessionId,
|
|
118
|
+
prompt: [{ type: 'text', text: promptText }],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const finalized = reducer.finalize(promptResult.stopReason);
|
|
122
|
+
result.messages = finalized.messages;
|
|
123
|
+
result.usage = finalized.usage;
|
|
124
|
+
result.stopReason = finalized.stopReason;
|
|
125
|
+
result.exitCode = result.wasAborted ? 1 : 0;
|
|
126
|
+
} catch (err) {
|
|
127
|
+
result.exitCode = 1;
|
|
128
|
+
result.errorMessage = err instanceof Error ? err.message : String(err);
|
|
129
|
+
const finalized = reducer.finalize(result.wasAborted ? 'cancelled' : 'error');
|
|
130
|
+
result.messages = finalized.messages;
|
|
131
|
+
result.usage = finalized.usage;
|
|
132
|
+
result.stopReason = finalized.stopReason;
|
|
133
|
+
} finally {
|
|
134
|
+
try {
|
|
135
|
+
proc.stdin.end();
|
|
136
|
+
} catch {
|
|
137
|
+
/* ignore */
|
|
138
|
+
}
|
|
139
|
+
if (!proc.killed) proc.kill();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single routing seam for subagent backends.
|
|
3
|
+
*
|
|
4
|
+
* Models prefixed with `cursor:` run through Cursor's ACP server (Composer
|
|
5
|
+
* family); everything else spawns a pi process as before. Both backends return
|
|
6
|
+
* the identical SpawnPiAgentResult shape, so callers are backend-agnostic.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawnPiAgent, type SpawnPiAgentOptions, type SpawnPiAgentResult } from '../spawn-utils.js';
|
|
10
|
+
import { parseCursorModel } from './model.js';
|
|
11
|
+
import { runCursorAcpAgent } from './acp-runner.js';
|
|
12
|
+
|
|
13
|
+
export function dispatchSubagent(options: SpawnPiAgentOptions): Promise<SpawnPiAgentResult> {
|
|
14
|
+
if (parseCursorModel(options.model).isCursor) {
|
|
15
|
+
return runCursorAcpAgent(options);
|
|
16
|
+
}
|
|
17
|
+
return spawnPiAgent(options);
|
|
18
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor model-prefix parsing.
|
|
3
|
+
*
|
|
4
|
+
* A subagent model of the form `cursor:<model>` routes the run through
|
|
5
|
+
* `cursor-agent acp` (Cursor's Composer family) instead of spawning a pi
|
|
6
|
+
* process. A bare `cursor:` or `cursor` defaults to `composer-2.5`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const CURSOR_PREFIX = 'cursor:';
|
|
10
|
+
const DEFAULT_CURSOR_MODEL = 'composer-2.5';
|
|
11
|
+
|
|
12
|
+
export interface ParsedCursorModel {
|
|
13
|
+
/** True when the model selects the Cursor ACP backend. */
|
|
14
|
+
isCursor: boolean;
|
|
15
|
+
/** The resolved model id: bare Cursor model name when isCursor, else passthrough. */
|
|
16
|
+
model: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseCursorModel(model?: string): ParsedCursorModel {
|
|
20
|
+
if (!model) return { isCursor: false, model: '' };
|
|
21
|
+
|
|
22
|
+
const trimmed = model.trim();
|
|
23
|
+
|
|
24
|
+
if (trimmed === 'cursor') {
|
|
25
|
+
return { isCursor: true, model: DEFAULT_CURSOR_MODEL };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (trimmed.toLowerCase().startsWith(CURSOR_PREFIX)) {
|
|
29
|
+
const rest = trimmed.slice(CURSOR_PREFIX.length).trim();
|
|
30
|
+
return { isCursor: true, model: rest || DEFAULT_CURSOR_MODEL };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { isCursor: false, model: trimmed };
|
|
34
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-approval policy for Cursor ACP permission requests.
|
|
3
|
+
*
|
|
4
|
+
* In ACP headless mode Cursor usually executes tool calls without prompting,
|
|
5
|
+
* but when it does send `session/request_permission` the client must answer or
|
|
6
|
+
* the turn blocks. We auto-select the most permissive "allow" option.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PermissionOption, RequestPermissionResponse } from '@agentclientprotocol/sdk';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Choose which option to grant. Prefers `allow_always`, then `allow_once`,
|
|
13
|
+
* then the first option. Returns undefined when no options are offered.
|
|
14
|
+
*/
|
|
15
|
+
export function selectPermissionOption(options: PermissionOption[]): string | undefined {
|
|
16
|
+
const byKind = (kind: PermissionOption['kind']) => options.find((o) => o.kind === kind)?.optionId;
|
|
17
|
+
return byKind('allow_always') ?? byKind('allow_once') ?? options[0]?.optionId;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildPermissionResponse(optionId: string): RequestPermissionResponse {
|
|
21
|
+
return { outcome: { outcome: 'selected', optionId } };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildCancelledResponse(): RequestPermissionResponse {
|
|
25
|
+
return { outcome: { outcome: 'cancelled' } };
|
|
26
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map Cursor ACP `session/update` notifications into the pi Message[] +
|
|
3
|
+
* UsageStats shape the rest of the subagent code consumes.
|
|
4
|
+
*
|
|
5
|
+
* A single ACP prompt turn is accumulated into one pi AssistantMessage whose
|
|
6
|
+
* content interleaves text parts and toolCall parts in arrival order, so the
|
|
7
|
+
* existing renderers (getFinalOutput / getDisplayItems) work unchanged.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
AssistantMessage,
|
|
12
|
+
Message,
|
|
13
|
+
StopReason,
|
|
14
|
+
TextContent,
|
|
15
|
+
ThinkingContent,
|
|
16
|
+
ToolCall,
|
|
17
|
+
} from '@earendil-works/pi-ai';
|
|
18
|
+
import type { SessionNotification } from '@agentclientprotocol/sdk';
|
|
19
|
+
import type { UsageStats } from '../agent-runner-types.js';
|
|
20
|
+
import { emptyUsage } from '../spawn-utils.js';
|
|
21
|
+
|
|
22
|
+
type SessionUpdate = SessionNotification['update'];
|
|
23
|
+
|
|
24
|
+
function blockText(content: { type: string; text?: string }): string {
|
|
25
|
+
return content?.type === 'text' ? (content.text ?? '') : '';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function mapStopReason(raw?: string): StopReason {
|
|
29
|
+
switch (raw) {
|
|
30
|
+
case 'end_turn':
|
|
31
|
+
case 'completed':
|
|
32
|
+
return 'stop';
|
|
33
|
+
case 'max_tokens':
|
|
34
|
+
case 'max_turn_requests':
|
|
35
|
+
return 'length';
|
|
36
|
+
case 'cancelled':
|
|
37
|
+
return 'aborted';
|
|
38
|
+
case 'refusal':
|
|
39
|
+
case 'error':
|
|
40
|
+
return 'error';
|
|
41
|
+
default:
|
|
42
|
+
return 'stop';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Accumulates ACP session updates into a single pi assistant message.
|
|
48
|
+
* Stateful but free of I/O — unit-testable by feeding `apply()` a sequence.
|
|
49
|
+
*/
|
|
50
|
+
export class CursorUpdateReducer {
|
|
51
|
+
private readonly assistant: AssistantMessage;
|
|
52
|
+
private hasContent = false;
|
|
53
|
+
readonly usage: UsageStats = emptyUsage();
|
|
54
|
+
|
|
55
|
+
constructor(model: string) {
|
|
56
|
+
this.assistant = {
|
|
57
|
+
role: 'assistant',
|
|
58
|
+
content: [],
|
|
59
|
+
api: 'cursor-acp',
|
|
60
|
+
provider: 'cursor',
|
|
61
|
+
model,
|
|
62
|
+
usage: {
|
|
63
|
+
input: 0,
|
|
64
|
+
output: 0,
|
|
65
|
+
cacheRead: 0,
|
|
66
|
+
cacheWrite: 0,
|
|
67
|
+
totalTokens: 0,
|
|
68
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
69
|
+
},
|
|
70
|
+
stopReason: 'stop',
|
|
71
|
+
timestamp: Date.now(),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Apply one `update` payload from a SessionNotification. */
|
|
76
|
+
apply(update: SessionUpdate): void {
|
|
77
|
+
switch (update.sessionUpdate) {
|
|
78
|
+
case 'agent_message_chunk': {
|
|
79
|
+
const text = blockText(update.content);
|
|
80
|
+
if (text) {
|
|
81
|
+
this.markTurn();
|
|
82
|
+
this.appendText(text);
|
|
83
|
+
}
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
case 'agent_thought_chunk': {
|
|
87
|
+
const text = blockText(update.content);
|
|
88
|
+
if (text) {
|
|
89
|
+
this.markTurn();
|
|
90
|
+
const part: ThinkingContent = { type: 'thinking', thinking: text };
|
|
91
|
+
this.assistant.content.push(part);
|
|
92
|
+
}
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
case 'tool_call': {
|
|
96
|
+
this.markTurn();
|
|
97
|
+
const part: ToolCall = {
|
|
98
|
+
type: 'toolCall',
|
|
99
|
+
id: update.toolCallId,
|
|
100
|
+
name: update.title || update.kind || 'tool',
|
|
101
|
+
arguments: (update.rawInput as Record<string, unknown>) ?? {},
|
|
102
|
+
};
|
|
103
|
+
this.assistant.content.push(part);
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
// tool_call_update / plan / available_commands_update / current_mode_update:
|
|
107
|
+
// no projection needed for subagent output.
|
|
108
|
+
default:
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private markTurn(): void {
|
|
114
|
+
if (!this.hasContent) {
|
|
115
|
+
this.hasContent = true;
|
|
116
|
+
this.usage.turns = Math.max(this.usage.turns, 1);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private appendText(text: string): void {
|
|
121
|
+
const last = this.assistant.content.at(-1);
|
|
122
|
+
if (last && last.type === 'text') {
|
|
123
|
+
(last as TextContent).text += text;
|
|
124
|
+
} else {
|
|
125
|
+
const part: TextContent = { type: 'text', text };
|
|
126
|
+
this.assistant.content.push(part);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Current assistant message (live snapshot for streaming callbacks). */
|
|
131
|
+
current(): AssistantMessage {
|
|
132
|
+
return this.assistant;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Finalize and return the collected messages + usage. */
|
|
136
|
+
finalize(rawStopReason?: string): { messages: Message[]; usage: UsageStats; stopReason: string } {
|
|
137
|
+
this.assistant.stopReason = mapStopReason(rawStopReason);
|
|
138
|
+
const messages: Message[] = this.hasContent ? [this.assistant] : [];
|
|
139
|
+
return { messages, usage: this.usage, stopReason: rawStopReason ?? 'end_turn' };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -39,11 +39,19 @@ interface SectionEntry {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function normalizeWhitespace(value: string): string {
|
|
42
|
-
return value
|
|
42
|
+
return value
|
|
43
|
+
.replace(/\r/g, '')
|
|
44
|
+
.replace(/[ \t]+/g, ' ')
|
|
45
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
46
|
+
.trim();
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
function normalizeSectionTitle(title: string): string {
|
|
46
|
-
return title
|
|
50
|
+
return title
|
|
51
|
+
.trim()
|
|
52
|
+
.toLowerCase()
|
|
53
|
+
.replace(/[\s/]+/g, ' ')
|
|
54
|
+
.replace(/[():]/g, '');
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
function splitMarkdownSections(markdown: string): { preamble: string; sections: SectionEntry[] } {
|
|
@@ -141,7 +149,10 @@ function extractPaths(text: string): HandoffFileRef[] {
|
|
|
141
149
|
const files: HandoffFileRef[] = [];
|
|
142
150
|
|
|
143
151
|
const addPath = (rawPath: string, notes?: string) => {
|
|
144
|
-
const path = rawPath
|
|
152
|
+
const path = rawPath
|
|
153
|
+
.trim()
|
|
154
|
+
.replace(/^`|`$/g, '')
|
|
155
|
+
.replace(/[),.:;]+$/g, '');
|
|
145
156
|
if (!path.includes('/')) return;
|
|
146
157
|
if (path.startsWith('http://') || path.startsWith('https://')) return;
|
|
147
158
|
if (seen.has(path)) return;
|
|
@@ -203,7 +214,9 @@ export function buildHandoffFromResult(input: {
|
|
|
203
214
|
const riskItems = uniq(extractListItems(getFirstSectionBody(sections, ['Risks'])));
|
|
204
215
|
const openQuestions = uniq([
|
|
205
216
|
...extractListItems(getFirstSectionBody(sections, ['Open Questions'])),
|
|
206
|
-
...toQuestions(
|
|
217
|
+
...toQuestions(
|
|
218
|
+
extractListItems(getFirstSectionBody(sections, ['Notes', 'Constraints or Unknowns'])),
|
|
219
|
+
),
|
|
207
220
|
]);
|
|
208
221
|
const goal =
|
|
209
222
|
extractParagraph(getFirstSectionBody(sections, ['Goal'])) ??
|
|
@@ -44,6 +44,7 @@ import type { AgentResult } from './agent-runner-types.js';
|
|
|
44
44
|
import { getFinalText } from './agent-result-utils.js';
|
|
45
45
|
import { buildHandoffFromResult, renderHandoffForPrompt } from './handoffs.js';
|
|
46
46
|
import { emptyUsage, spawnPiAgent, type ToolExecutionStartEvent } from './spawn-utils.js';
|
|
47
|
+
import { dispatchSubagent } from './cursor/dispatch.js';
|
|
47
48
|
import { registerListAgentsTool } from './list-agents.js';
|
|
48
49
|
import { registerCreateAgentCommand } from './create-agent.js';
|
|
49
50
|
|
|
@@ -356,7 +357,7 @@ async function runSingleAgent(
|
|
|
356
357
|
};
|
|
357
358
|
|
|
358
359
|
let spawnResult: Awaited<ReturnType<typeof spawnPiAgent>> | undefined;
|
|
359
|
-
spawnResult = await
|
|
360
|
+
spawnResult = await dispatchSubagent({
|
|
360
361
|
cwd: cwd ?? defaultCwd,
|
|
361
362
|
agentName: agent.name,
|
|
362
363
|
task,
|
|
@@ -526,6 +527,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
526
527
|
): Promise<Array<{ value: string; label?: string; description?: string }>> {
|
|
527
528
|
const items = new Map<string, { value: string; label?: string; description?: string }>();
|
|
528
529
|
|
|
530
|
+
// Cursor ACP backend: cursor:<model> routes through `cursor-agent acp`.
|
|
531
|
+
items.set('cursor:composer-2.5', {
|
|
532
|
+
value: 'cursor:composer-2.5',
|
|
533
|
+
label: 'cursor:composer-2.5',
|
|
534
|
+
description: 'Cursor Composer 2.5 via ACP (requires cursor-agent + agent login)',
|
|
535
|
+
});
|
|
536
|
+
|
|
529
537
|
try {
|
|
530
538
|
const authStorage = AuthStorage.create();
|
|
531
539
|
const modelRegistry = ModelRegistry.create(authStorage);
|
|
@@ -37,11 +37,12 @@ export function registerListAgentsTool(
|
|
|
37
37
|
const discovery = discoverAgents(ctx.cwd, agentScope, resolvedPaths);
|
|
38
38
|
|
|
39
39
|
if (discovery.agents.length === 0) {
|
|
40
|
-
const dirs =
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
const dirs =
|
|
41
|
+
agentScope === 'user'
|
|
42
|
+
? '~/.pi/agent/prompts'
|
|
43
|
+
: agentScope === 'project'
|
|
44
|
+
? '.pi/prompts'
|
|
45
|
+
: '~/.pi/agent/prompts and .pi/prompts';
|
|
45
46
|
return {
|
|
46
47
|
content: [
|
|
47
48
|
{
|
|
@@ -53,10 +54,7 @@ export function registerListAgentsTool(
|
|
|
53
54
|
};
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
const lines: string[] = [
|
|
57
|
-
`Available agents (scope: ${agentScope}):`,
|
|
58
|
-
'',
|
|
59
|
-
];
|
|
57
|
+
const lines: string[] = [`Available agents (scope: ${agentScope}):`, ''];
|
|
60
58
|
|
|
61
59
|
for (const agent of discovery.agents) {
|
|
62
60
|
lines.push(`### ${agent.name}`);
|
|
@@ -10,16 +10,18 @@ export interface RunAgentCommandOptions {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
function tokenize(input: string): string[] {
|
|
13
|
-
return
|
|
13
|
+
return (
|
|
14
|
+
input.match(/"[^"]*"|'[^']*'|\S+/g)?.map((token) => token.replace(/^['"]|['"]$/g, '')) ?? []
|
|
15
|
+
);
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export function formatRunAgentUsage(): string {
|
|
17
19
|
return 'Usage: /run-agent [--scope user|project|both] [--model <id>] [--thinking <level>] [--yes-project-agents] <agent> [task]';
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
export function parseRunAgentArgs(
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
export function parseRunAgentArgs(
|
|
23
|
+
rawArgs?: string,
|
|
24
|
+
): { ok: true; options: RunAgentCommandOptions } | { ok: false; error: string } {
|
|
23
25
|
const tokens = tokenize(rawArgs?.trim() ?? '');
|
|
24
26
|
const taskTokens: string[] = [];
|
|
25
27
|
|
|
@@ -49,12 +49,19 @@ async function writePromptToTempFile(
|
|
|
49
49
|
return { dir: tmpDir, filePath };
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
function cleanupTempFiles(
|
|
53
|
-
tmpPromptPath
|
|
54
|
-
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
function cleanupTempFiles(tmpPromptPath: string | null, tmpPromptDir: string | null): void {
|
|
53
|
+
if (tmpPromptPath)
|
|
54
|
+
try {
|
|
55
|
+
fs.unlinkSync(tmpPromptPath);
|
|
56
|
+
} catch {
|
|
57
|
+
/* ignore */
|
|
58
|
+
}
|
|
59
|
+
if (tmpPromptDir)
|
|
60
|
+
try {
|
|
61
|
+
fs.rmdirSync(tmpPromptDir);
|
|
62
|
+
} catch {
|
|
63
|
+
/* ignore */
|
|
64
|
+
}
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
export interface ToolExecutionStartEvent {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dreki-gg/pi-subagent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Subagent tool and direct agent runs for pi — isolated agents, parallel scouts, manager workflows, and bundled prompts",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
"./prompts"
|
|
31
31
|
]
|
|
32
32
|
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@agentclientprotocol/sdk": "^1.0.0"
|
|
35
|
+
},
|
|
33
36
|
"devDependencies": {
|
|
34
37
|
"@types/node": "24",
|
|
35
38
|
"oxfmt": "^0.43.0",
|
package/prompts/planner.md
CHANGED
|
@@ -12,14 +12,10 @@ You must NOT make any changes. Only read, analyze, and plan.
|
|
|
12
12
|
|
|
13
13
|
If the task has multiple viable designs, unclear ownership boundaries, or needs sharper decomposition before implementation, you may consult the `advisor` agent with the `subagent` tool.
|
|
14
14
|
|
|
15
|
-
When consulting `advisor`, send only:
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
- the smallest relevant task summary
|
|
20
|
-
- what constraints or trade-offs you already see
|
|
21
|
-
|
|
22
|
-
Treat `advisor` as a focused second opinion. You still own the plan.
|
|
15
|
+
When consulting `advisor`, send only: your role (`planner`), the exact
|
|
16
|
+
design or decomposition question, the implicated files/packages, the
|
|
17
|
+
smallest relevant task summary, and the constraints or trade-offs you
|
|
18
|
+
already see. Treat it as a focused second opinion — you still own the plan.
|
|
23
19
|
|
|
24
20
|
Input format you'll receive:
|
|
25
21
|
- Context/findings from a scout agent
|
package/prompts/reviewer.md
CHANGED
|
@@ -30,25 +30,10 @@ Rules:
|
|
|
30
30
|
8. Use `advisor` only when severity, intended behavior, or trade-offs remain unclear after inspection and validation.
|
|
31
31
|
9. You still own the final review judgment. Support agents inform your conclusion; they do not replace it.
|
|
32
32
|
|
|
33
|
-
When consulting
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
-
|
|
37
|
-
- the smallest relevant goal summary
|
|
38
|
-
- what you already verified from diff/code
|
|
39
|
-
|
|
40
|
-
When consulting `bug-prover`, send only:
|
|
41
|
-
- current role: `reviewer`
|
|
42
|
-
- the exact claim that now needs a minimal repro
|
|
43
|
-
- files, symbols, tests, or commands already inspected
|
|
44
|
-
- why read-only validation was insufficient
|
|
45
|
-
|
|
46
|
-
When consulting `advisor`, send only:
|
|
47
|
-
- current role: `reviewer`
|
|
48
|
-
- the exact uncertainty or severity question
|
|
49
|
-
- changed files or symbols involved
|
|
50
|
-
- the smallest relevant goal summary
|
|
51
|
-
- what you verified or could not verify from diff/code
|
|
33
|
+
When consulting any support agent, send only: your role (`reviewer`), the
|
|
34
|
+
exact claim or question, the files/symbols involved, the smallest relevant
|
|
35
|
+
goal summary, and what you already verified from diff/code. For
|
|
36
|
+
`bug-prover`, add why read-only validation was insufficient.
|
|
52
37
|
|
|
53
38
|
Strategy:
|
|
54
39
|
1. Inspect the diff and understand the reason for the change
|
package/prompts/worker.md
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: worker
|
|
3
3
|
description: General-purpose subagent with full capabilities, isolated context
|
|
4
|
-
model:
|
|
5
|
-
thinking: low
|
|
4
|
+
model: cursor:composer-2.5
|
|
6
5
|
sessionStrategy: fork-at
|
|
7
6
|
---
|
|
8
7
|
|
|
@@ -12,14 +11,10 @@ Work autonomously to complete the assigned task. Use all available tools as need
|
|
|
12
11
|
|
|
13
12
|
If a focused second opinion would reduce risk, you may consult the `advisor` agent with the `subagent` tool. Reserve that for ambiguous architecture tradeoffs, persistent failing tests or unexplained errors, merge conflicts or tangled diffs, and security-sensitive or migration-heavy changes.
|
|
14
13
|
|
|
15
|
-
When consulting `advisor`, send only:
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
- the smallest relevant task summary
|
|
20
|
-
- what you already tried or observed
|
|
21
|
-
|
|
22
|
-
Treat `advisor` as a second opinion, not a replacement owner. You stay responsible for implementation and the final result.
|
|
14
|
+
When consulting `advisor`, send only: your role (`worker`), the exact
|
|
15
|
+
question, the touched files/symbols, the smallest relevant task summary,
|
|
16
|
+
and what you already tried or observed. Treat it as a second opinion, not a
|
|
17
|
+
replacement owner — you stay responsible for the final result.
|
|
23
18
|
|
|
24
19
|
Keep the final response compact. It should function as a review packet for downstream agents, not a long implementation diary.
|
|
25
20
|
|
|
@@ -59,9 +59,33 @@ Output:
|
|
|
59
59
|
- Prefer local pi docs before external docs when writing `docs-scout`.
|
|
60
60
|
- Mention validation commands in planner/worker/reviewer when package behavior changes.
|
|
61
61
|
|
|
62
|
+
## Prompt hygiene (anti-rot)
|
|
63
|
+
|
|
64
|
+
Prompts are code: unmaintained instructions become technical debt. Apply the
|
|
65
|
+
same doctrine a project applies to its AGENTS.md — durable facts and
|
|
66
|
+
invariants, not narration.
|
|
67
|
+
|
|
68
|
+
- **Invariants, not incidents.** A rule earned from a field bug must state
|
|
69
|
+
the durable invariant, never the story ("fixes relocate gaps more often
|
|
70
|
+
than they close them" — not "remember that PR"). References to tools,
|
|
71
|
+
tickets, or model quirks of the moment rot first.
|
|
72
|
+
- **One owner per fact.** Anything that drifts (model ids, tool lists,
|
|
73
|
+
paths) lives in frontmatter or arrives in the caller's task — never
|
|
74
|
+
restated in the body. A block repeated across prompts is the same fact
|
|
75
|
+
with N owners: compress it to one convention line per prompt.
|
|
76
|
+
- **Every line changes behavior.** If deleting a line would not change what
|
|
77
|
+
the agent does, delete it.
|
|
78
|
+
- **Output contracts are APIs.** Downstream agents parse the sections;
|
|
79
|
+
renaming or reshaping them is a breaking change — evolve additively.
|
|
80
|
+
- **Update on every miss.** When an agent fails in the field, amend the rule
|
|
81
|
+
that should have caught it — as an invariant, in the same change as the
|
|
82
|
+
fix. A prompt that never absorbs its misses is rotting silently.
|
|
83
|
+
|
|
62
84
|
## Review checklist
|
|
63
85
|
- Is the role narrower than a general engineer?
|
|
64
86
|
- Would another agent know exactly when to use it?
|
|
65
87
|
- Are the tools minimal?
|
|
66
88
|
- Is the handoff structured?
|
|
67
89
|
- Is the file under 100 lines?
|
|
90
|
+
- Does any line narrate an incident instead of stating its invariant?
|
|
91
|
+
- Is any fact stated here also owned somewhere else?
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end check against a real `cursor-agent acp` server.
|
|
3
|
+
* Gated behind CURSOR_ACP_E2E=1 (requires cursor-agent installed + `agent login`).
|
|
4
|
+
*
|
|
5
|
+
* CURSOR_ACP_E2E=1 bun test test/cursor-acp-runner.e2e.test.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from 'bun:test';
|
|
9
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { runCursorAcpAgent } from '../extensions/subagent/cursor/acp-runner.js';
|
|
13
|
+
|
|
14
|
+
const E2E = !!process.env.CURSOR_ACP_E2E;
|
|
15
|
+
const maybe = E2E ? it : it.skip;
|
|
16
|
+
|
|
17
|
+
describe('runCursorAcpAgent (e2e)', () => {
|
|
18
|
+
maybe(
|
|
19
|
+
'completes a real Composer 2.5 turn',
|
|
20
|
+
async () => {
|
|
21
|
+
const cwd = await mkdtemp(join(tmpdir(), 'cursor-acp-e2e-'));
|
|
22
|
+
const result = await runCursorAcpAgent({
|
|
23
|
+
cwd,
|
|
24
|
+
agentName: 'e2e',
|
|
25
|
+
task: 'Reply with exactly the word: ok',
|
|
26
|
+
model: 'cursor:composer-2.5',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const text = result.messages
|
|
30
|
+
.filter((m) => m.role === 'assistant')
|
|
31
|
+
.flatMap((m) => (m as any).content)
|
|
32
|
+
.filter((p: any) => p.type === 'text')
|
|
33
|
+
.map((p: any) => p.text)
|
|
34
|
+
.join('');
|
|
35
|
+
|
|
36
|
+
expect(result.exitCode).toBe(0);
|
|
37
|
+
expect(result.stopReason).toBeDefined();
|
|
38
|
+
expect(text.trim().length).toBeGreaterThan(0);
|
|
39
|
+
},
|
|
40
|
+
120_000,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { parseCursorModel } from '../extensions/subagent/cursor/model.js';
|
|
3
|
+
|
|
4
|
+
describe('parseCursorModel', () => {
|
|
5
|
+
it('treats undefined/empty as non-cursor', () => {
|
|
6
|
+
expect(parseCursorModel(undefined)).toEqual({ isCursor: false, model: '' });
|
|
7
|
+
expect(parseCursorModel('')).toEqual({ isCursor: false, model: '' });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('passes through normal models', () => {
|
|
11
|
+
expect(parseCursorModel('anthropic/claude-sonnet')).toEqual({
|
|
12
|
+
isCursor: false,
|
|
13
|
+
model: 'anthropic/claude-sonnet',
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('routes cursor:<model> to the cursor backend', () => {
|
|
18
|
+
expect(parseCursorModel('cursor:composer-2.5')).toEqual({
|
|
19
|
+
isCursor: true,
|
|
20
|
+
model: 'composer-2.5',
|
|
21
|
+
});
|
|
22
|
+
expect(parseCursorModel('cursor:gpt-5.2')).toEqual({ isCursor: true, model: 'gpt-5.2' });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('defaults bare cursor / empty suffix to composer-2.5', () => {
|
|
26
|
+
expect(parseCursorModel('cursor')).toEqual({ isCursor: true, model: 'composer-2.5' });
|
|
27
|
+
expect(parseCursorModel('cursor:')).toEqual({ isCursor: true, model: 'composer-2.5' });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('is case-insensitive on the prefix and trims', () => {
|
|
31
|
+
expect(parseCursorModel(' Cursor:composer-2.5 ')).toEqual({
|
|
32
|
+
isCursor: true,
|
|
33
|
+
model: 'composer-2.5',
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import type { PermissionOption } from '@agentclientprotocol/sdk';
|
|
3
|
+
import {
|
|
4
|
+
buildCancelledResponse,
|
|
5
|
+
buildPermissionResponse,
|
|
6
|
+
selectPermissionOption,
|
|
7
|
+
} from '../extensions/subagent/cursor/permissions.js';
|
|
8
|
+
|
|
9
|
+
const opt = (kind: PermissionOption['kind'], optionId: string): PermissionOption => ({
|
|
10
|
+
kind,
|
|
11
|
+
optionId,
|
|
12
|
+
name: optionId,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('selectPermissionOption', () => {
|
|
16
|
+
it('prefers allow_always', () => {
|
|
17
|
+
const options = [
|
|
18
|
+
opt('reject_once', 'r'),
|
|
19
|
+
opt('allow_once', 'ao'),
|
|
20
|
+
opt('allow_always', 'aa'),
|
|
21
|
+
];
|
|
22
|
+
expect(selectPermissionOption(options)).toBe('aa');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('falls back to allow_once when no allow_always', () => {
|
|
26
|
+
expect(selectPermissionOption([opt('reject_once', 'r'), opt('allow_once', 'ao')])).toBe('ao');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('falls back to the first option when no allow kinds', () => {
|
|
30
|
+
expect(selectPermissionOption([opt('reject_once', 'r'), opt('reject_always', 'ra')])).toBe('r');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns undefined for empty options', () => {
|
|
34
|
+
expect(selectPermissionOption([])).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('response builders', () => {
|
|
39
|
+
it('builds a selected outcome', () => {
|
|
40
|
+
expect(buildPermissionResponse('aa')).toEqual({
|
|
41
|
+
outcome: { outcome: 'selected', optionId: 'aa' },
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('builds a cancelled outcome', () => {
|
|
46
|
+
expect(buildCancelledResponse()).toEqual({ outcome: { outcome: 'cancelled' } });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import type { SessionNotification } from '@agentclientprotocol/sdk';
|
|
3
|
+
import { CursorUpdateReducer, mapStopReason } from '../extensions/subagent/cursor/update-mapping.js';
|
|
4
|
+
|
|
5
|
+
type Update = SessionNotification['update'];
|
|
6
|
+
|
|
7
|
+
const textChunk = (text: string): Update => ({
|
|
8
|
+
sessionUpdate: 'agent_message_chunk',
|
|
9
|
+
content: { type: 'text', text },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const toolCall = (toolCallId: string, title: string, rawInput: Record<string, unknown>): Update =>
|
|
13
|
+
({
|
|
14
|
+
sessionUpdate: 'tool_call',
|
|
15
|
+
toolCallId,
|
|
16
|
+
title,
|
|
17
|
+
rawInput,
|
|
18
|
+
}) as Update;
|
|
19
|
+
|
|
20
|
+
describe('CursorUpdateReducer', () => {
|
|
21
|
+
it('coalesces message chunks into one assistant text part', () => {
|
|
22
|
+
const r = new CursorUpdateReducer('composer-2.5');
|
|
23
|
+
r.apply(textChunk('Hello'));
|
|
24
|
+
r.apply(textChunk(' world'));
|
|
25
|
+
const { messages, usage } = r.finalize('end_turn');
|
|
26
|
+
expect(messages).toHaveLength(1);
|
|
27
|
+
expect(messages[0].role).toBe('assistant');
|
|
28
|
+
const textPart = (messages[0] as any).content.find((p: any) => p.type === 'text');
|
|
29
|
+
expect(textPart.text).toBe('Hello world');
|
|
30
|
+
expect(usage.turns).toBe(1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('records tool calls as toolCall parts', () => {
|
|
34
|
+
const r = new CursorUpdateReducer('composer-2.5');
|
|
35
|
+
r.apply(textChunk('working'));
|
|
36
|
+
r.apply(toolCall('t1', 'Edit file', { path: 'a.ts' }));
|
|
37
|
+
const { messages } = r.finalize('end_turn');
|
|
38
|
+
const parts = (messages[0] as any).content;
|
|
39
|
+
const tool = parts.find((p: any) => p.type === 'toolCall');
|
|
40
|
+
expect(tool.name).toBe('Edit file');
|
|
41
|
+
expect(tool.arguments).toEqual({ path: 'a.ts' });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('produces no messages when nothing was emitted', () => {
|
|
45
|
+
const r = new CursorUpdateReducer('composer-2.5');
|
|
46
|
+
const { messages, usage } = r.finalize();
|
|
47
|
+
expect(messages).toHaveLength(0);
|
|
48
|
+
expect(usage.turns).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('maps stop reasons to pi StopReason', () => {
|
|
52
|
+
expect(mapStopReason('end_turn')).toBe('stop');
|
|
53
|
+
expect(mapStopReason('max_tokens')).toBe('length');
|
|
54
|
+
expect(mapStopReason('cancelled')).toBe('aborted');
|
|
55
|
+
expect(mapStopReason('refusal')).toBe('error');
|
|
56
|
+
expect(mapStopReason(undefined)).toBe('stop');
|
|
57
|
+
});
|
|
58
|
+
});
|