diff --git a/extensions/headsdown/call-renderer.ts b/extensions/headsdown/call-renderer.ts new file mode 100644 index 0000000..ca6cfa5 --- /dev/null +++ b/extensions/headsdown/call-renderer.ts @@ -0,0 +1,291 @@ +export const CANONICAL_HEADSDOWN_CALL_KEYS = [ + "good_to_run", + "keep_it_tight", + "not_worth_starting_now", + "off_the_clock", + "rabbit_hole_detected", + "ready_to_resume", + "all_contained", + "needs_your_yes", +] as const; + +export type CanonicalHeadsDownCallKey = (typeof CANONICAL_HEADSDOWN_CALL_KEYS)[number]; + +export type HeadsDownActionKey = + | "continue" + | "continue_with_limit" + | "narrow_scope" + | "ask_user" + | "queue_for_later" + | "queue_for_morning" + | "pause_and_summarize" + | "stop_run" + | "resume_run" + | "allow_once" + | "allow_for_duration" + | "create_temporary_exception" + | "keep_queued"; + +export type HeadsDownUiIntent = + | "view_details" + | "review_request" + | "review_runs" + | "review_handoff" + | "view_queue" + | "view_receipts" + | "adjust_playbooks" + | "start_run"; + +type RenderLabels = { + title: string; + body: string; + primaryLabel: string | null; + primaryActionKey: HeadsDownActionKey | null; + primaryUiIntent: HeadsDownUiIntent | null; + secondaryLabel: string | null; + secondaryActionKey: HeadsDownActionKey | null; + secondaryUiIntent: HeadsDownUiIntent | null; +}; + +export interface UnknownCallFallbackSignals { + actionRequired?: boolean; + approvalRequired?: boolean; + riskSignal?: boolean; + boundarySignal?: boolean; + spendSignal?: boolean; + externalSideEffectSignal?: boolean; + escalationSignal?: boolean; + limitSignal?: boolean; + scopeUncertainty?: boolean; + validationUncertainty?: boolean; + lowConfidence?: boolean; + noActionNeededExplicit?: boolean; + inBoundsExplicit?: boolean; +} + +export interface RenderHeadsDownCallInput { + key: string | null | undefined; + title?: string | null; + body?: string | null; + fallbackSignals?: UnknownCallFallbackSignals; +} + +export interface RenderedHeadsDownCallCopy extends RenderLabels { + key: CanonicalHeadsDownCallKey; + sourceKey: string | null; + fallbackApplied: boolean; +} + +const CANONICAL_CALL_COPY: Record = { + good_to_run: { + title: "Good to run", + body: "This task fits the time, scope, and attention available right now. Let the agent proceed within the approved bounds.", + primaryLabel: "Let the agent proceed", + primaryActionKey: "continue", + primaryUiIntent: null, + secondaryLabel: "Why this call?", + secondaryActionKey: null, + secondaryUiIntent: "view_details", + }, + keep_it_tight: { + title: "Keep it tight", + body: "There is enough room for a useful slice, not an open-ended run. Ask the agent for the smallest version that still ships value.", + primaryLabel: "Narrow scope", + primaryActionKey: "narrow_scope", + primaryUiIntent: null, + secondaryLabel: "Why this call?", + secondaryActionKey: null, + secondaryUiIntent: "view_details", + }, + not_worth_starting_now: { + title: "Not worth starting now", + body: "The likely cost is higher than the likely value right now. Queue it for later instead of burning time on a weak run.", + primaryLabel: "Queue for later", + primaryActionKey: "queue_for_later", + primaryUiIntent: null, + secondaryLabel: "Why this call?", + secondaryActionKey: null, + secondaryUiIntent: "view_details", + }, + off_the_clock: { + title: "Off the clock", + body: "Non-urgent agent decisions wait until the next work window. Safe continuation can stay contained, but new asks should queue.", + primaryLabel: "Queue for later", + primaryActionKey: "queue_for_later", + primaryUiIntent: null, + secondaryLabel: "Why this call?", + secondaryActionKey: null, + secondaryUiIntent: "view_details", + }, + rabbit_hole_detected: { + title: "Rabbit hole detected", + body: "The work is growing past the size that was worth approving. Pause, save the handoff, and re-scope before it becomes cleanup work.", + primaryLabel: "Pause + summarize", + primaryActionKey: "pause_and_summarize", + primaryUiIntent: null, + secondaryLabel: "Allow 15m", + secondaryActionKey: "allow_for_duration", + secondaryUiIntent: null, + }, + ready_to_resume: { + title: "Ready to resume", + body: "HeadsDown saved the thread so the agent can pick up without starting over. Resume the approved work or keep it queued.", + primaryLabel: "Resume approved work", + primaryActionKey: "resume_run", + primaryUiIntent: null, + secondaryLabel: "Keep queued", + secondaryActionKey: "keep_queued", + secondaryUiIntent: null, + }, + all_contained: { + title: "All contained", + body: "Runs are staying inside your time, scope, and interruption limits. Nothing needs you right now.", + primaryLabel: null, + primaryActionKey: null, + primaryUiIntent: null, + secondaryLabel: "Why this call?", + secondaryActionKey: null, + secondaryUiIntent: "view_details", + }, + needs_your_yes: { + title: "Needs your yes", + body: "An agent wants to cross a boundary that should not be automatic. Review the request and approve, narrow, or keep it queued.", + primaryLabel: "Review request", + primaryActionKey: null, + primaryUiIntent: "review_request", + secondaryLabel: "Keep queued", + secondaryActionKey: "keep_queued", + secondaryUiIntent: null, + }, +}; + +function isCanonicalHeadsDownCallKey(key: string): key is CanonicalHeadsDownCallKey { + return (CANONICAL_HEADSDOWN_CALL_KEYS as readonly string[]).includes(key); +} + +export function resolveUnknownHeadsDownCallFallback( + signals?: UnknownCallFallbackSignals, +): CanonicalHeadsDownCallKey { + const hasActionRiskOrBoundarySignal = + signals?.actionRequired === true || + signals?.approvalRequired === true || + signals?.riskSignal === true || + signals?.boundarySignal === true || + signals?.spendSignal === true || + signals?.externalSideEffectSignal === true || + signals?.escalationSignal === true; + + if (hasActionRiskOrBoundarySignal) { + return "needs_your_yes"; + } + + const hasLimitOrScopeUncertainty = + signals?.limitSignal === true || + signals?.scopeUncertainty === true || + signals?.validationUncertainty === true || + signals?.lowConfidence === true; + + if (hasLimitOrScopeUncertainty) { + return "keep_it_tight"; + } + + const explicitNoActionAndInBounds = + signals?.noActionNeededExplicit === true && signals?.inBoundsExplicit === true; + + if (explicitNoActionAndInBounds) { + return "all_contained"; + } + + return "needs_your_yes"; +} + +export function renderHeadsDownCallCopy( + input: RenderHeadsDownCallInput, +): RenderedHeadsDownCallCopy { + const normalizedKey = normalizeCallKey(input.key); + + if (normalizedKey && isCanonicalHeadsDownCallKey(normalizedKey)) { + const copy = CANONICAL_CALL_COPY[normalizedKey]; + return { + key: normalizedKey, + sourceKey: normalizedKey, + fallbackApplied: false, + ...copy, + }; + } + + const fallbackKey = resolveUnknownHeadsDownCallFallback(input.fallbackSignals); + const fallbackCopy = unknownFallbackCopy(fallbackKey); + + return { + key: fallbackKey, + sourceKey: normalizedKey.length > 0 ? normalizedKey : null, + fallbackApplied: true, + title: input.title?.trim() || fallbackCopy.title, + body: input.body?.trim() || fallbackCopy.body, + primaryLabel: fallbackCopy.primaryLabel, + primaryActionKey: fallbackCopy.primaryActionKey, + primaryUiIntent: fallbackCopy.primaryUiIntent, + secondaryLabel: fallbackCopy.secondaryLabel, + secondaryActionKey: fallbackCopy.secondaryActionKey, + secondaryUiIntent: fallbackCopy.secondaryUiIntent, + }; +} + +function unknownFallbackCopy(key: CanonicalHeadsDownCallKey): RenderLabels { + if (key === "needs_your_yes") { + return { + ...CANONICAL_CALL_COPY.needs_your_yes, + body: "HeadsDown needs a human decision before this agent continues.", + secondaryLabel: "Why this call?", + secondaryActionKey: null, + secondaryUiIntent: "view_details", + }; + } + + if (key === "keep_it_tight") { + return { + ...CANONICAL_CALL_COPY.keep_it_tight, + primaryLabel: "Why this call?", + primaryActionKey: null, + primaryUiIntent: "view_details", + secondaryLabel: null, + secondaryActionKey: null, + secondaryUiIntent: null, + }; + } + + return CANONICAL_CALL_COPY[key]; +} + +function normalizeCallKey(key: string | null | undefined): string { + return key?.trim().toLowerCase() ?? ""; +} + +export function formatHeadsDownCallForPrompt(rendered: RenderedHeadsDownCallCopy): string { + const lines = [`HEADSDOWN CALL`, `${rendered.title}`, `${rendered.body}`]; + + if (rendered.primaryLabel) { + const primaryTransport = rendered.primaryActionKey + ? `action=${rendered.primaryActionKey}` + : rendered.primaryUiIntent + ? `ui_intent=${rendered.primaryUiIntent}` + : "display_only"; + lines.push(`Primary: ${rendered.primaryLabel} (${primaryTransport})`); + } + + if (rendered.secondaryLabel) { + const secondaryTransport = rendered.secondaryActionKey + ? `action=${rendered.secondaryActionKey}` + : rendered.secondaryUiIntent + ? `ui_intent=${rendered.secondaryUiIntent}` + : "display_only"; + lines.push(`Secondary: ${rendered.secondaryLabel} (${secondaryTransport})`); + } + + if (rendered.fallbackApplied && rendered.sourceKey) { + lines.push(`Rendered fallback for unknown call key: ${rendered.sourceKey}`); + } + + return lines.join("\n"); +} diff --git a/test/call-renderer.test.ts b/test/call-renderer.test.ts new file mode 100644 index 0000000..2e85459 --- /dev/null +++ b/test/call-renderer.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { + CANONICAL_HEADSDOWN_CALL_KEYS, + formatHeadsDownCallForPrompt, + renderHeadsDownCallCopy, + resolveUnknownHeadsDownCallFallback, +} from "../extensions/headsdown/call-renderer.js"; + +describe("renderHeadsDownCallCopy", () => { + it("renders every canonical HeadsDown call key", () => { + for (const key of CANONICAL_HEADSDOWN_CALL_KEYS) { + const rendered = renderHeadsDownCallCopy({ key }); + expect(rendered.key).toBe(key); + expect(rendered.fallbackApplied).toBe(false); + expect(rendered.sourceKey).toBe(key); + expect(rendered.title.length).toBeGreaterThan(0); + expect(rendered.body.length).toBeGreaterThan(0); + expect(rendered.secondaryLabel).toBeTruthy(); + } + }); + + it("keeps backend action keys and UI intents separate for needs_your_yes", () => { + const rendered = renderHeadsDownCallCopy({ key: "needs_your_yes" }); + + expect(rendered.primaryLabel).toBe("Review request"); + expect(rendered.primaryActionKey).toBeNull(); + expect(rendered.primaryUiIntent).toBe("review_request"); + expect(rendered.secondaryLabel).toBe("Keep queued"); + expect(rendered.secondaryActionKey).toBe("keep_queued"); + expect(rendered.secondaryUiIntent).toBeNull(); + }); + + it("keeps all_contained display-only (no primary action)", () => { + const rendered = renderHeadsDownCallCopy({ key: "all_contained" }); + + expect(rendered.primaryLabel).toBeNull(); + expect(rendered.primaryActionKey).toBeNull(); + expect(rendered.primaryUiIntent).toBeNull(); + expect(rendered.secondaryLabel).toBe("Why this call?"); + expect(rendered.secondaryUiIntent).toBe("view_details"); + }); + + it("normalizes GraphQL enum-style canonical call keys", () => { + const rendered = renderHeadsDownCallCopy({ key: "READY_TO_RESUME" }); + + expect(rendered.key).toBe("ready_to_resume"); + expect(rendered.fallbackApplied).toBe(false); + expect(rendered.primaryActionKey).toBe("resume_run"); + }); + + it("falls back to needs_your_yes for unknown action/risk/boundary signals", () => { + const rendered = renderHeadsDownCallCopy({ + key: "future_call_key", + fallbackSignals: { boundarySignal: true }, + }); + + expect(rendered.fallbackApplied).toBe(true); + expect(rendered.key).toBe("needs_your_yes"); + expect(rendered.body).toBe("HeadsDown needs a human decision before this agent continues."); + expect(rendered.primaryActionKey).toBeNull(); + expect(rendered.primaryUiIntent).toBe("review_request"); + expect(rendered.secondaryActionKey).toBeNull(); + expect(rendered.secondaryUiIntent).toBe("view_details"); + }); + + it("falls back to keep_it_tight for unknown limit/scope/validation uncertainty", () => { + const rendered = renderHeadsDownCallCopy({ + key: "future_call_key", + fallbackSignals: { validationUncertainty: true }, + }); + + expect(rendered.fallbackApplied).toBe(true); + expect(rendered.key).toBe("keep_it_tight"); + expect(rendered.body).toContain("useful slice"); + expect(rendered.primaryLabel).toBe("Why this call?"); + expect(rendered.primaryActionKey).toBeNull(); + expect(rendered.primaryUiIntent).toBe("view_details"); + expect(rendered.secondaryLabel).toBeNull(); + expect(rendered.secondaryActionKey).toBeNull(); + expect(rendered.secondaryUiIntent).toBeNull(); + }); + + it("falls back to all_contained only with explicit no-action and in-bounds signals", () => { + const rendered = renderHeadsDownCallCopy({ + key: "future_call_key", + fallbackSignals: { noActionNeededExplicit: true, inBoundsExplicit: true }, + }); + + expect(rendered.fallbackApplied).toBe(true); + expect(rendered.key).toBe("all_contained"); + expect(rendered.body).toContain("Nothing needs you right now"); + expect(rendered.primaryLabel).toBeNull(); + expect(rendered.secondaryLabel).toBe("Why this call?"); + expect(rendered.secondaryUiIntent).toBe("view_details"); + }); + + it("prefers needs_your_yes over all_contained when signals conflict", () => { + const rendered = renderHeadsDownCallCopy({ + key: "future_call_key", + fallbackSignals: { + actionRequired: true, + noActionNeededExplicit: true, + inBoundsExplicit: true, + }, + }); + + expect(rendered.key).toBe("needs_your_yes"); + }); + + it("falls back to needs_your_yes for spend and external side-effect signals", () => { + expect( + renderHeadsDownCallCopy({ + key: "future_call_key", + fallbackSignals: { spendSignal: true }, + }).key, + ).toBe("needs_your_yes"); + + expect( + renderHeadsDownCallCopy({ + key: "future_call_key", + fallbackSignals: { externalSideEffectSignal: true }, + }).key, + ).toBe("needs_your_yes"); + }); + + it("uses server-provided title/body on unknown fallback when present", () => { + const rendered = renderHeadsDownCallCopy({ + key: "future_call_key", + title: "Server call title", + body: "Server call body", + fallbackSignals: { limitSignal: true }, + }); + + expect(rendered.key).toBe("keep_it_tight"); + expect(rendered.title).toBe("Server call title"); + expect(rendered.body).toBe("Server call body"); + }); + + it("formats prompt/output copy for Pi with explicit action vs UI intent transport", () => { + const rendered = renderHeadsDownCallCopy({ key: "needs_your_yes" }); + const output = formatHeadsDownCallForPrompt(rendered); + + expect(output).toContain("HEADSDOWN CALL"); + expect(output).toContain("Needs your yes"); + expect(output).toContain("Primary: Review request (ui_intent=review_request)"); + expect(output).toContain("Secondary: Keep queued (action=keep_queued)"); + }); +}); + +describe("resolveUnknownHeadsDownCallFallback", () => { + it("defaults to needs_your_yes when no explicit fallback signals exist", () => { + expect(resolveUnknownHeadsDownCallFallback()).toBe("needs_your_yes"); + }); +});