diff --git a/src/renderer/components/thread/ThreadGoalDock.tsx b/src/renderer/components/thread/ThreadGoalDock.tsx index 06e7bb46..8608d8bd 100644 --- a/src/renderer/components/thread/ThreadGoalDock.tsx +++ b/src/renderer/components/thread/ThreadGoalDock.tsx @@ -139,7 +139,7 @@ function GoalObjectiveText({ return (
- {text} + {text} {objective} {lastReason ? ( diff --git a/src/supervisor/agents/acp/canonicalMapping.test.ts b/src/supervisor/agents/acp/canonicalMapping.test.ts index 87e392c5..576fe180 100644 --- a/src/supervisor/agents/acp/canonicalMapping.test.ts +++ b/src/supervisor/agents/acp/canonicalMapping.test.ts @@ -36,6 +36,7 @@ describe("mapAcpSessionUpdate", () => { action: "set", objective: "Ship ACP goal support", status: "active", + availableActions: ["pause", "clear"], timeUsedSeconds: 0, updatedAt: 1_784_627_753.997, }, @@ -51,6 +52,7 @@ describe("mapAcpSessionUpdate", () => { action: "set", objective: "Ship ACP goal support", status: "active", + availableActions: ["pause", "clear"], }), }), expect.objectContaining({ type: "item.completed" }), diff --git a/src/supervisor/agents/acp/canonicalMapping/dispatch.ts b/src/supervisor/agents/acp/canonicalMapping/dispatch.ts index ccbac7e0..8cd6851c 100644 --- a/src/supervisor/agents/acp/canonicalMapping/dispatch.ts +++ b/src/supervisor/agents/acp/canonicalMapping/dispatch.ts @@ -154,6 +154,13 @@ export function mapAcpSessionUpdate( } case "agent_thought_chunk": { + const thoughtMeta = + update._meta && typeof update._meta === "object" && !Array.isArray(update._meta) + ? (update._meta as Record) + : undefined; + if (thoughtMeta?.[PORACODE_ACP_NEW_ASSISTANT_ITEM_META_KEY] === true) { + events.push(...closeOpenContentItems(state)); + } if (!state.openReasoningItemId) { // Close any prior assistant — reasoning bracket starts. if (state.openAssistantItemId) { diff --git a/src/supervisor/agents/acp/canonicalMapping/goals.ts b/src/supervisor/agents/acp/canonicalMapping/goals.ts index 0089df77..5839d80e 100644 --- a/src/supervisor/agents/acp/canonicalMapping/goals.ts +++ b/src/supervisor/agents/acp/canonicalMapping/goals.ts @@ -4,7 +4,11 @@ * shape before the shared canonical mapper sees it. */ -import type { GoalItemPayload, RuntimeEvent } from "@/shared/contracts"; +import { + goalControlActionSchema, + type GoalItemPayload, + type RuntimeEvent, +} from "@/shared/contracts"; import { startGoalItemEvents, updateGoalItemEvents } from "../../goalRuntime"; import type { AcpMapperState } from "./state"; import { newItemId } from "./state"; @@ -48,6 +52,8 @@ function readAcpCanonicalGoalUpdate(update: unknown): AcpCanonicalGoalUpdate | u "cancelled", ] as const); const objective = readString(raw.objective); + const availableActionsResult = goalControlActionSchema.array().safeParse(raw.availableActions); + const availableActions = availableActionsResult.success ? availableActionsResult.data : undefined; if (!action && !status && !objective) return undefined; return { @@ -70,6 +76,7 @@ function readAcpCanonicalGoalUpdate(update: unknown): AcpCanonicalGoalUpdate | u ...(readString(raw.providerThreadId) ? { providerThreadId: readString(raw.providerThreadId)! } : {}), + ...(availableActions ? { availableActions } : {}), ...(readFiniteNumber(raw.updatedAt) !== undefined ? { updatedAt: readFiniteNumber(raw.updatedAt)! } : {}), diff --git a/src/supervisor/agents/base/types.ts b/src/supervisor/agents/base/types.ts index b133d2f3..40a4c479 100644 --- a/src/supervisor/agents/base/types.ts +++ b/src/supervisor/agents/base/types.ts @@ -383,6 +383,8 @@ export interface AgentAcpAuth { export interface AgentPromptFormatter { shouldDeferPromptToTerminal?(config: ThreadConfig): boolean; buildTerminalPreInputs?(config: ThreadConfig): string[][] | undefined; + /** Translate a canonical goal control into the provider's native prompt command. */ + buildGoalControlPrompt?(control: ThreadGoalControl): string | undefined; buildDirectInput?( prompt: string, segments?: PromptSegment[], diff --git a/src/supervisor/agents/grok/acpTransform.test.ts b/src/supervisor/agents/grok/acpTransform.test.ts new file mode 100644 index 00000000..6303ef87 --- /dev/null +++ b/src/supervisor/agents/grok/acpTransform.test.ts @@ -0,0 +1,368 @@ +import type { SessionNotification } from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vitest"; +import { createAcpMapperState, mapAcpSessionUpdate } from "../acp/canonicalMapping"; +import { createGrokAcpSessionUpdateTransform } from "./acpTransform"; + +const PARENT_SESSION_ID = "parent-session"; +const CHILD_SESSION_ID = "child-session"; +const TOOL_CALL_ID = "spawn-call"; + +function notification(sessionId: string, update: Record): SessionNotification { + return { sessionId, update } as unknown as SessionNotification; +} + +describe("createGrokAcpSessionUpdateTransform", () => { + it("maps Grok goal extension updates into the canonical goal lifecycle", () => { + const transform = createGrokAcpSessionUpdateTransform(); + const state = createAcpMapperState("thread-1"); + + const started = mapAcpSessionUpdate( + transform( + notification(PARENT_SESSION_ID, { + sessionUpdate: "goal_updated", + goal_id: "goal-1", + objective: "Update README", + status: "active", + phase: "executing", + token_budget: 100_000, + tokens_used: 12_500, + elapsed_ms: 4_500, + total_worker_rounds: 2, + total_verify_rounds: 1, + classifier_runs_attempted: 1, + last_event: "worker_completed", + }), + ), + state, + ); + + expect(started).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "item.started", + itemType: "goal", + payload: expect.objectContaining({ + action: "set", + objective: "Update README", + status: "active", + tokenBudget: 100_000, + tokensUsed: 12_500, + timeUsedSeconds: 4.5, + iterations: 1, + availableActions: ["pause", "clear"], + providerThreadId: "goal-1", + }), + }), + ]), + ); + + const paused = mapAcpSessionUpdate( + transform( + notification(PARENT_SESSION_ID, { + sessionUpdate: "goal_updated", + goal_id: "goal-1", + objective: "Update README", + status: "user_paused", + phase: "executing", + tokens_used: 20_000, + elapsed_ms: 8_000, + total_worker_rounds: 2, + total_verify_rounds: 1, + last_event_detail: "user", + }), + ), + state, + ); + + expect(paused).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "item.updated", + payload: expect.objectContaining({ + action: "updated", + status: "paused", + availableActions: ["resume", "clear"], + lastReason: "user", + }), + }), + ]), + ); + + const cleared = mapAcpSessionUpdate( + transform( + notification(PARENT_SESSION_ID, { + sessionUpdate: "goal_updated", + goal_id: "", + objective: "", + status: "cleared", + phase: "idle", + }), + ), + state, + ); + expect(cleared).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "item.updated", + payload: expect.objectContaining({ action: "cleared", availableActions: [] }), + }), + ]), + ); + }); + + it("routes Grok child-session chat under its background subagent card", () => { + const transform = createGrokAcpSessionUpdateTransform(); + const state = createAcpMapperState("thread-1"); + + const launchEvents = mapAcpSessionUpdate( + transform( + notification(PARENT_SESSION_ID, { + sessionUpdate: "tool_call", + toolCallId: TOOL_CALL_ID, + title: "spawn_subagent", + rawInput: { + prompt: "Review README", + description: "README accuracy review", + subagent_type: "general-purpose", + background: true, + }, + _meta: { + "x.ai/tool": { name: "spawn_subagent" }, + }, + }), + ), + state, + ); + const parentStart = launchEvents.find( + (event) => + event.type === "item.started" && + (event.payload as Record | undefined)?.isSubAgent === true, + ); + const parentItemId = parentStart?.type === "item.started" ? parentStart.itemId : undefined; + expect(parentItemId).toBeDefined(); + + const receipt = transform( + notification(PARENT_SESSION_ID, { + sessionUpdate: "tool_call_update", + toolCallId: TOOL_CALL_ID, + status: "completed", + rawOutput: { type: "Text", text: "Subagent started in background." }, + }), + ); + expect(receipt.update).toMatchObject({ status: "in_progress" }); + expect(mapAcpSessionUpdate(receipt, state)).not.toContainEqual( + expect.objectContaining({ type: "item.completed", itemId: parentItemId }), + ); + + expect( + mapAcpSessionUpdate( + transform( + notification(PARENT_SESSION_ID, { + sessionUpdate: "subagent_spawned", + subagent_id: CHILD_SESSION_ID, + child_session_id: CHILD_SESSION_ID, + parent_session_id: PARENT_SESSION_ID, + subagent_type: "general-purpose", + description: "README accuracy review (provider normalized)", + }), + ), + state, + ), + ).toEqual([]); + + const childEvents = mapAcpSessionUpdate( + transform( + notification(CHILD_SESSION_ID, { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "Inspecting README" }, + }), + ), + state, + ); + expect(childEvents).toContainEqual( + expect.objectContaining({ + type: "item.started", + itemType: "reasoning", + parentItemId, + }), + ); + + const parentEvents = mapAcpSessionUpdate( + transform( + notification(PARENT_SESSION_ID, { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "Waiting for the review" }, + }), + ), + state, + ); + expect(parentEvents).toContainEqual( + expect.objectContaining({ + type: "item.started", + itemType: "reasoning", + }), + ); + expect(parentEvents).not.toContainEqual( + expect.objectContaining({ type: "item.started", parentItemId }), + ); + + expect( + mapAcpSessionUpdate( + transform( + notification(PARENT_SESSION_ID, { + sessionUpdate: "tool_call", + toolCallId: "task-output-call", + title: "get_command_or_subagent_output", + rawInput: { task_ids: [CHILD_SESSION_ID] }, + }), + ), + state, + ), + ).toEqual([]); + + const finished = mapAcpSessionUpdate( + transform( + notification(PARENT_SESSION_ID, { + sessionUpdate: "subagent_finished", + subagent_id: CHILD_SESSION_ID, + child_session_id: CHILD_SESSION_ID, + status: "completed", + output: "README review complete", + }), + ), + state, + ); + expect(finished).toContainEqual( + expect.objectContaining({ + type: "item.completed", + itemId: parentItemId, + payload: expect.objectContaining({ result: "README review complete" }), + }), + ); + + expect( + mapAcpSessionUpdate( + transform( + notification(PARENT_SESSION_ID, { + sessionUpdate: "tool_call_update", + toolCallId: "task-output-call", + status: "completed", + rawOutput: { + type: "TaskOutput", + Result: { + task_id: CHILD_SESSION_ID, + status: "completed", + output: "Full README comparison", + truncated: false, + }, + }, + }), + ), + state, + ), + ).toEqual([]); + + expect( + mapAcpSessionUpdate( + transform( + notification(CHILD_SESSION_ID, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "late child output" }, + }), + ), + state, + ), + ).toEqual([]); + }); + + it("keeps interleaved Grok child-session reasoning under the correct subagent cards", () => { + const transform = createGrokAcpSessionUpdateTransform(); + const state = createAcpMapperState("thread-1"); + const childSessions = ["child-a", "child-b"]; + const parentItemIds = childSessions.map((childSessionId) => { + const toolCallId = `spawn-${childSessionId}`; + const description = `Review ${childSessionId}`; + const events = mapAcpSessionUpdate( + transform( + notification(PARENT_SESSION_ID, { + sessionUpdate: "tool_call", + toolCallId, + title: "spawn_subagent", + rawInput: { + prompt: description, + description, + subagent_type: "explore", + background: true, + }, + }), + ), + state, + ); + mapAcpSessionUpdate( + transform( + notification(PARENT_SESSION_ID, { + sessionUpdate: "subagent_spawned", + child_session_id: childSessionId, + parent_session_id: PARENT_SESSION_ID, + subagent_type: "explore", + description, + }), + ), + state, + ); + const started = events.find( + (event) => + event.type === "item.started" && + (event.payload as Record | undefined)?.isSubAgent === true, + ); + expect(started?.type).toBe("item.started"); + return started?.type === "item.started" ? started.itemId : undefined; + }); + + for (const [index, childSessionId] of childSessions.entries()) { + const events = mapAcpSessionUpdate( + transform( + notification(childSessionId, { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: `Reasoning from ${childSessionId}` }, + }), + ), + state, + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "item.started", + itemType: "reasoning", + parentItemId: parentItemIds[index], + }), + ); + } + }); + + it("suppresses internal Grok goal subagents that have no parent tool card", () => { + const transform = createGrokAcpSessionUpdateTransform(); + const state = createAcpMapperState("thread-1"); + + transform( + notification(PARENT_SESSION_ID, { + sessionUpdate: "subagent_spawned", + subagent_id: CHILD_SESSION_ID, + child_session_id: CHILD_SESSION_ID, + parent_session_id: PARENT_SESSION_ID, + subagent_type: "general-purpose", + description: "goal plan writer", + }), + ); + + const events = mapAcpSessionUpdate( + transform( + notification(CHILD_SESSION_ID, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "internal goal planning" }, + }), + ), + state, + ); + expect(events).toEqual([]); + }); +}); diff --git a/src/supervisor/agents/grok/acpTransform.ts b/src/supervisor/agents/grok/acpTransform.ts new file mode 100644 index 00000000..af2fade8 --- /dev/null +++ b/src/supervisor/agents/grok/acpTransform.ts @@ -0,0 +1,337 @@ +import type { SessionNotification } from "@agentclientprotocol/sdk"; +import { + PORACODE_ACP_GOAL_META_KEY, + PORACODE_ACP_NEW_ASSISTANT_ITEM_META_KEY, + type AcpCanonicalGoalUpdate, +} from "../acp/canonicalMapping"; +import { + createAcpSubagentCoordinator, + normalizeAcpSubagentToolCall, + withAcpSubagentParent, + withAcpTopLevelToolCall, +} from "../acp/subagentCoordinator"; +import type { AcpSessionUpdateTransform } from "../base"; + +const GROK_SPAWN_SUBAGENT_TOOL = "spawn_subagent"; + +export function createGrokAcpSessionUpdateTransform(): AcpSessionUpdateTransform { + const subagents = createAcpSubagentCoordinator(); + const pendingToolCallIds: string[] = []; + const childSessionByToolCallId = new Map(); + const toolCallIdByChildSession = new Map(); + const ignoredChildSessions = new Set(); + const seenGoalIds = new Set(); + let parentSessionId: string | undefined; + let contentOwnerSessionId: string | undefined; + + return (notification) => { + const update = plainRecord(notification.update); + const sessionUpdate = readString(update, "sessionUpdate"); + + if (sessionUpdate === "goal_updated") { + parentSessionId = notification.sessionId; + const goal = readGrokGoalUpdate(update, seenGoalIds); + return goal ? withGoalMeta(notification, goal) : asNoop(notification); + } + + if (sessionUpdate === "subagent_spawned") { + parentSessionId = readString(update, "parent_session_id") ?? notification.sessionId; + const childSessionId = readString(update, "child_session_id"); + const toolCallId = findPendingToolCallId( + pendingToolCallIds, + subagents, + readString(update, "description"), + readString(update, "subagent_type"), + ); + if (childSessionId && toolCallId) { + const taskId = readString(update, "subagent_id") ?? childSessionId; + childSessionByToolCallId.set(toolCallId, childSessionId); + toolCallIdByChildSession.set(childSessionId, toolCallId); + subagents.registerBackgroundLaunch({ + sessionId: parentSessionId, + toolCallId, + taskId, + }); + } else if (childSessionId) { + ignoredChildSessions.add(childSessionId); + } + return asNoop(notification); + } + + if (sessionUpdate === "subagent_finished") { + const childSessionId = readString(update, "child_session_id"); + const toolCallId = childSessionId ? toolCallIdByChildSession.get(childSessionId) : undefined; + if (!childSessionId || !toolCallId) return asNoop(notification); + + const descriptor = subagents.getCall(toolCallId); + if (!descriptor?.background) return asNoop(notification); + + toolCallIdByChildSession.delete(childSessionId); + childSessionByToolCallId.delete(toolCallId); + ignoredChildSessions.add(childSessionId); + const result = readString(update, "output"); + return ( + subagents + .complete({ + sessionId: parentSessionId ?? notification.sessionId, + toolCallId, + status: readGrokSubagentStatus(update), + ...(result ? { result } : {}), + }) + .at(-1) ?? asNoop(notification) + ); + } + + const childToolCallId = toolCallIdByChildSession.get(notification.sessionId); + if (childToolCallId) { + const bounded = withContentBoundary(notification, sessionUpdate, contentOwnerSessionId); + contentOwnerSessionId = bounded.ownerSessionId; + return withAcpSubagentParent(bounded.notification, childToolCallId); + } + if ( + ignoredChildSessions.has(notification.sessionId) || + (parentSessionId && notification.sessionId !== parentSessionId) + ) { + return asNoop(notification); + } + parentSessionId ??= notification.sessionId; + + const bounded = withContentBoundary(notification, sessionUpdate, contentOwnerSessionId); + contentOwnerSessionId = bounded.ownerSessionId; + const boundedNotification = bounded.notification; + + if (sessionUpdate !== "tool_call" && sessionUpdate !== "tool_call_update") { + return boundedNotification; + } + const toolCallId = readString(update, "toolCallId"); + if (!toolCallId) return boundedNotification; + if (isMappedTaskOutput(update, subagents)) return asNoop(notification); + const metaTool = plainRecord(plainRecord(update._meta)["x.ai/tool"]); + const isSpawn = + readString(metaTool, "name") === GROK_SPAWN_SUBAGENT_TOOL || + readString(update, "title") === GROK_SPAWN_SUBAGENT_TOOL || + subagents.getCall(toolCallId) !== undefined; + if (!isSpawn) return boundedNotification; + + const descriptor = subagents.updateCall(toolCallId, { + rawInput: plainRecord(update.rawInput), + }); + if (!pendingToolCallIds.includes(toolCallId)) pendingToolCallIds.push(toolCallId); + const terminal = update.status === "completed" || update.status === "failed"; + const backgroundLaunchReceipt = descriptor.background && terminal; + const normalized = normalizeAcpSubagentToolCall(boundedNotification, { + rawInput: subagents.canonicalInput(toolCallId), + detached: descriptor.background, + keepOpen: backgroundLaunchReceipt, + ...(backgroundLaunchReceipt ? { omitContent: true, omitRawOutput: true } : {}), + }); + + if (terminal && !backgroundLaunchReceipt) { + removePendingToolCallId(pendingToolCallIds, toolCallId); + const childSessionId = childSessionByToolCallId.get(toolCallId); + if (childSessionId) { + toolCallIdByChildSession.delete(childSessionId); + childSessionByToolCallId.delete(toolCallId); + ignoredChildSessions.add(childSessionId); + } + subagents.forgetCall(toolCallId); + } + return sessionUpdate === "tool_call" ? withAcpTopLevelToolCall(normalized) : normalized; + }; +} + +function isMappedTaskOutput( + update: Record, + subagents: ReturnType, +): boolean { + const rawInput = plainRecord(update.rawInput); + const taskIds = readStringArray(rawInput, "task_ids"); + return taskIds.length === 1 && subagents.resolveBackgroundToolCallId(taskIds[0]!) !== undefined; +} + +function withContentBoundary( + notification: SessionNotification, + sessionUpdate: string | undefined, + ownerSessionId: string | undefined, +): { notification: SessionNotification; ownerSessionId: string | undefined } { + if (sessionUpdate !== "agent_message_chunk" && sessionUpdate !== "agent_thought_chunk") { + return { notification, ownerSessionId }; + } + if (!ownerSessionId || ownerSessionId === notification.sessionId) { + return { notification, ownerSessionId: notification.sessionId }; + } + const update = plainRecord(notification.update); + return { + notification: withUpdate(notification, { + ...update, + _meta: { + ...plainRecord(update._meta), + [PORACODE_ACP_NEW_ASSISTANT_ITEM_META_KEY]: true, + }, + }), + ownerSessionId: notification.sessionId, + }; +} + +function readGrokGoalUpdate( + update: Record, + seenGoalIds: Set, +): AcpCanonicalGoalUpdate | undefined { + const goalId = readString(update, "goal_id"); + const objective = readString(update, "objective")?.trim(); + const rawStatus = readString(update, "status"); + if (rawStatus === "cleared") { + return { action: "cleared", availableActions: [] }; + } + if (!goalId || !objective || !rawStatus) return undefined; + + const firstUpdate = !seenGoalIds.has(goalId); + seenGoalIds.add(goalId); + const status = mapGrokGoalStatus(rawStatus); + const lastReason = + readString(update, "pause_message") ?? + readString(update, "last_event_detail") ?? + readString(update, "last_event"); + const evaluationChecks = + readNonNegativeInteger(update, "classifier_runs_attempted") ?? + readNonNegativeInteger(update, "total_verify_rounds") ?? + 0; + const tokenBudget = readNullableNonNegativeNumber(update, "token_budget"); + const tokensUsed = readNonNegativeNumber(update, "tokens_used"); + const elapsedMs = readNonNegativeNumber(update, "elapsed_ms"); + return { + action: firstUpdate ? "set" : "updated", + objective, + ...(status ? { status } : {}), + ...(tokenBudget !== undefined ? { tokenBudget } : {}), + ...(tokensUsed !== undefined ? { tokensUsed } : {}), + ...(elapsedMs !== undefined ? { timeUsedSeconds: elapsedMs / 1000 } : {}), + iterations: evaluationChecks, + availableActions: grokGoalAvailableActions(rawStatus), + ...(lastReason ? { lastReason } : {}), + providerThreadId: goalId, + }; +} + +function grokGoalAvailableActions( + rawStatus: string, +): NonNullable { + if (rawStatus === "active") return ["pause", "clear"]; + if (isGrokPausedGoalStatus(rawStatus)) { + return ["resume", "clear"]; + } + return rawStatus === "cleared" ? [] : ["clear"]; +} + +function mapGrokGoalStatus(rawStatus: string): AcpCanonicalGoalUpdate["status"] { + if (rawStatus === "active") return "active"; + if (rawStatus === "budget_limited") return "budget_limited"; + if (rawStatus === "complete") return "complete"; + if (rawStatus === "cleared") return undefined; + if (isGrokPausedGoalStatus(rawStatus)) { + return "paused"; + } + return undefined; +} + +function isGrokPausedGoalStatus(rawStatus: string): boolean { + return ( + rawStatus === "user_paused" || + rawStatus === "back_off_paused" || + rawStatus === "no_progress_paused" || + rawStatus === "infra_paused" || + rawStatus === "doom_loop_paused" || + rawStatus === "blocked" + ); +} + +function readGrokSubagentStatus( + update: Record, +): "completed" | "failed" | "cancelled" { + const status = readString(update, "status"); + return status === "failed" || status === "cancelled" ? status : "completed"; +} + +function findPendingToolCallId( + pendingToolCallIds: string[], + subagents: ReturnType, + description: string | undefined, + subagentType: string | undefined, +): string | undefined { + const matchingIndex = pendingToolCallIds.findIndex((toolCallId) => { + const descriptor = subagents.getCall(toolCallId); + return ( + descriptor !== undefined && + (!description || descriptor.description === description) && + (!subagentType || descriptor.subagentType === subagentType) + ); + }); + const index = + matchingIndex >= 0 + ? matchingIndex + : pendingToolCallIds.findIndex((toolCallId) => subagents.getCall(toolCallId) !== undefined); + if (index < 0) return undefined; + return pendingToolCallIds.splice(index, 1)[0]; +} + +function removePendingToolCallId(pendingToolCallIds: string[], toolCallId: string): void { + const index = pendingToolCallIds.indexOf(toolCallId); + if (index >= 0) pendingToolCallIds.splice(index, 1); +} + +function withGoalMeta( + notification: SessionNotification, + goal: AcpCanonicalGoalUpdate, +): SessionNotification { + const update = plainRecord(notification.update); + return withUpdate(notification, { + ...update, + _meta: { ...plainRecord(update._meta), [PORACODE_ACP_GOAL_META_KEY]: goal }, + }); +} + +function asNoop(notification: SessionNotification): SessionNotification { + return withUpdate(notification, { sessionUpdate: "session_info_update" }); +} + +function withUpdate( + notification: SessionNotification, + update: Record, +): SessionNotification { + return { ...notification, update: update as SessionNotification["update"] }; +} + +function plainRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function readString(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function readStringArray(record: Record, key: string): string[] { + const value = record[key]; + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0) + : []; +} + +function readNonNegativeNumber(record: Record, key: string): number | undefined { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +function readNullableNonNegativeNumber( + record: Record, + key: string, +): number | null | undefined { + return record[key] === null ? null : readNonNegativeNumber(record, key); +} + +function readNonNegativeInteger(record: Record, key: string): number | undefined { + const value = readNonNegativeNumber(record, key); + return value !== undefined && Number.isInteger(value) ? value : undefined; +} diff --git a/src/supervisor/agents/grok/grok.test.ts b/src/supervisor/agents/grok/grok.test.ts index ebd63ff8..4de82c49 100644 --- a/src/supervisor/agents/grok/grok.test.ts +++ b/src/supervisor/agents/grok/grok.test.ts @@ -95,6 +95,22 @@ describe("createGrokAdapter skill roots", () => { }); }); +describe("createGrokAdapter goal controls", () => { + const adapter = createGrokAdapter(); + + it("maps Grok-supported goal actions to native slash commands", () => { + expect(adapter.buildGoalControlPrompt?.({ action: "pause" })).toBe("/goal pause"); + expect(adapter.buildGoalControlPrompt?.({ action: "resume" })).toBe("/goal resume"); + expect(adapter.buildGoalControlPrompt?.({ action: "clear" })).toBe("/goal clear"); + }); + + it("does not advertise an in-place edit command that Grok lacks", () => { + expect( + adapter.buildGoalControlPrompt?.({ action: "edit", objective: "Replacement goal" }), + ).toBeUndefined(); + }); +}); + describe("grokDetectionSpec", () => { it("uses device auth for WSL login to avoid localhost callback nonce mismatches", () => { expect(typeof grokDetectionSpec.loginCommand).toBe("function"); diff --git a/src/supervisor/agents/grok/index.ts b/src/supervisor/agents/grok/index.ts index 8b8c4b97..e043ddf0 100644 --- a/src/supervisor/agents/grok/index.ts +++ b/src/supervisor/agents/grok/index.ts @@ -15,6 +15,7 @@ import { import { resolveAgentBinaryPath } from "../binaryResolver"; import { resolveInstallNodePath, warnIfPluginManifestMissing } from "../plugin/installerBase"; import { buildGrokAcpArgs, buildGrokArgs } from "./argv"; +import { createGrokAcpSessionUpdateTransform } from "./acpTransform"; import { buildGrokCommand, grokDefaultCapabilities, grokDetectionSpec } from "./detection"; import { installGrokPlugin, @@ -91,6 +92,12 @@ export function createGrokAdapter(): AgentAdapter { handleOscNotification: iterm2ProgressOscHint, handleOscTitle: brailleSpinnerOscTitleHint, + buildGoalControlPrompt(control) { + return control.action === "pause" || control.action === "resume" || control.action === "clear" + ? `/goal ${control.action}` + : undefined; + }, + pluginId: "poracode-status@grok", pluginVersion: GROK_PLUGIN_VERSION, minProtocolVersion: 1, @@ -172,7 +179,10 @@ export function createGrokAdapter(): AgentAdapter { [...acpArgs, "agent", "stdio"], resolveAgentBinaryPath(input.projectLocation, "grok"), ); - return createAcpStructuredSession(command, input); + return createAcpStructuredSession(command, { + ...input, + acpSessionUpdateTransform: createGrokAcpSessionUpdateTransform(), + }); }, async buildAcpAuthCommand(ctx?: AgentEnvContext) { diff --git a/src/supervisor/runtime/threadSessionManager.ts b/src/supervisor/runtime/threadSessionManager.ts index f354a678..367ec0ea 100644 --- a/src/supervisor/runtime/threadSessionManager.ts +++ b/src/supervisor/runtime/threadSessionManager.ts @@ -631,10 +631,13 @@ export class ThreadSessionManager { async controlThreadGoal(payload: ControlThreadGoalPayload): Promise { const { threadId, ...control } = payload; const session = this.requireSession(threadId); - if (!session.structuredSession?.controlGoal) { - throw new Error(`${session.adapter.label} does not support goal controls.`); + if (session.structuredSession?.controlGoal) { + await session.structuredSession.controlGoal(control); + return; } - await session.structuredSession.controlGoal(control); + const prompt = session.adapter.buildGoalControlPrompt?.(control); + if (!prompt) throw new Error(`${session.adapter.label} does not support this goal control.`); + await this.sendThreadInput({ threadId, prompt, config: session.config }); } async rollbackThreadConversation(payload: RollbackThreadConversationPayload): Promise {