@dreki-gg/pi-subagent 0.9.2 → 0.11.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 +17 -0
- package/README.md +41 -0
- package/extensions/subagent/agent-runner.ts +3 -2
- 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/index.ts +32 -11
- package/package.json +4 -1
- package/prompts/advisor.md +1 -1
- package/prompts/bug-prover.md +1 -1
- package/prompts/docs-scout.md +1 -1
- package/prompts/planner.md +1 -1
- package/prompts/reviewer.md +1 -1
- package/prompts/scout.md +1 -1
- package/prompts/ux-designer.md +1 -1
- package/prompts/validator.md +1 -1
- package/prompts/worker.md +1 -2
- package/skills/spawn-subagents/SKILL.md +23 -1
- 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,22 @@
|
|
|
1
1
|
# @dreki-gg/pi-subagent
|
|
2
2
|
|
|
3
|
+
## 0.11.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Model routing guidance and visible model attribution.
|
|
8
|
+
|
|
9
|
+
- `spawn-subagents` skill: new "Model routing" section — route each subagent task to the model whose strengths match the work (bulk token burn → cheap, user-facing → tasteful, reviews → strongest), honor a user model routing policy from AGENTS.md when one exists, and escalate without asking when a cheaper model's output misses the bar.
|
|
10
|
+
- Reasoning defaults: Opus-backed judgment/coding agents (advisor, planner, reviewer, validator, bug-prover, ux-designer) now run `thinking: high` instead of low/medium — reasoning effort applies per step, and high is the quality/cost sweet spot. Scouts stay low for cheap bulk recon.
|
|
11
|
+
- Result rendering: the model that ran each task is now shown next to the agent name in single, parallel, and chain headers (dim ` · model`), and in the working message while a run is active — so per-model quality is auditable at a glance. Parallel running placeholders resolve the model up front (task override → call default → agent default), so attribution shows while tasks are still running, not just after completion.
|
|
12
|
+
- `scout` and `docs-scout` default models move from `gpt-5.4-mini` to `gpt-5.6-luna` — near-Terra coding quality at the lowest benchmarked cost per task.
|
|
13
|
+
|
|
14
|
+
## 0.10.0
|
|
15
|
+
|
|
16
|
+
### Minor Changes
|
|
17
|
+
|
|
18
|
+
- 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`).
|
|
19
|
+
|
|
3
20
|
## 0.9.2
|
|
4
21
|
|
|
5
22
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -26,6 +26,47 @@ 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
|
+
|
|
59
|
+
## Model Routing
|
|
60
|
+
|
|
61
|
+
The `spawn-subagents` skill teaches the orchestrating agent to route each task to the model whose strengths match the work instead of one default model for everything:
|
|
62
|
+
|
|
63
|
+
- **Bulk token burn goes cheap** — log digging, large specs, migrations, clear-spec implementation.
|
|
64
|
+
- **User-facing output goes tasteful** — public APIs, SDKs, UI copy go to (or are reviewed by) the highest-taste model.
|
|
65
|
+
- **Reviews get the strong model** — cheap models only as an extra perspective, never the sole reviewer.
|
|
66
|
+
- **Defaults, not limits** — redo cheap-model output on a stronger model without asking; judge the output, not the price tag.
|
|
67
|
+
|
|
68
|
+
If your context files (e.g. a global `AGENTS.md`) define a model routing policy — a table scoring your models on intelligence / taste / cost — the skill treats it as authoritative when picking per-task `model` overrides. Result headers and working messages show which model ran each task (` · model`), so quality is auditable per model.
|
|
69
|
+
|
|
29
70
|
## Opinionated Defaults
|
|
30
71
|
|
|
31
72
|
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,
|
|
@@ -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
|
+
}
|
|
@@ -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);
|
|
@@ -662,7 +670,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
662
670
|
: undefined;
|
|
663
671
|
|
|
664
672
|
if (ctx.hasUI) {
|
|
665
|
-
|
|
673
|
+
const stepModel =
|
|
674
|
+
step.model ?? params.model ?? agents.find((a) => a.name === step.agent)?.model;
|
|
675
|
+
ctx.ui.setWorkingMessage(
|
|
676
|
+
`Chain step ${i + 1}/${params.chain.length}: ${step.agent}${stepModel ? ` · ${stepModel}` : ''}`,
|
|
677
|
+
);
|
|
666
678
|
}
|
|
667
679
|
|
|
668
680
|
const result = await runSingleAgent(
|
|
@@ -742,10 +754,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
742
754
|
|
|
743
755
|
// Initialize placeholder results
|
|
744
756
|
for (let i = 0; i < params.tasks.length; i++) {
|
|
757
|
+
const t = params.tasks[i];
|
|
745
758
|
allResults[i] = {
|
|
746
|
-
agent:
|
|
759
|
+
agent: t.agent,
|
|
747
760
|
agentSource: 'unknown',
|
|
748
|
-
task:
|
|
761
|
+
task: t.task,
|
|
762
|
+
model: t.model ?? params.model ?? agents.find((a) => a.name === t.agent)?.model,
|
|
749
763
|
exitCode: -1, // -1 = still running
|
|
750
764
|
messages: [],
|
|
751
765
|
stderr: '',
|
|
@@ -832,7 +846,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
832
846
|
|
|
833
847
|
if (params.agent && params.task) {
|
|
834
848
|
if (ctx.hasUI) {
|
|
835
|
-
|
|
849
|
+
const singleModel = params.model ?? agents.find((a) => a.name === params.agent)?.model;
|
|
850
|
+
ctx.ui.setWorkingMessage(
|
|
851
|
+
`Running agent: ${params.agent}${singleModel ? ` · ${singleModel}` : ''}`,
|
|
852
|
+
);
|
|
836
853
|
}
|
|
837
854
|
|
|
838
855
|
const result = await runSingleAgent(
|
|
@@ -965,7 +982,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
965
982
|
|
|
966
983
|
if (expanded) {
|
|
967
984
|
const container = new Container();
|
|
968
|
-
let header = `${icon} ${theme.fg('toolTitle', theme.bold(r.agent))}${theme.fg('muted', ` (${r.agentSource})`)}`;
|
|
985
|
+
let header = `${icon} ${theme.fg('toolTitle', theme.bold(r.agent))}${r.model ? theme.fg('dim', ` · ${r.model}`) : ''}${theme.fg('muted', ` (${r.agentSource})`)}`;
|
|
969
986
|
if (isError && r.stopReason) header += ` ${theme.fg('error', `[${r.stopReason}]`)}`;
|
|
970
987
|
container.addChild(new Text(header, 0, 0));
|
|
971
988
|
if (isError && r.errorMessage)
|
|
@@ -1002,7 +1019,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1002
1019
|
return container;
|
|
1003
1020
|
}
|
|
1004
1021
|
|
|
1005
|
-
let text = `${icon} ${theme.fg('toolTitle', theme.bold(r.agent))}${theme.fg('muted', ` (${r.agentSource})`)}`;
|
|
1022
|
+
let text = `${icon} ${theme.fg('toolTitle', theme.bold(r.agent))}${r.model ? theme.fg('dim', ` · ${r.model}`) : ''}${theme.fg('muted', ` (${r.agentSource})`)}`;
|
|
1006
1023
|
if (isError && r.stopReason) text += ` ${theme.fg('error', `[${r.stopReason}]`)}`;
|
|
1007
1024
|
if (isError && r.errorMessage) text += `\n${theme.fg('error', `Error: ${r.errorMessage}`)}`;
|
|
1008
1025
|
else if (displayItems.length === 0) text += `\n${theme.fg('muted', '(no output)')}`;
|
|
@@ -1057,7 +1074,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1057
1074
|
container.addChild(new Spacer(1));
|
|
1058
1075
|
container.addChild(
|
|
1059
1076
|
new Text(
|
|
1060
|
-
`${theme.fg('muted', `─── Step ${r.step}: `) + theme.fg('accent', r.agent)} ${rIcon}`,
|
|
1077
|
+
`${theme.fg('muted', `─── Step ${r.step}: `) + theme.fg('accent', r.agent)}${r.model ? theme.fg('dim', ` · ${r.model}`) : ''} ${rIcon}`,
|
|
1061
1078
|
0,
|
|
1062
1079
|
0,
|
|
1063
1080
|
),
|
|
@@ -1107,7 +1124,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1107
1124
|
for (const r of details.results) {
|
|
1108
1125
|
const rIcon = r.exitCode === 0 ? theme.fg('success', '✓') : theme.fg('error', '✗');
|
|
1109
1126
|
const displayItems = getDisplayItems(r.messages);
|
|
1110
|
-
text += `\n\n${theme.fg('muted', `─── Step ${r.step}: `)}${theme.fg('accent', r.agent)} ${rIcon}`;
|
|
1127
|
+
text += `\n\n${theme.fg('muted', `─── Step ${r.step}: `)}${theme.fg('accent', r.agent)}${r.model ? theme.fg('dim', ` · ${r.model}`) : ''} ${rIcon}`;
|
|
1111
1128
|
if (displayItems.length === 0) text += `\n${theme.fg('muted', '(no output)')}`;
|
|
1112
1129
|
else text += `\n${renderDisplayItems(displayItems, 5)}`;
|
|
1113
1130
|
}
|
|
@@ -1148,7 +1165,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1148
1165
|
|
|
1149
1166
|
container.addChild(new Spacer(1));
|
|
1150
1167
|
container.addChild(
|
|
1151
|
-
new Text(
|
|
1168
|
+
new Text(
|
|
1169
|
+
`${theme.fg('muted', '─── ') + theme.fg('accent', r.agent)}${r.model ? theme.fg('dim', ` · ${r.model}`) : ''} ${rIcon}`,
|
|
1170
|
+
0,
|
|
1171
|
+
0,
|
|
1172
|
+
),
|
|
1152
1173
|
);
|
|
1153
1174
|
container.addChild(
|
|
1154
1175
|
new Text(theme.fg('muted', 'Task: ') + theme.fg('dim', r.task), 0, 0),
|
|
@@ -1196,7 +1217,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1196
1217
|
? theme.fg('success', '✓')
|
|
1197
1218
|
: theme.fg('error', '✗');
|
|
1198
1219
|
const displayItems = getDisplayItems(r.messages);
|
|
1199
|
-
text += `\n\n${theme.fg('muted', '─── ')}${theme.fg('accent', r.agent)} ${rIcon}`;
|
|
1220
|
+
text += `\n\n${theme.fg('muted', '─── ')}${theme.fg('accent', r.agent)}${r.model ? theme.fg('dim', ` · ${r.model}`) : ''} ${rIcon}`;
|
|
1200
1221
|
if (displayItems.length === 0)
|
|
1201
1222
|
text += `\n${theme.fg('muted', r.exitCode === -1 ? '(running...)' : '(no output)')}`;
|
|
1202
1223
|
else text += `\n${renderDisplayItems(displayItems, 5)}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dreki-gg/pi-subagent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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/advisor.md
CHANGED
package/prompts/bug-prover.md
CHANGED
|
@@ -3,7 +3,7 @@ name: bug-prover
|
|
|
3
3
|
description: Create the smallest failing repro for a suspected bug. Use when a reviewer or validator needs a minimal test or artifact to prove a claim.
|
|
4
4
|
tools: read, grep, find, ls, bash, edit, write
|
|
5
5
|
model: anthropic/claude-opus-4-6
|
|
6
|
-
thinking:
|
|
6
|
+
thinking: high
|
|
7
7
|
sessionStrategy: fork-at
|
|
8
8
|
---
|
|
9
9
|
|
package/prompts/docs-scout.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: docs-scout
|
|
3
3
|
description: Documentation scout that uses Context7 first, then summarizes the relevant implementation details
|
|
4
4
|
tools: context7_resolve_library_id, context7_get_library_docs, context7_get_cached_doc_raw, read, grep, find, ls
|
|
5
|
-
model: openai/gpt-5.
|
|
5
|
+
model: openai/gpt-5.6-luna
|
|
6
6
|
thinking: low
|
|
7
7
|
---
|
|
8
8
|
|
package/prompts/planner.md
CHANGED
|
@@ -3,7 +3,7 @@ name: planner
|
|
|
3
3
|
description: Creates implementation plans from context and requirements
|
|
4
4
|
tools: read, grep, find, ls, subagent
|
|
5
5
|
model: anthropic/claude-opus-4-6
|
|
6
|
-
thinking:
|
|
6
|
+
thinking: high
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
You are a planning specialist. You receive context (from a scout) and requirements, then produce a clear implementation plan.
|
package/prompts/reviewer.md
CHANGED
package/prompts/scout.md
CHANGED
package/prompts/ux-designer.md
CHANGED
|
@@ -3,7 +3,7 @@ name: ux-designer
|
|
|
3
3
|
description: Frontend UI designer that produces clean, human-designed interfaces — anti-Codex aesthetic
|
|
4
4
|
tools: read, grep, find, ls
|
|
5
5
|
model: anthropic/claude-opus-4-6
|
|
6
|
-
thinking:
|
|
6
|
+
thinking: high
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
You are a frontend UX designer agent. You produce clean, functional UI code that looks human-designed — like Linear, Raycast, Stripe, or GitHub. You exist to counter the default AI aesthetic.
|
package/prompts/validator.md
CHANGED
|
@@ -3,7 +3,7 @@ name: validator
|
|
|
3
3
|
description: Validate or falsify a specific bug, regression, or behavior claim from code, tests, and commands. Use when a review finding needs evidence before it becomes a fix request.
|
|
4
4
|
tools: read, grep, find, ls, bash
|
|
5
5
|
model: anthropic/claude-opus-4-6
|
|
6
|
-
thinking:
|
|
6
|
+
thinking: high
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
You are a validator.
|
package/prompts/worker.md
CHANGED
|
@@ -26,6 +26,8 @@ Strong triggers:
|
|
|
26
26
|
- "ask advisor for a second opinion on this"
|
|
27
27
|
- "validate whether this review finding is real"
|
|
28
28
|
- "prove this bug with a minimal failing test"
|
|
29
|
+
- "route this to the cheap model"
|
|
30
|
+
- "use the right model for each piece"
|
|
29
31
|
|
|
30
32
|
Patterns:
|
|
31
33
|
|
|
@@ -114,13 +116,33 @@ Avoid:
|
|
|
114
116
|
- calling `advisor` when `planner` or `reviewer` already has enough signal to proceed alone
|
|
115
117
|
- sending speculative review findings back for fixes before they are validated when evidence is needed
|
|
116
118
|
|
|
117
|
-
## 8.
|
|
119
|
+
## 8. Model routing
|
|
120
|
+
Route each subagent task to the model whose strengths match the work, not to one default model for everything.
|
|
121
|
+
|
|
122
|
+
Policy source:
|
|
123
|
+
- If the user's context files (AGENTS.md or equivalent) define a model routing policy — a table scoring models on axes like intelligence, taste, and cost — that policy is authoritative. Apply it when picking `model` overrides for `single` / `parallel` / `chain` tasks.
|
|
124
|
+
- Without a user policy, use the agent prompt's default model and these structural rules.
|
|
125
|
+
|
|
126
|
+
Structural rules (harness-independent):
|
|
127
|
+
- **Bulk token burn goes cheap.** Log digging, reading large files/specs/PDFs, data analysis, mechanical migrations, and clear-spec implementation belong on the cheapest capable model.
|
|
128
|
+
- **User-facing output goes tasteful.** Public APIs, SDKs, UI copy, and design decisions go to the highest-taste model, or are reviewed by it before shipping.
|
|
129
|
+
- **Reviews get the strong model.** Plan and implementation reviews run on the strongest model; a cheap model may be added as an extra independent perspective, never as the only reviewer.
|
|
130
|
+
- **Defaults, not limits.** If a cheaper model's output does not meet the bar, rerun the task on a stronger model without asking. Judge the output, not the price tag — escalating costs less than shipping mediocre work.
|
|
131
|
+
- **Cost is a tiebreaker only.** Use cheap models to gather information before engaging an expensive one, never to avoid it.
|
|
132
|
+
- **Name the model.** When reporting delegated work back, say which model ran each task so quality can be judged per model.
|
|
133
|
+
|
|
134
|
+
Mechanics:
|
|
135
|
+
- Pass `model` per task/step (e.g. `{ agent: "worker", task: "…", model: "cursor:composer-2.5" }`) or once per call as a default for all tasks.
|
|
136
|
+
- `cursor:<model>` routes through Cursor's agent via ACP — useful as a distinct cheap/fast implementation backend.
|
|
137
|
+
|
|
138
|
+
## 9. Agent discovery and creation
|
|
118
139
|
Use `list_agents` before spawning when you're unsure which agents exist.
|
|
119
140
|
Use `/create-agent <name> [description]` to scaffold a new project-local agent in `.pi/prompts/` when the user needs a custom agent that doesn't exist yet.
|
|
120
141
|
- After creating, read and refine the prompt file to fit the use case.
|
|
121
142
|
- Remember to use `agentScope: "both"` or `"project"` to include project-local agents.
|
|
122
143
|
|
|
123
144
|
Execution rules:
|
|
145
|
+
- Route each task to the model whose strengths match the work; honor the user's model routing policy when one exists (section 8).
|
|
124
146
|
- Use `manager` when the task is too large for one prompt but still needs coherent decisions.
|
|
125
147
|
- Use `advisor` for targeted second opinions on tricky or high-risk cases.
|
|
126
148
|
- Use `validator` to confirm a claim before escalating it as a fix.
|
|
@@ -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
|
+
});
|