From 40a5e2353cdf9c1f899afea33ac477e29ee587a2 Mon Sep 17 00:00:00 2001 From: ch0udry Date: Thu, 18 Jun 2026 15:09:00 +0500 Subject: [PATCH] fix: activate agentos from link names --- README.md | 44 ++++- ...18-002-fix-agentos-link-activation-plan.md | 178 ++++++++++++++++++ index.ts | 169 +++++++++++++++-- src/agentos.ts | 33 +++- test/agentos.test.mjs | 8 +- test/index.test.mjs | 160 ++++++++++------ 6 files changed, 500 insertions(+), 92 deletions(-) create mode 100644 docs/plans/2026-06-18-002-fix-agentos-link-activation-plan.md diff --git a/README.md b/README.md index 64c7eb8..b53b9c1 100644 --- a/README.md +++ b/README.md @@ -96,13 +96,51 @@ Rules and checklists may be written relative to `.pi/agents`. Docs and profiles Unknown tool names are reported visibly. If an allowlist resolves to zero available tools, role activation fails instead of starting a broken session. -## Example +## Launch sessions + +Resume an existing AgentOS-backed linked session when it exists in this cwd, otherwise let pi-link create a new linked session: + +```sh +pi-link planner +``` + +If `.pi/agents/agents.yaml` contains `id: planner`, the session activates Planner Agent. If not, it stays a normal pi-link session. + +Open a fresh persistent Planner Agent session: + +```sh +pi --link --link-name planner +``` + +Open a fresh non-persistent scratch Planner Agent session: ```sh -pi-link builder +pi --link --link-name planner --no-session ``` -If `.pi/agents/agents.yaml` contains `id: builder`, the session starts as Builder Agent. If not, it stays a normal linked Pi session. +pi-link may print `No "planner" in this cwd... Starting new session.` when creating a new session. That message is normal pi-link session resolution, not an AgentOS failure. + +## Check active role + +Inside a session: + +```text +/agentos-status +``` + +Active role example: + +```text +AgentOS active: planner +profile: .pi/agents/planner.md +workspace: . +``` + +Fallback example: + +```text +Plain pi-link session: no AgentOS agent matched "scratch". +``` ## Development diff --git a/docs/plans/2026-06-18-002-fix-agentos-link-activation-plan.md b/docs/plans/2026-06-18-002-fix-agentos-link-activation-plan.md new file mode 100644 index 0000000..c53e358 --- /dev/null +++ b/docs/plans/2026-06-18-002-fix-agentos-link-activation-plan.md @@ -0,0 +1,178 @@ +--- +title: "fix: Activate AgentOS roles from pi-link session names" +type: fix +date: 2026-06-18 +--- + +# fix: Activate AgentOS roles from pi-link session names + +## Summary + +Fix `pi-agentos` so `pi-link ` reliably activates the matching AgentOS profile when `` exists in `.pi/agents/agents.yaml`. If the pi-link name does not match an AgentOS agent, the session remains a normal pi-link session. Preserve pi-link's native resume/create behavior, document direct Pi commands for explicit fresh sessions, and add `/agentos-status` so users can verify whether the current linked session is role-backed or plain pi-link. + +--- + +## Problem Frame + +A user installed `pi-agentos`, saw the default `.pi/agents` directory populate, then ran `pi-link planner`. pi-link printed its normal session-resolution message and started a session, but the model behaved like a normal Pi session instead of the Planner Agent. The intended product contract is simple: pi-link owns opening and resuming named linked sessions; direct Pi `--link-name` launches provide explicit fresh sessions; pi-agentos maps the resolved link name to a role only when the name is declared in the AgentOS registry. + +--- + +## Requirements + +- R1. `pi-link ` must activate the AgentOS profile whose `id` matches `` when the registry declares it. +- R2. `pi-link ` must remain normal pi-link behavior when `` is not declared in the registry. +- R3. Activation must not require a new launcher, new user-facing flag, or changes to pi-link or Pi coding agent. +- R4. Name detection must not rely only on `PI_LINK_NAME`, because pi-link may consume it before pi-agentos reads it. +- R5. Activation must retry at the last cheap moment before the model turn so extension load order cannot silently produce a plain session. +- R6. `/agentos-status` must report active role state, including whether the current session is AgentOS-backed or plain pi-link fallback. +- R7. Status output must distinguish registry/profile/tool-policy failures from a valid no-match fallback. +- R8. Existing default-agent sync, no-overwrite behavior, workspace safety, and tool-policy restore behavior must remain unchanged. +- R9. `pi-link ` must remain the normal resume path: resume an existing cwd session when one exists, otherwise allow pi-link's normal new-session behavior. +- R10. `pi --link --link-name ` must be documented as the explicit fresh persistent AgentOS session path. +- R11. `pi --link --link-name --no-session` must be documented as the explicit fresh non-persistent scratch path. + +--- + +## Key Technical Decisions + +- **Keep `pi-link ` as the resume/create linked-session UX:** pi-link already provides cwd-scoped session lookup and new-session creation, so pi-agentos should activate roles from the resulting link name rather than introduce a second launcher. +- **Use direct Pi `--link-name` for explicit fresh sessions:** pi-link rejects resume/fresh-style flags before extensions load, so fresh control belongs in documented Pi commands, not in pi-agentos flags. +- **Resolve link identity from durable session state, not only env:** use env when available, but prefer a resolver that can also read Pi session name and pi-link's saved `link-name` custom entry after pi-link has consumed `PI_LINK_NAME`. +- **Refresh activation in `before_agent_start`:** `session_start` can race extension ordering; the pre-agent hook is the final point where pi-agentos can detect the resolved name and inject role context before the model sees the prompt. +- **Add a first-class status command:** notifications are transient, while `/agentos-status` gives a repeatable check during manual testing and multi-terminal orchestration. +- **Treat no-match as a clean fallback:** an undeclared pi-link name is not an error; it should show as plain pi-link in status and avoid noisy warnings during normal linked-session use. + +--- + +## High-Level Technical Design + +```mermaid +flowchart TB + Launch[pi-link planner] --> Pi[Pi resumes or creates linked session] + Fresh[pi --link --link-name planner] --> Pi + Scratch[pi --link --link-name planner --no-session] --> Pi + Pi --> Link[pi-link resolves link/session name] + Link --> AgentOS[pi-agentos refreshes active role] + AgentOS --> Name{resolved name?} + Name -->|planner| Registry{agents.yaml has id?} + Name -->|none| Plain[plain pi-link fallback] + Registry -->|yes| Role[load planner profile] + Registry -->|no| Plain + Role --> Tools[apply tool policy] + Tools --> Prompt[inject AgentOS role before model turn] + Plain --> PromptNoop[no prompt/tool changes] + Role --> Status[/agentos-status: active role] + Plain --> StatusPlain[/agentos-status: pi-link only] +``` + +--- + +## Implementation Units + +### U1. Harden pi-link name resolution + +- **Goal:** Make the pure resolver correctly identify a pi-link name from all sources available to pi-agentos. +- **Requirements:** R1-R5, R7 +- **Dependencies:** None +- **Files:** + - `src/agentos.ts` + - `test/agentos.test.mjs` +- **Approach:** Extend the launch-name resolver to support the actual shapes returned by Pi session branch entries and pi-link custom entries. Keep source precedence explicit: live env when present, saved pi-link `link-name`, then Pi session name if no better link name exists. This same resolver must support `pi-link planner` and direct `pi --link --link-name planner` sessions. +- **Patterns to follow:** Existing `resolveLaunchName` and `latestLinkNameFromEntries`; upstream pi-link records a `link-name` custom entry and can set the Pi session name only through the wrapper env path. +- **Test scenarios:** + - Given `PI_LINK_NAME=planner`, resolver returns `planner`. + - Given a saved pi-link custom entry for `planner` and no env, resolver returns `planner`. + - Given a Pi session name `planner` and no saved link entry, resolver returns `planner`. + - Given direct Pi launch with `--link-name planner` causes pi-link to save or expose link name state, resolver returns `planner`. + - Given both a saved link name and unrelated session name, resolver returns the saved link name. + - Given no usable source, resolver returns blank and does not activate a role. +- **Verification:** Unit tests cover env, session name, saved entry, precedence, and no-source fallback. + +### U2. Make activation resilient to extension ordering + +- **Goal:** Ensure `pi-link planner` becomes Planner Agent even if pi-link consumes the env var before pi-agentos `session_start` runs. +- **Requirements:** R1-R5, R8 +- **Dependencies:** U1 +- **Files:** + - `index.ts` + - `test/index.test.mjs` +- **Approach:** Keep the current `session_start` refresh, but make `before_agent_start` always re-evaluate activation when no role is active or when the resolved name changes. Store the last resolved name and active state so no-match fallback stays clean and tool restoration only runs after a role had changed tools. +- **Patterns to follow:** Current `refreshActiveRole`, baseline tool capture/restore, and prompt injection in `before_agent_start`. +- **Test scenarios:** + - Given pi-link's saved entry appears only after initial startup, `before_agent_start` activates the matching role. + - Given `planner` is declared, `before_agent_start` injects Planner context before the model turn. + - Given `scratch` is undeclared, no AgentOS prompt is injected and no tool changes occur. + - Given a role was active and the session later resolves to an undeclared name, baseline tools are restored once. + - Given profile loading fails for a declared name, no partial role is injected and status reports an error. +- **Verification:** Extension-level tests simulate handler ordering and prove activation happens before prompt injection. + +### U3. Add `/agentos-status` + +- **Goal:** Give users a direct way to confirm whether the current pi-link session is AgentOS-backed. +- **Requirements:** R6-R7 +- **Dependencies:** U1, U2 +- **Files:** + - `index.ts` + - `test/index.test.mjs` + - `README.md` +- **Approach:** Register a Pi command named `agentos-status`. The command should refresh state, then report one of three states: active AgentOS role, plain pi-link fallback because no registry match exists, or AgentOS error because a declared role could not load. +- **Patterns to follow:** Pi extension command registration patterns in existing examples, plus the current notification/status UI helpers in `index.ts`. +- **Test scenarios:** + - Given active `planner`, command output includes active role, profile path, workspace, and warnings count. + - Given undeclared `scratch`, command output says plain pi-link session and no AgentOS role active. + - Given declared `planner` with missing profile, command output reports the load error. + - Given no link/session name, command output says no AgentOS role active. +- **Verification:** Command tests call the registered handler directly and assert the user-visible output for active, fallback, and error states. + +### U4. Document the corrected contract and smoke path + +- **Goal:** Make user docs match the intended runtime: pi-link remains the resume/create launcher, direct Pi `--link-name` is the fresh-session path, and pi-agentos activates roles by matching names. +- **Requirements:** R1-R8 +- **Dependencies:** U1-U3 +- **Files:** + - `README.md` +- **Approach:** Update examples to say `pi-link planner` is the normal resume/create flow. Explain that pi-link's `No "planner" in this cwd... Starting new session.` message is normal when it creates a new session, not an AgentOS failure. Document `pi --link --link-name planner` for a fresh persistent session and `pi --link --link-name planner --no-session` for a fresh scratch session. Add `/agentos-status` examples for active and fallback sessions. +- **Test scenarios:** + - Test expectation: none -- documentation-only change. +- **Verification:** README describes the resume, fresh persistent, fresh scratch, fallback, and status-command behavior without recommending a separate AgentOS launcher. + +--- + +## Acceptance Examples + +- AE1. Given `planner` is declared in `.pi/agents/agents.yaml`, when the user runs `pi-link planner`, then the next agent turn includes Planner Agent context. +- AE2. Given `scratch` is not declared in `.pi/agents/agents.yaml`, when the user runs `pi-link scratch`, then the session behaves as normal pi-link without AgentOS role injection. +- AE2a. Given the user wants a fresh persistent Planner session, when they run `pi --link --link-name planner`, then pi-agentos activates Planner Agent in the new linked session. +- AE2b. Given the user wants a fresh non-persistent Planner scratch session, when they run `pi --link --link-name planner --no-session`, then pi-agentos activates Planner Agent without saving a resumable session. +- AE3. Given the user runs `/agentos-status` in an active Planner session, then the command reports `planner` as active and shows the profile path. +- AE4. Given the user runs `/agentos-status` in an undeclared linked session, then the command reports plain pi-link fallback rather than an error. +- AE5. Given pi-link consumes `PI_LINK_NAME` before pi-agentos startup handling, then pi-agentos still activates from saved link/session state before the model turn. + +--- + +## Scope Boundaries + +- Do not patch, fork, wrap, or replace pi-link. +- Do not add a `pi-agentos ` launcher for this fix. +- Do not add user-facing launch flags for AgentOS activation. +- Do not add unsupported `pi-link planner --resume`, `pi-link planner --fresh`, or similar flags; pi-link rejects those before pi-agentos can handle them. +- Do not change default-agent sync semantics. +- Do not warn on normal undeclared pi-link names outside `/agentos-status`. + +--- + +## Risks & Dependencies + +- **Session entry shape:** Pi's session branch APIs may expose custom entries differently than current tests assume; implementation should inspect the actual branch shape while keeping pure resolver tests around observed variants. +- **Extension ordering:** Both pi-link and pi-agentos run lifecycle handlers; the plan relies on pre-agent refresh to absorb startup ordering differences. +- **Status command output channel:** Pi command handlers may prefer returning text or notifying UI depending on API shape; implementation should follow current Pi examples and test the command handler directly. + +--- + +## Sources / Research + +- `index.ts` currently resolves names from `PI_LINK_NAME`, Pi session name, and session entries, then refreshes again from `before_agent_start` when no active role exists. +- `src/agentos.ts` already contains `latestLinkNameFromEntries` and `resolveLaunchName`, but tests need to cover the actual pi-link ordering and custom-entry shapes. +- Upstream pi-link records a `link-name` custom entry, consumes `PI_LINK_NAME`, and prints `Starting new session` when it creates a named session instead of finding a local one. +- pi-link's shell launcher rejects resume/fresh-style managed flags, so explicit fresh AgentOS sessions must use Pi's existing `--link --link-name` flow instead of pi-link flags. diff --git a/index.ts b/index.ts index e71993c..9458798 100644 --- a/index.ts +++ b/index.ts @@ -5,6 +5,7 @@ import { buildRolePrompt, computeToolPolicy, ensureWorkspace, + relativeProjectPath, resolveAgent, resolveLaunchName, syncDefaultAgents, @@ -17,12 +18,44 @@ type ActiveRole = { warnings: string[]; }; +type AgentOSStatus = { + state: "inactive" | "active" | "fallback" | "error"; + name: string; + message: string; + errors: string[]; + warnings: string[]; + profile?: AgentProfile; +}; + const packageRoot = path.dirname(fileURLToPath(import.meta.url)); const packageAgentsDir = path.join(packageRoot, "agents"); let activeRole: ActiveRole | null = null; let activeProjectRoot = ""; let baselineTools: string[] | null = null; let roleChangedTools = false; +let lastResolvedName = ""; +let lastStatus: AgentOSStatus = { + state: "inactive", + name: "", + message: "No link/session name resolved. No AgentOS role active.", + errors: [], + warnings: [], +}; + +function resetState() { + activeRole = null; + activeProjectRoot = ""; + baselineTools = null; + roleChangedTools = false; + lastResolvedName = ""; + lastStatus = { + state: "inactive", + name: "", + message: "No link/session name resolved. No AgentOS role active.", + errors: [], + warnings: [], + }; +} function entriesFrom(ctx: ExtensionContext): unknown[] { try { @@ -55,30 +88,77 @@ function restoreBaselineTools(pi: ExtensionAPI) { } } -async function refreshActiveRole(pi: ExtensionAPI, ctx: ExtensionContext, options: { notifyUser?: boolean } = {}) { - activeProjectRoot = ctx.cwd; - await syncDefaultAgents({ packageAgentsDir, projectRoot: ctx.cwd }); - captureBaselineTools(pi); +function linkFlagName(pi: ExtensionAPI): unknown { + try { + return pi.getFlag?.("link-name"); + } catch { + return undefined; + } +} - const launchName = resolveLaunchName({ +function currentLaunchName(pi: ExtensionAPI, ctx: ExtensionContext): string { + return resolveLaunchName({ + flagName: linkFlagName(pi), envName: process.env.PI_LINK_NAME, sessionName: pi.getSessionName?.(), entries: entriesFrom(ctx), }); +} + +function setFallbackStatus(pi: ExtensionAPI, ctx: ExtensionContext, name: string) { + activeRole = null; + setStatus(ctx, undefined); + restoreBaselineTools(pi); + lastStatus = name + ? { + state: "fallback", + name, + message: `Plain pi-link session: no AgentOS agent matched "${name}".`, + errors: [], + warnings: [], + } + : { + state: "inactive", + name: "", + message: "No link/session name resolved. No AgentOS role active.", + errors: [], + warnings: [], + }; +} + +function setErrorStatus(pi: ExtensionAPI, ctx: ExtensionContext, name: string, errors: string[], warnings: string[] = []) { + activeRole = null; + setStatus(ctx, undefined); + restoreBaselineTools(pi); + lastStatus = { + state: "error", + name, + message: `AgentOS ${name}: ${errors.join("; ")}`, + errors, + warnings, + }; +} + +async function refreshActiveRole(pi: ExtensionAPI, ctx: ExtensionContext, options: { notifyUser?: boolean } = {}) { + activeProjectRoot = ctx.cwd; + await syncDefaultAgents({ packageAgentsDir, projectRoot: ctx.cwd }); + captureBaselineTools(pi); + + const launchName = currentLaunchName(pi, ctx); + lastResolvedName = launchName; if (!launchName) { - activeRole = null; - setStatus(ctx, undefined); - restoreBaselineTools(pi); + setFallbackStatus(pi, ctx, ""); return; } const resolved = await resolveAgent(ctx.cwd, launchName); if (!resolved.active) { - activeRole = null; - setStatus(ctx, undefined); - restoreBaselineTools(pi); - if (resolved.errors.length) notify(ctx, `AgentOS ${launchName}: ${resolved.errors.join("; ")}`, "error"); + setFallbackStatus(pi, ctx, launchName); + if (resolved.errors.length) { + setErrorStatus(pi, ctx, launchName, resolved.errors); + notify(ctx, lastStatus.message, "error"); + } return; } @@ -92,10 +172,8 @@ async function refreshActiveRole(pi: ExtensionAPI, ctx: ExtensionContext, option ); if (policy.errors.length) { - activeRole = null; - setStatus(ctx, undefined); - restoreBaselineTools(pi); - notify(ctx, `AgentOS ${launchName}: ${policy.errors.join("; ")}`, "error"); + setErrorStatus(pi, ctx, launchName, policy.errors, policy.warnings); + notify(ctx, lastStatus.message, "error"); return; } @@ -106,6 +184,14 @@ async function refreshActiveRole(pi: ExtensionAPI, ctx: ExtensionContext, option } activeRole = { name: launchName, profile: resolved.profile, warnings }; + lastStatus = { + state: "active", + name: launchName, + message: `AgentOS active: ${launchName}`, + errors: [], + warnings, + profile: resolved.profile, + }; setStatus(ctx, `AgentOS: ${launchName}`); if (options.notifyUser) { @@ -114,19 +200,66 @@ async function refreshActiveRole(pi: ExtensionAPI, ctx: ExtensionContext, option } } +function statusText(ctx: ExtensionContext): string { + if (lastStatus.state === "active" && lastStatus.profile) { + const profilePath = relativeProjectPath(ctx.cwd, lastStatus.profile.profilePath); + const workspace = relativeProjectPath(ctx.cwd, lastStatus.profile.workspace.path); + const warningLine = lastStatus.warnings.length ? `\nwarnings: ${lastStatus.warnings.length}` : ""; + return `AgentOS active: ${lastStatus.name}\nprofile: ${profilePath}\nworkspace: ${workspace}${warningLine}`; + } + + if (lastStatus.state === "error") { + return `AgentOS error for ${lastStatus.name}: ${lastStatus.errors.join("; ")}`; + } + + if (lastStatus.state === "fallback") { + return lastStatus.message; + } + + return lastStatus.message; +} + export default function piAgentOS(pi: ExtensionAPI) { + resetState(); + + pi.registerCommand?.("agentos-status", { + description: "Show active AgentOS role or pi-link fallback status", + handler: async (_args, ctx) => { + try { + await refreshActiveRole(pi, ctx, { notifyUser: false }); + } catch (error) { + lastStatus = { + state: "error", + name: lastResolvedName, + message: `AgentOS status failed: ${error instanceof Error ? error.message : String(error)}`, + errors: [error instanceof Error ? error.message : String(error)], + warnings: [], + }; + } + notify(ctx, statusText(ctx), lastStatus.state === "error" ? "error" : "info"); + }, + }); + pi.on("session_start", async (_event, ctx) => { try { await refreshActiveRole(pi, ctx, { notifyUser: true }); } catch (error) { activeRole = null; setStatus(ctx, undefined); - notify(ctx, `AgentOS failed: ${error instanceof Error ? error.message : String(error)}`, "error"); + lastStatus = { + state: "error", + name: lastResolvedName, + message: `AgentOS failed: ${error instanceof Error ? error.message : String(error)}`, + errors: [error instanceof Error ? error.message : String(error)], + warnings: [], + }; + notify(ctx, lastStatus.message, "error"); } }); pi.on("before_agent_start", async (event, ctx) => { - if (!activeRole || activeProjectRoot !== ctx.cwd) { + const launchName = currentLaunchName(pi, ctx); + if (!activeRole || activeProjectRoot !== ctx.cwd || launchName !== lastResolvedName) { try { await refreshActiveRole(pi, ctx, { notifyUser: false }); } catch { diff --git a/src/agentos.ts b/src/agentos.ts index 563eb00..5014a62 100644 --- a/src/agentos.ts +++ b/src/agentos.ts @@ -307,6 +307,10 @@ function rel(projectRoot: string, filePath: string): string { return path.relative(projectRoot, filePath) || "."; } +export function relativeProjectPath(projectRoot: string, filePath: string): string { + return rel(projectRoot, filePath); +} + export function buildRolePrompt(projectRoot: string, profile: AgentProfile, options: { availableSkills?: string[]; maxContextChars?: number } = {}): string { const availableSkills = options.availableSkills; const warnings = [...profile.warnings]; @@ -361,15 +365,30 @@ export function buildRolePrompt(projectRoot: string, profile: AgentProfile, opti export function latestLinkNameFromEntries(entries: unknown[]): string { for (let i = entries.length - 1; i >= 0; i--) { - const entry = entries[i] as { type?: string; customType?: string; data?: { name?: unknown } }; - if (entry.type === "custom" && entry.customType === "link-name") { - const name = normalizeName(entry.data?.name); - if (name) return name; - } + const entry = entries[i] as { + type?: string; + customType?: string; + data?: { name?: unknown }; + content?: unknown; + details?: { name?: unknown }; + }; + if (entry.type !== "custom" || entry.customType !== "link-name") continue; + + const dataName = normalizeName(entry.data?.name); + if (dataName) return dataName; + + const detailsName = normalizeName(entry.details?.name); + if (detailsName) return detailsName; + + const contentName = normalizeName(entry.content); + if (contentName) return contentName; } return ""; } -export function resolveLaunchName(input: { envName?: unknown; sessionName?: unknown; entries?: unknown[] }): string { - return normalizeName(input.envName) || normalizeName(input.sessionName) || latestLinkNameFromEntries(input.entries ?? []); +export function resolveLaunchName(input: { flagName?: unknown; envName?: unknown; sessionName?: unknown; entries?: unknown[] }): string { + return normalizeName(input.flagName) + || normalizeName(input.envName) + || latestLinkNameFromEntries(input.entries ?? []) + || normalizeName(input.sessionName); } diff --git a/test/agentos.test.mjs b/test/agentos.test.mjs index 78c8a92..5f8fa4d 100644 --- a/test/agentos.test.mjs +++ b/test/agentos.test.mjs @@ -133,8 +133,12 @@ test("role prompt includes profile and referenced context with warnings", async assert.match(prompt, /Rule text/); }); -test("launch name prefers env, then session, then saved link-name", () => { +test("launch name prefers flag, env, saved link-name, then session", () => { + assert.equal(resolveLaunchName({ flagName: " direct ", envName: "env", sessionName: "session" }), "direct"); assert.equal(resolveLaunchName({ envName: " builder ", sessionName: "session" }), "builder"); assert.equal(resolveLaunchName({ sessionName: " session " }), "session"); - assert.equal(resolveLaunchName({ entries: [{ type: "custom", customType: "link-name", data: { name: "saved" } }] }), "saved"); + assert.equal(resolveLaunchName({ sessionName: "session", entries: [{ type: "custom", customType: "link-name", data: { name: "saved" } }] }), "saved"); + assert.equal(resolveLaunchName({ sessionName: "session", entries: [{ type: "custom", customType: "link-name", details: { name: "details" } }] }), "details"); + assert.equal(resolveLaunchName({ sessionName: "session", entries: [{ type: "custom", customType: "link-name", content: "content" }] }), "content"); + assert.equal(resolveLaunchName({}), ""); }); diff --git a/test/index.test.mjs b/test/index.test.mjs index ef09132..03d80f5 100644 --- a/test/index.test.mjs +++ b/test/index.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, readdir, readFile } from "node:fs/promises"; +import { mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { test } from "node:test"; @@ -9,19 +9,28 @@ async function tmpProject() { return mkdtemp(path.join(os.tmpdir(), "pi-agentos-ext-test-")); } -test("extension startup syncs defaults and activates declared pi-link name", async () => { - const project = await tmpProject(); +function harness(project, overrides = {}) { const handlers = new Map(); + const commands = new Map(); const notifications = []; const statuses = []; - let activeTools = ["read", "grep", "find", "ls", "edit", "write", "bash", "ask_user"]; + let activeTools = overrides.activeTools ?? ["read", "grep", "find", "ls", "edit", "write", "bash", "ask_user"]; + let sessionName = overrides.sessionName ?? "builder"; + let flagName = overrides.flagName; + let entries = overrides.entries ?? []; const pi = { on(name, handler) { handlers.set(name, handler); }, + registerCommand(name, config) { + commands.set(name, config); + }, + getFlag(name) { + return name === "link-name" ? flagName : undefined; + }, getSessionName() { - return "builder"; + return sessionName; }, getActiveTools() { return activeTools; @@ -45,86 +54,113 @@ test("extension startup syncs defaults and activates declared pi-link name", asy }, sessionManager: { getEntries() { - return []; + return entries; }, }, }; piAgentOS(pi); - await handlers.get("session_start")({}, ctx); - const result = await handlers.get("before_agent_start")({ systemPrompt: "base prompt" }, ctx); + + return { + pi, + ctx, + handlers, + commands, + notifications, + statuses, + get activeTools() { return activeTools; }, + setSessionName(value) { sessionName = value; }, + setFlagName(value) { flagName = value; }, + setEntries(value) { entries = value; }, + }; +} + +test("extension startup syncs defaults and activates declared pi-link name", async () => { + const project = await tmpProject(); + const app = harness(project, { sessionName: "builder" }); + + await app.handlers.get("session_start")({}, app.ctx); + const result = await app.handlers.get("before_agent_start")({ systemPrompt: "base prompt" }, app.ctx); assert.ok((await readdir(path.join(project, ".pi", "agents"))).includes("agents.yaml")); assert.match(await readFile(path.join(project, ".pi", "agents", "builder.md"), "utf8"), /You are the Builder Agent/); - assert.deepEqual(activeTools, ["read", "grep", "find", "ls", "edit", "write", "bash"]); - assert.ok(statuses.some((status) => status.key === "agentos" && status.value === "AgentOS: builder")); - assert.ok(notifications.some((note) => note.message.includes("AgentOS role active: builder"))); + assert.deepEqual(app.activeTools, ["read", "grep", "find", "ls", "edit", "write", "bash"]); + assert.ok(app.statuses.some((status) => status.key === "agentos" && status.value === "AgentOS: builder")); + assert.ok(app.notifications.some((note) => note.message.includes("AgentOS role active: builder"))); assert.match(result.systemPrompt, /AgentOS Active Role/); assert.match(result.systemPrompt, /You are the Builder Agent/); }); +test("before_agent_start activates when pi-link saved entry appears after startup", async () => { + const project = await tmpProject(); + const app = harness(project, { sessionName: "plain" }); -test("extension restores baseline tools when a later session name is undeclared", async () => { + await app.handlers.get("session_start")({}, app.ctx); + app.setEntries([{ type: "custom", customType: "link-name", data: { name: "planner" } }]); + + const result = await app.handlers.get("before_agent_start")({ systemPrompt: "base prompt" }, app.ctx); + + assert.match(result.systemPrompt, /AgentOS Active Role/); + assert.match(result.systemPrompt, /You are the Planner Agent/); +}); + +test("direct --link-name flag activates matching agent", async () => { const project = await tmpProject(); - const handlers = new Map(); - let sessionName = "builder"; - let activeTools = ["read", "grep", "find", "ls", "edit", "write", "bash", "ask_user"]; + const app = harness(project, { flagName: "planner", sessionName: "" }); - const pi = { - on(name, handler) { handlers.set(name, handler); }, - getSessionName() { return sessionName; }, - getActiveTools() { return activeTools; }, - getAllTools() { return activeTools.map((name) => ({ name })); }, - setActiveTools(names) { activeTools = names; }, - }; - const ctx = { - cwd: project, - ui: { notify() {}, setStatus() {} }, - sessionManager: { getEntries() { return []; } }, - }; + await app.handlers.get("session_start")({}, app.ctx); + const result = await app.handlers.get("before_agent_start")({ systemPrompt: "base prompt" }, app.ctx); - piAgentOS(pi); - await handlers.get("session_start")({}, ctx); - assert.deepEqual(activeTools, ["read", "grep", "find", "ls", "edit", "write", "bash"]); + assert.match(result.systemPrompt, /You are the Planner Agent/); +}); + +test("extension restores baseline tools when a later session name is undeclared", async () => { + const project = await tmpProject(); + const app = harness(project, { sessionName: "builder" }); + + await app.handlers.get("session_start")({}, app.ctx); + assert.deepEqual(app.activeTools, ["read", "grep", "find", "ls", "edit", "write", "bash"]); - sessionName = "scratch"; - await handlers.get("session_start")({}, ctx); + app.setSessionName("scratch"); + await app.handlers.get("session_start")({}, app.ctx); - assert.deepEqual(activeTools, ["read", "grep", "find", "ls", "edit", "write", "bash", "ask_user"]); + assert.deepEqual(app.activeTools, ["read", "grep", "find", "ls", "edit", "write", "bash", "ask_user"]); }); test("extension leaves undeclared names alone", async () => { const project = await tmpProject(); - const handlers = new Map(); - let setActiveToolsCalled = false; - - const pi = { - on(name, handler) { - handlers.set(name, handler); - }, - getSessionName() { - return "scratch"; - }, - getActiveTools() { - return ["read"]; - }, - getAllTools() { - return [{ name: "read" }]; - }, - setActiveTools() { - setActiveToolsCalled = true; - }, - }; - const ctx = { - cwd: project, - ui: { notify() {}, setStatus() {} }, - sessionManager: { getEntries() { return []; } }, - }; + const app = harness(project, { sessionName: "scratch", activeTools: ["read"] }); - piAgentOS(pi); - await handlers.get("session_start")({}, ctx); - const result = await handlers.get("before_agent_start")({ systemPrompt: "base prompt" }, ctx); + await app.handlers.get("session_start")({}, app.ctx); + const result = await app.handlers.get("before_agent_start")({ systemPrompt: "base prompt" }, app.ctx); - assert.equal(setActiveToolsCalled, false); + assert.deepEqual(app.activeTools, ["read"]); assert.equal(result, undefined); }); + +test("agentos-status reports active role and fallback", async () => { + const project = await tmpProject(); + const app = harness(project, { sessionName: "planner" }); + + await app.handlers.get("session_start")({}, app.ctx); + await app.commands.get("agentos-status").handler("", app.ctx); + + assert.ok(app.notifications.some((note) => note.message.includes("AgentOS active: planner"))); + assert.ok(app.notifications.some((note) => note.message.includes("profile: .pi/agents/planner.md"))); + + app.setSessionName("scratch"); + await app.commands.get("agentos-status").handler("", app.ctx); + + assert.ok(app.notifications.some((note) => note.message.includes("Plain pi-link session: no AgentOS agent matched \"scratch\"."))); +}); + +test("agentos-status reports declared agent load errors", async () => { + const project = await tmpProject(); + const app = harness(project, { sessionName: "broken" }); + + await app.handlers.get("session_start")({}, app.ctx); + await writeFile(path.join(project, ".pi", "agents", "agents.yaml"), `agents:\n - id: broken\n profile: .pi/agents/missing.md\n`); + await app.commands.get("agentos-status").handler("", app.ctx); + + assert.ok(app.notifications.some((note) => note.level === "error" && note.message.includes("AgentOS error for broken"))); +});