@justjoshn/pi-pstack 0.15.36-pi.1 → 0.15.36-pi.7
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 +39 -0
- package/README.md +15 -12
- package/agents/ci-watcher.md +27 -0
- package/agents/thermo-nuclear-code-quality-review.md +23 -0
- package/assets/logo.png +0 -0
- package/automations/benny/FOR_AGENTS.md +1 -1
- package/automations/benny/README.md +2 -2
- package/automations/benny/skills/setup-benny/SKILL.md +5 -5
- package/bin/pstack-routines-supervisor +9 -0
- package/docs/guide/07-overnight.md +1 -1
- package/docs/guide/09-make-it-yours.md +1 -1
- package/docs/guide/10-recipes-and-pitfalls.md +1 -1
- package/docs/native-gaps.md +11 -5
- package/docs/parity-reverse-engineering.md +94 -0
- package/package.json +41 -6
- package/packaging/install-supervisor-unit.sh +164 -0
- package/packaging/launchd/com.pstack.routines-supervisor.plist.example +24 -0
- package/packaging/systemd/pstack-routines-supervisor.service.example +15 -0
- package/skills/architect/SKILL.md +1 -1
- package/skills/architect/references/runner-prompt.md +1 -1
- package/skills/arena/SKILL.md +3 -3
- package/skills/check-compiler-errors/SKILL.md +23 -0
- package/skills/create-skill/SKILL.md +515 -29
- package/skills/fix-ci/SKILL.md +29 -0
- package/skills/fix-merge-conflicts/SKILL.md +32 -0
- package/skills/get-pr-comments/SKILL.md +23 -0
- package/skills/how/SKILL.md +1 -1
- package/skills/ketchup-video/SKILL.md +2 -2
- package/skills/loop-on-ci/SKILL.md +50 -0
- package/skills/make-bot-ui/SKILL.md +2 -2
- package/skills/make-pr-easy-to-review/SKILL.md +59 -0
- package/skills/new-branch-and-pr/SKILL.md +29 -0
- package/skills/no-inline-imports/SKILL.md +12 -0
- package/skills/poteto-help/SKILL.md +4 -4
- package/skills/poteto-mode/playbooks/multi-phase-plan.md +1 -1
- package/skills/poteto-mode/playbooks/orchestrate.md +1 -1
- package/skills/pr-review-canvas/SKILL.md +176 -0
- package/skills/pr-review-canvas/renderer.js +170 -0
- package/skills/pr-review-canvas/styles.css +96 -0
- package/skills/pr-review-canvas/template.html +18 -0
- package/skills/reflect/SKILL.md +1 -1
- package/skills/review-and-ship/SKILL.md +41 -0
- package/skills/run-smoke-tests/SKILL.md +42 -0
- package/skills/statechart/scripts/test/cli.test.ts +1 -1
- package/skills/swarm/SKILL.md +2 -2
- package/skills/thermo-nuclear-code-quality-review/SKILL.md +192 -0
- package/skills/typescript-exhaustive-switch/SKILL.md +10 -0
- package/skills/verify-this/SKILL.md +74 -0
- package/skills/weekly-review/SKILL.md +32 -0
- package/skills/what-did-i-get-done/SKILL.md +31 -0
- package/skills/why/SKILL.md +1 -1
- package/skills/workflow-from-chats/SKILL.md +50 -0
- package/src/mode.ts +3 -3
- package/src/paths.ts +13 -0
- package/src/pi-command.ts +12 -0
- package/src/rule.ts +4 -4
- package/src/sections.ts +2 -1
- package/src/subscriptions/durable-store.ts +482 -0
- package/src/subscriptions/durable-types.ts +41 -0
- package/src/subscriptions/ensure-supervisor.ts +67 -0
- package/src/subscriptions/index.ts +231 -271
- package/src/subscriptions/supervisor-main.ts +88 -0
- package/src/subscriptions/supervisor.ts +271 -0
- package/src/subscriptions/webhook.ts +22 -9
- package/src/task/card.ts +29 -0
- package/src/task/records.ts +3 -8
- package/src/task/runner.ts +9 -14
- package/src/task/tool.ts +18 -21
- package/src/todo.ts +1 -2
- package/scripts/live-e2e.mjs +0 -257
- package/test/aliases.test.ts +0 -57
- package/test/ask.test.ts +0 -85
- package/test/autocomplete.test.ts +0 -39
- package/test/e2e/aliases.e2e.test.ts +0 -81
- package/test/e2e/ask.e2e.test.ts +0 -135
- package/test/e2e/harness.ts +0 -179
- package/test/e2e/host.e2e.test.ts +0 -193
- package/test/e2e/lifecycle.e2e.test.ts +0 -99
- package/test/e2e/mode.e2e.test.ts +0 -160
- package/test/e2e/package.e2e.test.ts +0 -67
- package/test/e2e/task.e2e.test.ts +0 -734
- package/test/e2e/todo.e2e.test.ts +0 -59
- package/test/fixtures/cursor-askquestion-calls.json +0 -806
- package/test/fixtures/fake-pi.mjs +0 -96
- package/test/fixtures/image-model.ts +0 -16
- package/test/fixtures/list-models.ts +0 -15
- package/test/fixtures/readonly-shell-exit.ts +0 -17
- package/test/fixtures/scripted-model.ts +0 -113
- package/test/loop.test.ts +0 -209
- package/test/mode.test.ts +0 -143
- package/test/plan.test.ts +0 -126
- package/test/readonly-shell-security.test.ts +0 -84
- package/test/readonly-shell.test.ts +0 -44
- package/test/records.test.ts +0 -143
- package/test/registry.test.ts +0 -18
- package/test/resources.test.ts +0 -162
- package/test/rule.test.ts +0 -92
- package/test/scripts.test.ts +0 -182
- package/test/sections.test.ts +0 -56
- package/test/subscriptions-cron.test.ts +0 -60
- package/test/subscriptions.test.ts +0 -598
- package/test/task-agents.test.ts +0 -49
- package/test/task-card.test.ts +0 -34
- package/test/task-fork.test.ts +0 -233
- package/test/task-format.test.ts +0 -65
- package/test/task-model.test.ts +0 -141
- package/test/task-runner.test.ts +0 -259
- package/test/task-schema.test.ts +0 -50
- package/test/task-status.test.ts +0 -155
- package/test/task-worktree.test.ts +0 -106
- package/test/todo.test.ts +0 -74
- package/test/visualize.test.ts +0 -152
- package/tsconfig.json +0 -5
- package/vitest.config.ts +0 -16
package/src/task/tool.ts
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { existsSync, rmSync } from 'node:fs';
|
|
3
|
-
import {
|
|
3
|
+
import { join } from 'node:path';
|
|
4
4
|
|
|
5
|
-
import { getSupportedThinkingLevels
|
|
5
|
+
import { getSupportedThinkingLevels } from '@earendil-works/pi-ai';
|
|
6
6
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
7
7
|
import { type Static, Type } from 'typebox';
|
|
8
8
|
import { childIdentity, childVariables } from '../child-env.ts';
|
|
9
9
|
import { rootStoreDir, subagentDir, workspaceStateDir, worktreesDir } from '../paths.ts';
|
|
10
|
+
import { piCommand } from '../pi-command.ts';
|
|
10
11
|
import { type AgentDefinition, findAgent } from './agents.ts';
|
|
11
|
-
import { renderTaskCard } from './card.ts';
|
|
12
|
+
import { renderTaskCard, renderTaskNotification } from './card.ts';
|
|
12
13
|
import { backgroundText, bounded, foregroundText, steerText, worktreeLine, worktreePreface } from './format.ts';
|
|
13
14
|
import { type AvailableModel, type ModelSpec, resolveSubagentModel } from './models.ts';
|
|
14
15
|
import { type AgentRecord, acquireRunLock, forkTranscript, type RunLock, readonlyLaunchRestores, readRecord, transcriptFor, writeRecord } from './records.ts';
|
|
15
|
-
import { type BackgroundNotice, createSubagentRegistry } from './registry.ts';
|
|
16
|
-
import { type ChildHandle, type ChildOutcome, childArgs, resolveAttachments, startChild, validateAgentId, withAttachments } from './runner.ts';
|
|
16
|
+
import { type BackgroundNotice, createSubagentRegistry, NOTIFICATION_TYPE } from './registry.ts';
|
|
17
|
+
import { type ChildHandle, type ChildInput, type ChildOutcome, childArgs, resolveAttachments, startChild, validateAgentId, withAttachments } from './runner.ts';
|
|
17
18
|
import { registerStopAgent } from './stop.ts';
|
|
18
19
|
import { addWorktree, planWorktree, type Worktree } from './worktree.ts';
|
|
19
20
|
|
|
@@ -50,7 +51,8 @@ export const TaskParams = Type.Object({
|
|
|
50
51
|
),
|
|
51
52
|
file_attachments: Type.Optional(Type.Array(Type.String(), { description: 'Paths of files or images to attach to the agent first message.' })),
|
|
52
53
|
environment: Type.Optional(
|
|
53
|
-
|
|
54
|
+
Type.Enum(['local', 'cloud'] as const, {
|
|
55
|
+
type: 'string',
|
|
54
56
|
description:
|
|
55
57
|
'local (default) runs the agent in this working directory. cloud runs it on this machine in its own git worktree on a new branch pstack/<agent id>, created from cloud_base_branch or the current HEAD commit, so uncommitted changes do not reach it. The result names the worktree and branch; merge the branch to take its commits. Ignored with resume; not allowed with fork.',
|
|
56
58
|
}),
|
|
@@ -101,13 +103,6 @@ export interface Launch {
|
|
|
101
103
|
|
|
102
104
|
const runsReadonly = (plan: Launch) => plan.readonly || plan.planMode;
|
|
103
105
|
|
|
104
|
-
/** The running pi script, so children match the parent's Pi version; falls back to `pi` on PATH. */
|
|
105
|
-
function piCommand(): string[] {
|
|
106
|
-
const script = process.argv[1];
|
|
107
|
-
if (script !== undefined && !script.startsWith('/$bunfs/') && existsSync(script)) return [process.execPath, script];
|
|
108
|
-
return /^(node|bun)(\.exe)?$/.test(basename(process.execPath).toLowerCase()) ? ['pi'] : [process.execPath];
|
|
109
|
-
}
|
|
110
|
-
|
|
111
106
|
export function availableModels(ctx: ExtensionContext): AvailableModel[] {
|
|
112
107
|
return ctx.modelRegistry.getAvailable().map((model) => ({ provider: model.provider, id: model.id, reasoning: model.reasoning, levels: getSupportedThinkingLevels(model) }));
|
|
113
108
|
}
|
|
@@ -219,13 +214,12 @@ export function planFork(params: TaskInput, ctx: ExtensionContext, agents: reado
|
|
|
219
214
|
};
|
|
220
215
|
}
|
|
221
216
|
|
|
222
|
-
function launchChild(plan: Launch,
|
|
217
|
+
function launchChild(plan: Launch, input: ChildInput, signal: AbortSignal | undefined) {
|
|
223
218
|
const readonly = runsReadonly(plan);
|
|
224
219
|
const env = { ...process.env, ...childVariables(plan.agent.type, plan.depth, readonly, plan.rootStore, workspaceStateDir(plan.workspace)) };
|
|
225
220
|
const transcript = plan.kind === 'fresh' || readonly ? undefined : transcriptFor(subagentDir(plan.workspace), plan.agentId);
|
|
226
221
|
const restoreTools = transcript === undefined ? [] : readonlyLaunchRestores(transcript);
|
|
227
222
|
const args = childArgs({ sessionDir: subagentDir(plan.workspace), agentId: plan.agentId, readonly, restoreTools, trusted: plan.trusted, ...(plan.model === undefined ? {} : { model: plan.model }) });
|
|
228
|
-
const input = withAttachments(prompt, plan.attachments);
|
|
229
223
|
return startChild({ command: piCommand(), args, prompt: input.text, images: input.images, cwd: plan.worktree?.cwd ?? plan.workspace, env, ...(signal === undefined ? {} : { signal }) });
|
|
230
224
|
}
|
|
231
225
|
|
|
@@ -256,10 +250,11 @@ function recordResume(plan: Launch): void {
|
|
|
256
250
|
* Starts the child, then points the run lock at it and records the agent. A child that never started (no pid) has no
|
|
257
251
|
* transcript and changes no record, so a new agent's ID never becomes resumable; a fork's copied transcript is removed with it.
|
|
258
252
|
*/
|
|
259
|
-
function start(plan: Launch,
|
|
253
|
+
function start(plan: Launch, rawInput: ChildInput, signal: AbortSignal | undefined, lock: RunLock): ChildHandle {
|
|
260
254
|
let handle: ChildHandle | undefined;
|
|
261
255
|
try {
|
|
262
|
-
|
|
256
|
+
const input = plan.kind === 'fresh' && plan.worktree !== undefined ? { ...rawInput, text: worktreePreface(plan.worktree, rawInput.text) } : rawInput;
|
|
257
|
+
handle = launchChild(plan, input, signal);
|
|
263
258
|
if (handle.pid !== undefined) {
|
|
264
259
|
lock.holdFor(handle.pid);
|
|
265
260
|
if (plan.kind === 'resume') recordResume(plan);
|
|
@@ -387,7 +382,7 @@ async function steerRunning(params: TaskInput, running: { handle: ChildHandle; r
|
|
|
387
382
|
const agentId = params.resume ?? '';
|
|
388
383
|
if ((params.readonly === true || planMode) && !running.readonly)
|
|
389
384
|
throw new Error(`Agent ${agentId} is running with write access, so a readonly call cannot message it${planMode ? ' (plan mode makes every Task call readonly)' : ''}. Wait for its completion notification, or stop it with stop_agent.`);
|
|
390
|
-
const input = withAttachments(params.prompt, resolveAttachments(params.file_attachments ?? [], ctx.cwd));
|
|
385
|
+
const input = await withAttachments(params.prompt, resolveAttachments(params.file_attachments ?? [], ctx.cwd));
|
|
391
386
|
await running.handle.steer(input.text, input.images).catch((error: Error) => {
|
|
392
387
|
throw new Error(`Agent ${agentId} finished before the message reached it (${error.message}). Resume it once its result arrives.`);
|
|
393
388
|
});
|
|
@@ -427,6 +422,7 @@ export interface TaskHandle {
|
|
|
427
422
|
|
|
428
423
|
export function registerTask(pi: ExtensionAPI, options: TaskToolOptions): TaskHandle {
|
|
429
424
|
const registry = createSubagentRegistry(pi);
|
|
425
|
+
pi.registerMessageRenderer?.(NOTIFICATION_TYPE, (message, renderOptions) => renderTaskNotification(message, renderOptions));
|
|
430
426
|
let sessionCtx: ExtensionContext | undefined;
|
|
431
427
|
pi.on('agent_before_settle', (event, ctx) => registry.beforeSettle(ctx, event.outcome));
|
|
432
428
|
pi.on('session_shutdown', (event, ctx) => {
|
|
@@ -458,11 +454,12 @@ export function registerTask(pi: ExtensionAPI, options: TaskToolOptions): TaskHa
|
|
|
458
454
|
taskPreflight(params, options.depth);
|
|
459
455
|
const running = params.resume === undefined ? undefined : registry.get(validateAgentId(params.resume));
|
|
460
456
|
if (running !== undefined) return steerRunning(params, running, ctx, options.planMode?.() === true);
|
|
457
|
+
const attachments = resolveAttachments(params.file_attachments ?? [], ctx.cwd);
|
|
458
|
+
const input = attachments.length === 0 ? { text: params.prompt.trim(), images: [] } : await withAttachments(params.prompt, attachments);
|
|
461
459
|
const { plan, lock } = prepare(params, ctx, options, registry.isRunning);
|
|
462
|
-
// Awaiting yields to a parallel Task call, so only a fresh isolated agent awaits before it is tracked.
|
|
463
460
|
if (plan.kind === 'fresh' && plan.worktree !== undefined) await createWorktree(plan, lock, signal);
|
|
464
461
|
const background = params.run_in_background ?? plan.agent.background;
|
|
465
|
-
const handle = start(plan,
|
|
462
|
+
const handle = start(plan, input, background ? undefined : signal, lock);
|
|
466
463
|
if (background) {
|
|
467
464
|
registry.track(plan.agentId, handle, lock.release, runsReadonly(plan), backgroundNotice(plan, params.prompt, ctx, toolCallId));
|
|
468
465
|
return { content: [{ type: 'text', text: backgroundText(plan.agentId, transcriptOf(plan), plan.notes) }], details: details(plan, true, 'running', handle.pid) };
|
|
@@ -482,7 +479,7 @@ export function registerTask(pi: ExtensionAPI, options: TaskToolOptions): TaskHa
|
|
|
482
479
|
taskPreflight(params, options.depth);
|
|
483
480
|
const { plan, lock } = prepare(params, ctx, options, registry.isRunning);
|
|
484
481
|
await createWorktree(plan, lock, undefined);
|
|
485
|
-
const handle = start(plan, prompt, undefined, lock);
|
|
482
|
+
const handle = start(plan, { text: prompt.trim(), images: [] }, undefined, lock);
|
|
486
483
|
registry.track(plan.agentId, handle, lock.release, runsReadonly(plan), backgroundNotice(plan, prompt, ctx, `pstack-${plan.agentId}`));
|
|
487
484
|
return plan.agentId;
|
|
488
485
|
}
|
package/src/todo.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { StringEnum } from '@earendil-works/pi-ai';
|
|
2
1
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
3
2
|
import { Type } from 'typebox';
|
|
4
3
|
|
|
@@ -30,7 +29,7 @@ const TodoParams = Type.Object({
|
|
|
30
29
|
Type.Object({
|
|
31
30
|
id: Type.String({ description: 'Unique identifier for the TODO item' }),
|
|
32
31
|
content: Type.Optional(Type.String({ description: 'The description/content of the TODO item. Required for a new item; may be omitted when merging an update.' })),
|
|
33
|
-
status: Type.Optional(
|
|
32
|
+
status: Type.Optional(Type.Enum(STATUSES, { type: 'string', description: 'The current status of the TODO item. Required for a new item; may be omitted when merging an update.' })),
|
|
34
33
|
}),
|
|
35
34
|
{ minItems: 1, description: 'Array of TODO items to update or create' },
|
|
36
35
|
),
|
package/scripts/live-e2e.mjs
DELETED
|
@@ -1,257 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Runs pstack scenarios against a real model through the Pi CLI and records what the model did.
|
|
3
|
-
// Usage: live-e2e.mjs [--model provider/id] [--scenario poteto|delegate|nocomments|setup|all] [--out <dir>] [--tag <name>]
|
|
4
|
-
// [--rule <file>] [--providers claude-subscription]
|
|
5
|
-
// --rule copies a pstack-models.md into the throwaway agent directory first, for example to bound subagent cost.
|
|
6
|
-
// --providers loads extra provider packages so their models are available without driving the run.
|
|
7
|
-
// It needs credentials in the user's Pi auth.json. Each scenario uses a throwaway workspace, HOME, and agent
|
|
8
|
-
// directory, and auth.json is symlinked rather than copied. Pi writes refreshed OAuth tokens through that symlink,
|
|
9
|
-
// so an OAuth refresh during a live run updates the user's real auth.json, and its lock file sits beside the
|
|
10
|
-
// symlink rather than the real file; avoid running OAuth-provider scenarios while another Pi session refreshes.
|
|
11
|
-
// The model under test has bash and write tools, so it could read or overwrite the real auth.json through that
|
|
12
|
-
// symlink. Run it only with models and prompts you trust, and review the recorded tool calls in the evidence file.
|
|
13
|
-
import { execFileSync, spawn } from 'node:child_process';
|
|
14
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
15
|
-
import { homedir, tmpdir } from 'node:os';
|
|
16
|
-
import { dirname, join, resolve } from 'node:path';
|
|
17
|
-
import { fileURLToPath } from 'node:url';
|
|
18
|
-
|
|
19
|
-
import { resolveSubagentModel } from '../src/task/models.ts';
|
|
20
|
-
|
|
21
|
-
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
-
const repoRoot = resolve(packageRoot, '../..');
|
|
23
|
-
const cli = join(packageRoot, 'node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js');
|
|
24
|
-
const userAgentDir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi/agent');
|
|
25
|
-
const ROLES = [
|
|
26
|
-
'feature, refactoring',
|
|
27
|
-
'bug-fix',
|
|
28
|
-
'perf-issue',
|
|
29
|
-
'hillclimb',
|
|
30
|
-
'judgment and prose',
|
|
31
|
-
'hardest tasks',
|
|
32
|
-
'how explorer',
|
|
33
|
-
'how explainer',
|
|
34
|
-
'why investigators',
|
|
35
|
-
'why synthesizer',
|
|
36
|
-
'reflect tooling',
|
|
37
|
-
'reflect judgment, divergent, synthesizer',
|
|
38
|
-
'arena runners',
|
|
39
|
-
'arena cross-judge pool',
|
|
40
|
-
'swarm workers',
|
|
41
|
-
'architect runners',
|
|
42
|
-
'interrogate reviewers',
|
|
43
|
-
];
|
|
44
|
-
|
|
45
|
-
const PROVIDER_PACKAGES = { 'claude-subscription': 'extensions/pi-anthropic-oauth' };
|
|
46
|
-
|
|
47
|
-
function sandbox(model, ruleFile, providers) {
|
|
48
|
-
const root = realpathSync(mkdtempSync(join(tmpdir(), 'pstack-live-')));
|
|
49
|
-
const agentDir = join(root, 'agent');
|
|
50
|
-
const work = join(root, 'work');
|
|
51
|
-
mkdirSync(agentDir);
|
|
52
|
-
mkdirSync(work);
|
|
53
|
-
mkdirSync(join(root, 'home'));
|
|
54
|
-
symlinkSync(join(userAgentDir, 'auth.json'), join(agentDir, 'auth.json'));
|
|
55
|
-
// Only the driver's provider loads, so default role models such as claude-opus-5-5-xhigh cannot bill a subscription unasked.
|
|
56
|
-
const wanted = new Set([model.split('/')[0], ...providers]);
|
|
57
|
-
const packages = [
|
|
58
|
-
packageRoot,
|
|
59
|
-
...Object.entries(PROVIDER_PACKAGES)
|
|
60
|
-
.filter(([provider]) => wanted.has(provider))
|
|
61
|
-
.map(([, path]) => join(repoRoot, path)),
|
|
62
|
-
];
|
|
63
|
-
writeFileSync(join(agentDir, 'settings.json'), JSON.stringify({ packages, quietStartup: true, defaultProvider: model.split('/')[0], defaultModel: model.split('/')[1] }));
|
|
64
|
-
if (ruleFile !== undefined) {
|
|
65
|
-
mkdirSync(join(agentDir, 'pstack'));
|
|
66
|
-
writeFileSync(join(agentDir, 'pstack/pstack-models.md'), readFileSync(ruleFile));
|
|
67
|
-
}
|
|
68
|
-
const git = (...args) => execFileSync('git', ['-C', work, ...args], { stdio: 'ignore' });
|
|
69
|
-
git('init', '-q', '-b', 'main');
|
|
70
|
-
return { root, agentDir, work, git, env: { ...process.env, HOME: join(root, 'home'), PI_CODING_AGENT_DIR: agentDir } };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function commitAll(box, message) {
|
|
74
|
-
box.git('add', '-A');
|
|
75
|
-
box.git('-c', 'user.email=live@example.com', '-c', 'user.name=live', 'commit', '-qm', message);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function runPi(box, model, prompt, timeoutMs) {
|
|
79
|
-
return new Promise((resolveRun) => {
|
|
80
|
-
const started = Date.now();
|
|
81
|
-
const child = spawn(process.execPath, [cli, '--mode', 'json', '--no-session', '--model', model, prompt], { cwd: box.work, env: box.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
82
|
-
const events = [];
|
|
83
|
-
let buffer = '';
|
|
84
|
-
let stderr = '';
|
|
85
|
-
const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs);
|
|
86
|
-
child.stdout.on('data', (chunk) => {
|
|
87
|
-
buffer += chunk.toString('utf8');
|
|
88
|
-
for (let index = buffer.indexOf('\n'); index !== -1; index = buffer.indexOf('\n')) {
|
|
89
|
-
const line = buffer.slice(0, index).trim();
|
|
90
|
-
buffer = buffer.slice(index + 1);
|
|
91
|
-
if (line !== '') events.push(JSON.parse(line));
|
|
92
|
-
}
|
|
93
|
-
});
|
|
94
|
-
child.stderr.on('data', (chunk) => {
|
|
95
|
-
stderr += chunk.toString('utf8');
|
|
96
|
-
});
|
|
97
|
-
child.on('close', (code) => {
|
|
98
|
-
clearTimeout(timer);
|
|
99
|
-
resolveRun({ events, code, stderr: stderr.slice(-4000), seconds: Math.round((Date.now() - started) / 1000) });
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function toolCalls(events) {
|
|
105
|
-
return events
|
|
106
|
-
.filter((event) => event.type === 'message_end' && event.message?.role === 'assistant')
|
|
107
|
-
.flatMap((event) => event.message.content.filter((part) => part.type === 'toolCall').map((part) => ({ name: part.name, arguments: part.arguments })));
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function finalText(events) {
|
|
111
|
-
const last = events.filter((event) => event.type === 'message_end' && event.message?.role === 'assistant').at(-1);
|
|
112
|
-
return (last?.message.content ?? [])
|
|
113
|
-
.filter((part) => part.type === 'text')
|
|
114
|
-
.map((part) => part.text)
|
|
115
|
-
.join('')
|
|
116
|
-
.slice(0, 3000);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const playbookSteps = (name) =>
|
|
120
|
-
readFileSync(join(packageRoot, `skills/poteto-mode/playbooks/${name}.md`), 'utf8')
|
|
121
|
-
.split('\n')
|
|
122
|
-
.filter((line) => /^\d+\. /.test(line))
|
|
123
|
-
.map((line) => line.replace(/^\d+\. /, ''));
|
|
124
|
-
|
|
125
|
-
const SCENARIOS = {
|
|
126
|
-
poteto: {
|
|
127
|
-
requirement: 'LIVE-001',
|
|
128
|
-
prompt: '/poteto-mode How does add in calc.js treat two numeric strings such as "2" and "3"? This is a read-only question; do not change any files.',
|
|
129
|
-
prepare: (box) => {
|
|
130
|
-
writeFileSync(join(box.work, 'calc.js'), 'export function add(a, b) {\n return a + b;\n}\n');
|
|
131
|
-
commitAll(box, 'calc');
|
|
132
|
-
},
|
|
133
|
-
check: (calls, box) => {
|
|
134
|
-
const steps = playbookSteps('investigation');
|
|
135
|
-
const playbook = 'poteto-mode/playbooks/investigation.md';
|
|
136
|
-
const readPlaybook = calls.some((call) => (call.name === 'read' && String(call.arguments.path ?? '').endsWith(playbook)) || (call.name === 'bash' && String(call.arguments.command ?? '').includes(playbook)));
|
|
137
|
-
const todos = calls.find((call) => call.name === 'TodoWrite')?.arguments.todos ?? [];
|
|
138
|
-
// A todo list is plain text, so Markdown emphasis marks (** and backticks) are dropped before comparing.
|
|
139
|
-
const plain = (text) => text.replace(/\*\*|`/g, '');
|
|
140
|
-
const verbatim = steps.map((step, index) => typeof todos[index]?.content === 'string' && plain(todos[index].content) === plain(step));
|
|
141
|
-
const clean = execFileSync('git', ['-C', box.work, 'status', '--porcelain'], { encoding: 'utf8' }).trim() === '';
|
|
142
|
-
return { readPlaybook, firstTodosAreStepsVerbatim: verbatim.every(Boolean), stepMatches: verbatim, workspaceUnchanged: clean, todos: todos.map((todo) => todo.content) };
|
|
143
|
-
},
|
|
144
|
-
},
|
|
145
|
-
delegate: {
|
|
146
|
-
requirement: 'LIVE-004',
|
|
147
|
-
prompt: '/poteto-mode Delegate to one foreground poteto-agent: find what calc.js exports. Read-only.',
|
|
148
|
-
prepare: (box) => {
|
|
149
|
-
writeFileSync(join(box.work, 'calc.js'), 'export const add = (a, b) => a + b;\n');
|
|
150
|
-
commitAll(box, 'calc');
|
|
151
|
-
},
|
|
152
|
-
check: (calls) => {
|
|
153
|
-
// pstack 0.15.21: the parent puts poteto-mode's absolute SKILL.md path in every poteto-agent prompt.
|
|
154
|
-
const skillPath = join(packageRoot, 'skills/poteto-mode/SKILL.md');
|
|
155
|
-
const delegates = calls.filter((call) => call.name === 'Task' && call.arguments.subagent_type === 'poteto-agent');
|
|
156
|
-
return {
|
|
157
|
-
delegated: delegates.length > 0,
|
|
158
|
-
everyPromptHasSkillPath: delegates.length > 0 && delegates.every((call) => String(call.arguments.prompt ?? '').includes(skillPath)),
|
|
159
|
-
prompts: delegates.map((call) => String(call.arguments.prompt ?? '').slice(0, 400)),
|
|
160
|
-
};
|
|
161
|
-
},
|
|
162
|
-
},
|
|
163
|
-
nocomments: {
|
|
164
|
-
requirement: 'LIVE-002',
|
|
165
|
-
prompt: '/no-comments',
|
|
166
|
-
prepare: (box) => {
|
|
167
|
-
writeFileSync(join(box.work, 'util.js'), 'export function add(a, b) {\n return a + b;\n}\n');
|
|
168
|
-
commitAll(box, 'util');
|
|
169
|
-
writeFileSync(join(box.work, 'util.js'), '// This function adds two numbers together\nexport function add(a, b) {\n // return the sum of a and b\n return a + b;\n}\n');
|
|
170
|
-
},
|
|
171
|
-
check: (calls) => {
|
|
172
|
-
const tasks = calls.filter((call) => call.name === 'Task');
|
|
173
|
-
return { spawnedCommentSicko: tasks.some((call) => call.arguments.subagent_type === 'comment-sicko'), taskTypes: tasks.map((call) => call.arguments.subagent_type ?? '(default)') };
|
|
174
|
-
},
|
|
175
|
-
},
|
|
176
|
-
setup: {
|
|
177
|
-
requirement: 'LIVE-003',
|
|
178
|
-
prompt:
|
|
179
|
-
"/setup-pstack Use the large budget and keep the skill's mapping. For a role whose default model is not available, use the closest available model of the same family, else inherit-parent. Do not ask me anything; write the rule now.",
|
|
180
|
-
prepare: () => undefined,
|
|
181
|
-
check: (_calls, box, model) => {
|
|
182
|
-
const rule = join(box.agentDir, 'pstack/pstack-models.md');
|
|
183
|
-
const text = existsSync(rule) ? readFileSync(rule, 'utf8') : '';
|
|
184
|
-
const missing = ROLES.filter((role) => !text.split('\n').some((line) => line.startsWith(`${role}:`)));
|
|
185
|
-
const values = text
|
|
186
|
-
.split('\n')
|
|
187
|
-
.filter((line) => ROLES.some((role) => line.startsWith(`${role}:`)))
|
|
188
|
-
.flatMap((line) => line.slice(line.indexOf(':') + 1).split(','))
|
|
189
|
-
.map((value) => value.trim());
|
|
190
|
-
const available = listModels(box);
|
|
191
|
-
const parent = { provider: model.split('/')[0], id: model.split('/')[1] };
|
|
192
|
-
const unresolved = values.filter((value) => {
|
|
193
|
-
try {
|
|
194
|
-
resolveSubagentModel(value, available, parent);
|
|
195
|
-
return false;
|
|
196
|
-
} catch {
|
|
197
|
-
return true;
|
|
198
|
-
}
|
|
199
|
-
});
|
|
200
|
-
return {
|
|
201
|
-
ruleWritten: text !== '',
|
|
202
|
-
noFrontmatter: !text.startsWith('---'),
|
|
203
|
-
missingRoles: missing,
|
|
204
|
-
distinctValues: [...new Set(values)],
|
|
205
|
-
unresolvedValues: unresolved,
|
|
206
|
-
rule: text,
|
|
207
|
-
};
|
|
208
|
-
},
|
|
209
|
-
},
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
/** The models the throwaway agent directory exposes, with the thinking levels Pi supports for each, as Task sees them. */
|
|
213
|
-
function listModels(box) {
|
|
214
|
-
const fixture = join(packageRoot, 'test/fixtures/list-models.ts');
|
|
215
|
-
const output = execFileSync(process.execPath, [cli, '-p', '--offline', '--no-session', '-e', fixture, '/list-models'], { cwd: box.work, env: box.env, encoding: 'utf8' });
|
|
216
|
-
return output
|
|
217
|
-
.split('\n')
|
|
218
|
-
.filter((line) => line.startsWith('{'))
|
|
219
|
-
.map((line) => JSON.parse(line));
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
async function runScenario(name, model, outDir, tag, ruleFile, providers) {
|
|
223
|
-
const scenario = SCENARIOS[name];
|
|
224
|
-
const box = sandbox(model, ruleFile, providers);
|
|
225
|
-
try {
|
|
226
|
-
scenario.prepare(box);
|
|
227
|
-
const run = await runPi(box, model, scenario.prompt, 15 * 60_000);
|
|
228
|
-
const calls = toolCalls(run.events);
|
|
229
|
-
const result = {
|
|
230
|
-
scenario: name,
|
|
231
|
-
requirement: scenario.requirement,
|
|
232
|
-
model,
|
|
233
|
-
prompt: scenario.prompt,
|
|
234
|
-
exitCode: run.code,
|
|
235
|
-
seconds: run.seconds,
|
|
236
|
-
checks: scenario.check(calls, box, model),
|
|
237
|
-
toolCalls: calls.map((call) => ({ name: call.name, arguments: JSON.stringify(call.arguments).slice(0, 600) })),
|
|
238
|
-
finalText: finalText(run.events),
|
|
239
|
-
stderrTail: run.stderr,
|
|
240
|
-
};
|
|
241
|
-
writeFileSync(join(outDir, `live-${name}${tag === undefined ? '' : `-${tag}`}.json`), `${JSON.stringify(result, null, 2)}\n`);
|
|
242
|
-
process.stdout.write(`${name}: ${JSON.stringify(result.checks)}\n`);
|
|
243
|
-
} finally {
|
|
244
|
-
rmSync(box.root, { recursive: true, force: true });
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const argv = process.argv.slice(2);
|
|
249
|
-
const option = (name, fallback) => (argv.includes(name) ? argv[argv.indexOf(name) + 1] : fallback);
|
|
250
|
-
const model = option('--model', 'deepseek/deepseek-flash');
|
|
251
|
-
const outDir = resolve(option('--out', join(repoRoot, 'parity/pi-pstack-two/evidence')));
|
|
252
|
-
const chosen = option('--scenario', 'all');
|
|
253
|
-
const tag = option('--tag', undefined);
|
|
254
|
-
const ruleFile = argv.includes('--rule') ? resolve(option('--rule', '')) : undefined;
|
|
255
|
-
const providers = option('--providers', '').split(',').filter(Boolean);
|
|
256
|
-
mkdirSync(outDir, { recursive: true });
|
|
257
|
-
for (const name of chosen === 'all' ? Object.keys(SCENARIOS) : [chosen]) await runScenario(name, model, outDir, tag, ruleFile, providers);
|
package/test/aliases.test.ts
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import { join } from 'node:path';
|
|
2
|
-
|
|
3
|
-
import { describe, expect, test } from 'vitest';
|
|
4
|
-
import { aliasInputResult, aliasTable, aliasTransform, packageSkillNames } from '../src/aliases.ts';
|
|
5
|
-
import { SKILLS_DIR } from '../src/paths.ts';
|
|
6
|
-
|
|
7
|
-
const names = new Set(['how', 'poteto-mode', 'principle-prove-it-works']);
|
|
8
|
-
|
|
9
|
-
describe('aliasTransform', () => {
|
|
10
|
-
test.for([
|
|
11
|
-
{ input: '/how', expected: '/skill:how' },
|
|
12
|
-
{ input: '/how does the cache work?', expected: '/skill:how does the cache work?' },
|
|
13
|
-
{ input: '/poteto-mode fix the "drift" bug\nrepro first, then $1 and $@', expected: '/skill:poteto-mode fix the "drift" bug\nrepro first, then $1 and $@' },
|
|
14
|
-
{ input: '/how\nwhat owns retries?', expected: '/skill:how \nwhat owns retries?' },
|
|
15
|
-
{ input: '/principle-prove-it-works', expected: '/skill:principle-prove-it-works' },
|
|
16
|
-
])('rewrites $input to a native skill command (CMD-001)', ({ input, expected }) => {
|
|
17
|
-
expect(aliasTransform(input, names)).toBe(expected);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
test.for([
|
|
21
|
-
{ input: '/skill:how what', why: 'already native' },
|
|
22
|
-
{ input: '/model', why: 'a Pi built-in' },
|
|
23
|
-
{ input: '/howdy partner', why: 'a longer word that only starts with a skill name' },
|
|
24
|
-
{ input: '/unknown thing', why: 'not a shipped skill' },
|
|
25
|
-
{ input: 'please /how this', why: 'not at the start' },
|
|
26
|
-
{ input: ' /how leading space', why: 'Pi commands start at column zero' },
|
|
27
|
-
{ input: '', why: 'empty input' },
|
|
28
|
-
])('leaves $why input alone (CMD-002)', ({ input }) => {
|
|
29
|
-
expect(aliasInputResult({ text: input }, names)).toEqual({ action: 'continue' });
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test('the input handler transforms an alias and keeps attached images (CMD-001)', () => {
|
|
33
|
-
const images = [{ type: 'image' as const, data: 'AAAA', mimeType: 'image/png' }];
|
|
34
|
-
expect(aliasInputResult({ text: '/how this screen?', images }, names)).toEqual({ action: 'transform', text: '/skill:how this screen?', images });
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
describe('aliasTable (CMD-002, CMD-003; review: architecture 6)', () => {
|
|
39
|
-
const skill = (name: string, path: string) => ({ name: `skill:${name}`, description: `${name} skill`, source: 'skill' as const, sourceInfo: { path, source: 'test', scope: 'user' as const, origin: 'package' as const } });
|
|
40
|
-
const commands = [
|
|
41
|
-
skill('how', join(SKILLS_DIR, 'how/SKILL.md')),
|
|
42
|
-
skill('why', join(SKILLS_DIR, 'why/SKILL.md')),
|
|
43
|
-
skill('mine', '/home/me/.pi/agent/skills/mine/SKILL.md'),
|
|
44
|
-
{ name: 'why', description: 'user template', source: 'prompt' as const, sourceInfo: { path: '/home/me/.pi/agent/prompts/why.md', source: 'test', scope: 'user' as const, origin: 'top-level' as const } },
|
|
45
|
-
];
|
|
46
|
-
|
|
47
|
-
test('aliases every loaded skill that no template or command already names, as Cursor attaches any skill by /name (review r2: parity 1)', () => {
|
|
48
|
-
expect([...aliasTable(commands)]).toEqual([
|
|
49
|
-
['how', 'how skill'],
|
|
50
|
-
['mine', 'mine skill'],
|
|
51
|
-
]);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test('lists the loaded skills that live in this package, by canonical path (A4, recheck 1)', () => {
|
|
55
|
-
expect(packageSkillNames(commands)).toEqual(['how', 'why']);
|
|
56
|
-
});
|
|
57
|
-
});
|
package/test/ask.test.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { dirname, join } from 'node:path';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
|
|
5
|
-
import type { Static } from 'typebox';
|
|
6
|
-
import { Value } from 'typebox/value';
|
|
7
|
-
import { describe, expect, test } from 'vitest';
|
|
8
|
-
import { AskParams, DONE_LABEL, validateQuestions } from '../src/ask.ts';
|
|
9
|
-
|
|
10
|
-
const question = (options: { id: string; label: string }[], allow_multiple?: boolean) => ({ id: 'q', prompt: 'Pick', options, ...(allow_multiple === undefined ? {} : { allow_multiple }) });
|
|
11
|
-
const outcome = (questions: ReturnType<typeof question>[]) => {
|
|
12
|
-
try {
|
|
13
|
-
validateQuestions(questions);
|
|
14
|
-
return 'valid';
|
|
15
|
-
} catch (error) {
|
|
16
|
-
return (error as Error).message;
|
|
17
|
-
}
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
describe('validateQuestions (HOST-005; reviews: parity, security 6, architecture 4)', () => {
|
|
21
|
-
test.for([
|
|
22
|
-
{
|
|
23
|
-
name: 'duplicate labels',
|
|
24
|
-
questions: [
|
|
25
|
-
question([
|
|
26
|
-
{ id: 'a', label: 'Same' },
|
|
27
|
-
{ id: 'b', label: 'Same' },
|
|
28
|
-
]),
|
|
29
|
-
],
|
|
30
|
-
expected: 'Question "q" has two options labelled "Same".',
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
name: 'duplicate option ids',
|
|
34
|
-
questions: [
|
|
35
|
-
question([
|
|
36
|
-
{ id: 'a', label: 'A' },
|
|
37
|
-
{ id: 'a', label: 'B' },
|
|
38
|
-
]),
|
|
39
|
-
],
|
|
40
|
-
expected: 'Question "q" has two options with id "a".',
|
|
41
|
-
},
|
|
42
|
-
{ name: 'no options', questions: [question([])], expected: 'Question "q" has no options.' },
|
|
43
|
-
{ name: 'the reserved other id', questions: [question([{ id: 'other', label: 'Something else' }])], expected: 'Option label "Other: (type to answer)" and id "other" are reserved for the typed answer.' },
|
|
44
|
-
{ name: 'the reserved done label', questions: [question([{ id: 'd', label: DONE_LABEL }], true)], expected: `Option label "${DONE_LABEL}" is reserved for ending a multi-select question.` },
|
|
45
|
-
{
|
|
46
|
-
name: 'a well-formed question',
|
|
47
|
-
questions: [
|
|
48
|
-
question(
|
|
49
|
-
[
|
|
50
|
-
{ id: 'a', label: 'Done' },
|
|
51
|
-
{ id: 'b', label: 'B' },
|
|
52
|
-
],
|
|
53
|
-
true,
|
|
54
|
-
),
|
|
55
|
-
],
|
|
56
|
-
expected: 'valid',
|
|
57
|
-
},
|
|
58
|
-
])('$name', ({ questions, expected }) => {
|
|
59
|
-
expect(outcome(questions)).toBe(expected);
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe('Cursor AskQuestion calls (HOST-005)', () => {
|
|
64
|
-
// 24 AskQuestion inputs the Cursor IDE produced, read from its agent transcripts.
|
|
65
|
-
const fixture = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'fixtures/cursor-askquestion-calls.json'), 'utf8')) as { calls: { input: Static<typeof AskParams> }[] };
|
|
66
|
-
const questionSchema = AskParams.properties.questions.items;
|
|
67
|
-
const optionSchema = questionSchema.properties.options.items;
|
|
68
|
-
|
|
69
|
-
test('every field Cursor sends is declared by the port schema', () => {
|
|
70
|
-
const undeclared = fixture.calls.flatMap(({ input }) => [
|
|
71
|
-
...Object.keys(input).filter((key) => !(key in AskParams.properties)),
|
|
72
|
-
...input.questions.flatMap((question) => [
|
|
73
|
-
...Object.keys(question).filter((key) => !(key in questionSchema.properties)),
|
|
74
|
-
...question.options.flatMap((option) => Object.keys(option).filter((key) => !(key in optionSchema.properties))),
|
|
75
|
-
]),
|
|
76
|
-
]);
|
|
77
|
-
expect(fixture.calls.length).toBe(24);
|
|
78
|
-
expect(undeclared).toEqual([]);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test('every Cursor call is valid input for the port', () => {
|
|
82
|
-
expect(fixture.calls.filter(({ input }) => !Value.Check(AskParams, input))).toEqual([]);
|
|
83
|
-
expect(fixture.calls.map(({ input }) => outcome(input.questions as ReturnType<typeof question>[]))).toEqual(fixture.calls.map(() => 'valid'));
|
|
84
|
-
});
|
|
85
|
-
});
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import type { AutocompleteItem, AutocompleteProvider } from '@earendil-works/pi-tui';
|
|
2
|
-
import { describe, expect, test } from 'vitest';
|
|
3
|
-
import { aliasAutocomplete } from '../src/autocomplete.ts';
|
|
4
|
-
|
|
5
|
-
const builtin: AutocompleteItem[] = [
|
|
6
|
-
{ value: 'skill:poteto-mode', label: 'skill:poteto-mode' },
|
|
7
|
-
{ value: 'model', label: 'model' },
|
|
8
|
-
];
|
|
9
|
-
const current: AutocompleteProvider = {
|
|
10
|
-
getSuggestions: async (lines, _line, col) => ((lines[0] ?? '').slice(0, col).startsWith('/') ? { items: builtin, prefix: (lines[0] ?? '').slice(0, col) } : null),
|
|
11
|
-
applyCompletion: (lines, cursorLine, _col, item, prefix) => ({ lines: [`/${item.value} `, ...lines.slice(1)], cursorLine, cursorCol: prefix.length }),
|
|
12
|
-
};
|
|
13
|
-
const aliases = new Map([
|
|
14
|
-
['poteto-mode', "poteto's agent style"],
|
|
15
|
-
['poteto-help', 'Guides users through pstack'],
|
|
16
|
-
['how', 'Explains how code works'],
|
|
17
|
-
]);
|
|
18
|
-
const signal = new AbortController().signal;
|
|
19
|
-
|
|
20
|
-
describe('alias autocomplete (CMD-003)', () => {
|
|
21
|
-
test('lists matching pstack skills under their Cursor names ahead of the built-in suggestions', async () => {
|
|
22
|
-
const provider = aliasAutocomplete(current, aliases);
|
|
23
|
-
expect(await provider.getSuggestions(['/pot'], 0, 4, { signal })).toEqual({
|
|
24
|
-
prefix: '/pot',
|
|
25
|
-
items: [{ value: 'poteto-mode', label: 'poteto-mode', description: "poteto's agent style" }, { value: 'poteto-help', label: 'poteto-help', description: 'Guides users through pstack' }, ...builtin],
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test('leaves completions alone outside a leading command token', async () => {
|
|
30
|
-
const provider = aliasAutocomplete(current, aliases);
|
|
31
|
-
expect(await provider.getSuggestions(['/how why'], 0, 8, { signal })).toEqual({ items: builtin, prefix: '/how why' });
|
|
32
|
-
expect(await provider.getSuggestions(['say /pot'], 0, 8, { signal })).toEqual(null);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test('applies an alias like any command completion', () => {
|
|
36
|
-
const provider = aliasAutocomplete(current, aliases);
|
|
37
|
-
expect(provider.applyCompletion(['/pot'], 0, 4, { value: 'poteto-mode', label: 'poteto-mode' }, '/pot')).toEqual({ lines: ['/poteto-mode '], cursorLine: 0, cursorCol: 4 });
|
|
38
|
-
});
|
|
39
|
-
});
|