Compare commits

..

5 Commits

Author SHA1 Message Date
Michal
3fa41e4d46 Merge main into feat/pi-extension
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m11s
CI/CD / lint (pull_request) Successful in 2m28s
CI/CD / test (pull_request) Successful in 1m24s
CI/CD / smoke (pull_request) Failing after 1m55s
CI/CD / build (pull_request) Successful in 4m45s
CI/CD / publish (pull_request) Has been skipped
2026-08-08 19:59:25 +01:00
Michal
79e04ed89f Merge 'refactor(prime-agent): tray status only' into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m4s
CI/CD / lint (push) Successful in 2m6s
CI/CD / test (push) Successful in 1m34s
CI/CD / build (push) Successful in 2m19s
CI/CD / smoke (push) Failing after 3m13s
CI/CD / publish (push) Has been skipped
2026-08-08 19:59:25 +01:00
Michal
90c49bcb22 refactor(prime-agent): drop the widget fallback, keep the tray status
With prime-agent-extension-status.patch in place the tray renders
ctx.ui.setStatus() next to the model name, which is what a status line should
be. The widget was a workaround for its absence and was never a substitute:
widgetContainerBelow sits in the fullscreen *scroll* list, not the dock, so it
scrolled away with the transcript, and with both set the project name appeared
twice.

The startup retries stay: resetExtensionUI() clears extension statuses just as
it cleared widgets, so the value set during session_start is still wiped before
it can be seen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 19:59:24 +01:00
Michal
cee27084ac Merge 'fix(prime-agent): indicator re-publish after widget reset' into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m4s
CI/CD / lint (push) Successful in 2m7s
CI/CD / test (push) Successful in 1m20s
CI/CD / build (push) Successful in 2m18s
CI/CD / smoke (push) Failing after 2m44s
CI/CD / publish (push) Has been skipped
2026-08-08 19:52:08 +01:00
Michal
0a29c2fd7f fix(prime-agent): re-publish the indicator after prime-agent clears extension widgets
Verified with a probe extension rather than by reading the bundle: session_start
fires with hasUI=true, ctx.ui.setWidget exists and the call returns without
throwing — and the widget still never appeared.

Cause is prime-agent wiping it immediately afterwards. resetExtensionUI() ->
clearExtensionWidgets() runs from onBeforeSessionInvalidate and from the
connection-state-snapshot handler, both of which land after session_start, so
the indicator was set and cleared before it could be seen. Nothing re-set it
until a turn, which is why a fresh session with no messages showed nothing.

Re-publishes at 1s/3s/6s after session_start to land past that reset.
setWidget is idempotent, so a redundant retry costs one re-render.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 19:52:07 +01:00
6 changed files with 19 additions and 136 deletions

View File

@@ -33,8 +33,7 @@
"mcpd:deploy": "bash deploy.sh",
"mcpd:deploy-dry": "bash deploy.sh --dry-run",
"mcpd:logs": "bash logs.sh",
"typecheck:pi-ext": "tsc -p src/pi-ext/tsconfig.json",
"smoke:clean": "tsx scripts/clean-smoke-resources.ts"
"typecheck:pi-ext": "tsc -p src/pi-ext/tsconfig.json"
},
"engines": {
"node": ">=20.0.0",

View File

@@ -1,101 +0,0 @@
#!/usr/bin/env node
/**
* Sweep per-run smoke-test resources off the shared mcpd.
*
* The suites clean up in `afterAll`, which covers assertion failures but not a
* killed process (CI timeout, Ctrl-C, OOM). Over time that leaked hundreds of
* `smoke-*` projects and never-expiring `smoke-*` mcptokens onto the shared
* server, to the point where the project picker was unusable.
*
* Run after a smoke run, or any time the server looks cluttered:
* pnpm smoke:clean # show what would go
* pnpm smoke:clean --yes # actually delete
*
* Only names matching SMOKE_PREFIX are ever considered, and the shared fixture
* (`smoke-data` / `smoke-aws-docs`, provisioned from
* tests/smoke/fixtures/smoke-data.yaml and depended on by five suites) is
* protected — deleting it mid-run would break a concurrent suite.
*/
import { execFileSync } from 'node:child_process';
const SMOKE_PREFIX = /^smoke-/;
/** Shared, intentionally long-lived fixture — never swept. */
const PROTECTED = new Set(['smoke-data', 'smoke-aws-docs']);
const APPLY = process.argv.includes('--yes') || process.argv.includes('-y');
interface Named { id?: string; name?: string; projectName?: string; status?: string }
function mcpctl(args: string[]): string {
return execFileSync('mcpctl', args, { encoding: 'utf-8', maxBuffer: 32 * 1024 * 1024 });
}
function list(resource: string, extra: string[] = []): Named[] {
try {
const out = mcpctl(['get', resource, '-o', 'json', ...extra]);
const parsed: unknown = JSON.parse(out || '[]');
return Array.isArray(parsed) ? (parsed as Named[]) : [];
} catch {
return [];
}
}
function sweepable(rows: Named[]): Named[] {
return rows.filter((r) => typeof r.name === 'string' && SMOKE_PREFIX.test(r.name) && !PROTECTED.has(r.name));
}
function remove(resource: string, row: Named, extra: string[] = []): boolean {
const name = row.name ?? row.id ?? '';
if (!SMOKE_PREFIX.test(name) || PROTECTED.has(name)) return false; // belt and braces
try {
mcpctl(['delete', resource, name, ...extra]);
return true;
} catch (err) {
process.stderr.write(` ! ${resource} ${name}: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}\n`);
return false;
}
}
const projects = sweepable(list('projects'));
// Tokens live on real projects too (the suites mint smoke-* tokens against
// mcpctl-development and sre), so they need sweeping independently of projects.
const tokens = sweepable(list('mcptoken'));
const servers = sweepable(list('servers'));
const llms = sweepable(list('llms'));
const agents = sweepable(list('agents'));
const groups: Array<[string, Named[], string[]]> = [
['project', projects, []],
['mcptoken', tokens, []],
['server', servers, []],
['llm', llms, []],
['agent', agents, []],
];
let total = 0;
for (const [, rows] of groups) total += rows.length;
if (total === 0) {
process.stdout.write('smoke:clean — nothing to sweep\n');
process.exit(0);
}
for (const [kind, rows] of groups) {
if (rows.length === 0) continue;
process.stdout.write(`${kind}: ${String(rows.length)}\n`);
for (const r of rows.slice(0, 5)) process.stdout.write(` ${r.name ?? ''}${r.projectName ? ` (in ${r.projectName})` : ''}\n`);
if (rows.length > 5) process.stdout.write(` … and ${String(rows.length - 5)} more\n`);
}
if (!APPLY) {
process.stdout.write(`\n${String(total)} resource(s) would be deleted. Re-run with --yes to apply.\n`);
process.exit(0);
}
let deleted = 0;
for (const [kind, rows, extra] of groups) {
for (const r of rows) if (remove(kind, r, extra)) deleted++;
}
process.stdout.write(`\nsmoke:clean — deleted ${String(deleted)}/${String(total)}\n`);
process.exit(deleted === total ? 0 : 1);

File diff suppressed because one or more lines are too long

View File

@@ -537,12 +537,12 @@ describe('config prime-agent', () => {
await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' });
const ext = readFileSync(join(tmpDir, 'extensions', 'mcpctl-switch.ts'), 'utf-8');
// prime-agent stores extension statuses but never renders them, so a
// setStatus-only indicator is invisible there. Widgets are rendered.
expect(ext).toContain('ctx.ui.setWidget(STATUS_KEY');
expect(ext).toContain("placement: 'belowEditor'");
// Still set the status: pi's footer does render it.
// Both hosts render statuses next to the model name (pi in its footer,
// prime-agent in the tray — the latter via prime-agent-extension-status.patch).
expect(ext).toContain('ctx.ui.setStatus(STATUS_KEY');
// The widget was a workaround for the unpatched tray; it scrolled away with
// the transcript, so it is not a status line and must not come back.
expect(ext).not.toContain('setWidget');
// prime-agent emits session_start only from reload(), never at startup, so
// a session_start-only indicator stays blank until the first switch.
expect(ext).toContain("pi.on('turn_start'");

View File

@@ -14,7 +14,7 @@
*
* Run with: pnpm test:smoke
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll } from 'vitest';
import http from 'node:http';
import https from 'node:https';
import { execSync } from 'node:child_process';
@@ -87,23 +87,9 @@ describe('mcptoken smoke', () => {
}
}, 20_000);
// Cleanup belongs in afterAll, not an `it()`: a failing assertion earlier in
// the file must not strand server-side projects. Deliberately NOT gated on
// `gatewayUp` — the project is created through mcpd, which can be reachable
// even when the gateway healthz probe is not, and deleting something that was
// never created is a harmless no-op.
//
// `delete project` takes no --force (only `create project` does); passing it
// made every one of these calls exit non-zero and silently do nothing, which
// is how hundreds of smoke-* projects accumulated on the shared mcpd.
afterAll(() => {
run(`delete project ${PROJECT_NAME}`);
run(`delete project ${OTHER_PROJECT}`);
});
it('creates the project and a project-scoped mcptoken', () => {
if (!gatewayUp) return;
run(`delete project ${PROJECT_NAME}`); // cleanup leftovers — best-effort
run(`delete project ${PROJECT_NAME} --force`); // cleanup leftovers — best-effort
const createProj = run(`create project ${PROJECT_NAME} --force`);
expect(createProj.code).toBe(0);
@@ -163,8 +149,10 @@ describe('mcptoken smoke', () => {
expect(report.error ?? '').toMatch(/401|revoked|Invalid token/i);
}, 20_000);
it('recorded a tool name for the gated catalogue', () => {
it('cleans up test fixtures', () => {
if (!gatewayUp) return;
run(`delete project ${PROJECT_NAME} --force`);
run(`delete project ${OTHER_PROJECT} --force`);
expect(knownToolName === undefined || typeof knownToolName === 'string').toBe(true);
});
});

View File

@@ -81,20 +81,17 @@ describe('project-llm-ref smoke', () => {
}
}, 30_000);
// Not gated on `mcpdUp`: if creation got far enough to leave a project
// behind, cleanup must still run. Deleting a non-existent project is a no-op.
// (`delete project` takes no --force — passing it made these calls fail
// silently and leak a project per run.)
afterAll(() => {
run(`delete project ${PROJ_OK}`);
run(`delete project ${PROJ_ORPHAN}`);
run(`delete project ${PROJ_NONE}`);
if (!mcpdUp) return;
run(`delete project ${PROJ_OK} --force`);
run(`delete project ${PROJ_ORPHAN} --force`);
run(`delete project ${PROJ_NONE} --force`);
run(`delete llm ${LLM_NAME}`);
});
it('project with --llm pointing at a registered Llm describes without warning', () => {
if (!mcpdUp) return;
run(`delete project ${PROJ_OK}`);
run(`delete project ${PROJ_OK} --force`);
const created = run(`create project ${PROJ_OK} --llm ${LLM_NAME}`);
expect(created.code, created.stderr || created.stdout).toBe(0);
@@ -107,7 +104,7 @@ describe('project-llm-ref smoke', () => {
it('project with --llm naming an unregistered Llm shows the warning line', () => {
if (!mcpdUp) return;
run(`delete project ${PROJ_ORPHAN}`);
run(`delete project ${PROJ_ORPHAN} --force`);
const created = run(`create project ${PROJ_ORPHAN} --llm claude-ghost-${SUFFIX}`);
expect(created.code, created.stderr || created.stdout).toBe(0);
@@ -120,7 +117,7 @@ describe('project-llm-ref smoke', () => {
it('project with --llm none treats it as an explicit disable (no warning)', () => {
if (!mcpdUp) return;
run(`delete project ${PROJ_NONE}`);
run(`delete project ${PROJ_NONE} --force`);
const created = run(`create project ${PROJ_NONE} --llm none`);
expect(created.code).toBe(0);