From 5f9e0e20d4e453ed3eb22a0de3bc5ba0480772a6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 9 Jul 2026 12:59:50 +0100 Subject: [PATCH] fix: scope task suggestions to supported surfaces (#102743) * fix: make task suggestions surface-aware * fix: honor task suggestion action scopes * fix: align task surface compatibility types * fix: isolate task suggestions across surfaces * style: satisfy task selector lint * chore: defer task suggestion release note * test: dedupe gateway capability fixture * test: cover combined gateway capabilities --- docs/concepts/managed-worktrees.md | 4 +- docs/gateway/config-tools.md | 4 +- packages/gateway-protocol/src/client-info.ts | 1 + src/agents/agent-tools.ts | 8 +- src/agents/cli-runner/prepare.test.ts | 5 + src/agents/cli-runner/prepare.ts | 2 + src/agents/cli-runner/types.ts | 6 +- src/agents/embedded-agent-runner/run-state.ts | 6 +- .../run.overflow-compaction.test.ts | 2 + src/agents/embedded-agent-runner/run.ts | 1 + .../embedded-agent-runner/run/attempt.ts | 2 + .../embedded-agent-runner/run/params.ts | 2 + src/agents/embedded-agent-runner/runs.test.ts | 33 ++ src/agents/embedded-agent-runner/runs.ts | 14 +- src/agents/openclaw-tools.ts | 9 +- src/agents/openclaw-tools.update-plan.test.ts | 15 +- src/auto-reply/get-reply-options.types.ts | 5 + .../reply/agent-runner-execution.ts | 1 + .../reply/agent-runner-run-params.ts | 1 + .../reply/agent-runner-utils.test.ts | 2 + .../reply/agent-runner.media-paths.test.ts | 6 +- src/auto-reply/reply/agent-runner.ts | 4 + src/auto-reply/reply/commands-steer.test.ts | 23 + src/auto-reply/reply/commands-steer.ts | 4 + src/auto-reply/reply/followup-runner.test.ts | 4 + src/auto-reply/reply/followup-runner.ts | 2 + src/auto-reply/reply/get-reply-run.ts | 1 + src/auto-reply/reply/queue.collect.test.ts | 42 ++ src/auto-reply/reply/queue/drain.ts | 1 + src/auto-reply/reply/queue/types.ts | 2 + .../reply/reply-run-registry.test.ts | 34 ++ src/auto-reply/reply/reply-run-registry.ts | 38 +- src/gateway/mcp-http.loopback-runtime.ts | 1 + src/gateway/mcp-http.request.ts | 16 +- src/gateway/mcp-http.runtime.ts | 7 +- src/gateway/mcp-http.test.ts | 16 +- src/gateway/mcp-http.ts | 1 + .../chat.directive-tags.test.ts | 115 ++++- src/gateway/server-methods/chat.ts | 7 + src/gateway/tool-resolution.test.ts | 19 + src/gateway/tool-resolution.ts | 7 +- src/talk/agent-run-control.test.ts | 8 +- src/talk/agent-run-control.ts | 9 +- src/tui/gateway-chat.test.ts | 78 +++ src/tui/gateway-chat.ts | 50 +- src/tui/tui-backend.ts | 11 + src/tui/tui-pty-harness.e2e.test.ts | 79 +++ src/tui/tui-task-suggestions.test.ts | 367 ++++++++++++++ src/tui/tui-task-suggestions.ts | 456 ++++++++++++++++++ src/tui/tui.ts | 26 + ui/src/api/gateway.node.test.ts | 9 +- ui/src/api/gateway.ts | 6 +- 52 files changed, 1540 insertions(+), 32 deletions(-) create mode 100644 src/tui/tui-task-suggestions.test.ts create mode 100644 src/tui/tui-task-suggestions.ts diff --git a/docs/concepts/managed-worktrees.md b/docs/concepts/managed-worktrees.md index 6aee5660ccd..9158ea74265 100644 --- a/docs/concepts/managed-worktrees.md +++ b/docs/concepts/managed-worktrees.md @@ -47,7 +47,9 @@ A nonzero exit aborts creation and removes the new worktree and branch. This is Start an isolated chat from the active agent's git workspace with **New chat in worktree**: use the secondary New Chat action in the Control UI sidebar, the Chat actions menu on iOS, or the overflow action beside New Chat on Android. The action is available only for a git-backed agent where the client has that capability; clients that cannot preflight it surface the gateway error instead. -Coding agents can also call `spawn_task` when they discover confirmed follow-up work outside the current task. The Control UI shows a suggestion chip without starting anything. Selecting **Start in worktree** creates a fresh session-owned worktree from the suggested project and sends the self-contained prompt as its first turn; dismissing the chip leaves the repository untouched. Suggestions and their IDs are ephemeral and do not survive a Gateway restart. +Coding agents can also call `spawn_task` when they discover confirmed follow-up work outside the current task. The Control UI shows a suggestion chip without starting anything, while a Gateway-backed TUI shows an interactive prompt with the same actions. Selecting **Start in worktree** creates a fresh session-owned worktree from the suggested project and sends the self-contained prompt as its first turn; dismissing the suggestion leaves the repository untouched. Suggestions and their IDs are ephemeral and do not survive a Gateway restart. + +OpenClaw exposes these tools only to operator sessions with an actionable Gateway UI. Channel sessions and local/embedded TUI sessions do not receive them until those surfaces have a portable typed task-action contract. The resulting managed worktree is owned by the session, and every agent run in that session uses its checkout. When the workspace is a repository subdirectory, the worktree is anchored at the repository root and the session runs from the matching subdirectory inside it. Session worktree creation uses the method's `operator.write` scope, but the `.openclaw/worktree-setup.sh` step runs only for `operator.admin` callers because it executes repository code; `.worktreeinclude` provisioning still applies to every caller. Deleting the session removes the worktree only when doing so is lossless. Dirty worktrees or branches with unpushed commits stay available; hourly cleanup snapshots session worktrees after 7 idle days, treating recent session activity as worktree activity. Removed worktrees remain restorable from their snapshots as described below. diff --git a/docs/gateway/config-tools.md b/docs/gateway/config-tools.md index d3d7cb8188b..e542ef22098 100644 --- a/docs/gateway/config-tools.md +++ b/docs/gateway/config-tools.md @@ -47,7 +47,9 @@ Local onboarding defaults new local configs to `tools.profile: "coding"` when un | `group:openclaw` | All built-in tools above except `read`/`write`/`edit`/`apply_patch`/`exec`/`process`/`canvas` (excludes plugin tools) | | `group:plugins` | Tools owned by loaded plugins, including configured MCP servers exposed through `bundle-mcp` | -`spawn_task` lets a coding agent propose confirmed follow-up work without starting it. The Control UI shows the title and summary as an actionable chip; accepting it creates a fresh managed-worktree session and sends the full prompt there while the current turn continues. `dismiss_task` withdraws a still-pending suggestion by the ephemeral `task_id` returned from `spawn_task`. Suggestions are process-local and disappear when the Gateway restarts. Both tools are in the `coding` profile and `group:sessions`, so normal `tools.allow` and `tools.deny` policy configures them automatically. +`spawn_task` lets a coding agent propose confirmed follow-up work without starting it. The Control UI shows the title and summary as an actionable chip; a Gateway-backed TUI shows an equivalent interactive prompt. Accepting either creates a fresh managed-worktree session and sends the full prompt there while the current turn continues. `dismiss_task` withdraws a still-pending suggestion by the ephemeral `task_id` returned from `spawn_task`. + +The tools are offered only when the initiating operator surface can receive and action Gateway task-suggestion events. Channel sessions and local/embedded TUI sessions do not receive them; channel transports need a portable typed task action before they can safely expose this flow. Suggestions are process-local and disappear when the Gateway restarts. Both tools remain in the `coding` profile and `group:sessions`, so normal `tools.allow` and `tools.deny` policy configures them automatically when the surface supports them. ### MCP and plugin tools inside sandbox tool policy diff --git a/packages/gateway-protocol/src/client-info.ts b/packages/gateway-protocol/src/client-info.ts index a687aecfb7d..0b1ca5fdee1 100644 --- a/packages/gateway-protocol/src/client-info.ts +++ b/packages/gateway-protocol/src/client-info.ts @@ -74,6 +74,7 @@ export type GatewayClientInfo = { /** Capability flags a client may advertise during the gateway handshake. */ export const GATEWAY_CLIENT_CAPS = { INLINE_WIDGETS: "inline-widgets", + TASK_SUGGESTIONS: "task-suggestions", TOOL_EVENTS: "tool-events", } as const; diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index cc5a9332258..ad80c74513a 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -8,7 +8,10 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "@openclaw/normalization-core/string-coerce"; -import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js"; +import type { + SourceReplyDeliveryMode, + TaskSuggestionDeliveryMode, +} from "../auto-reply/get-reply-options.types.js"; import { HEARTBEAT_RESPONSE_TOOL_NAME } from "../auto-reply/heartbeat-tool-response.js"; import type { ChatType } from "../channels/chat-type.js"; import type { InboundEventKind } from "../channels/inbound-event/kind.js"; @@ -505,6 +508,8 @@ export function createOpenClawCodingTools(options?: { requireExplicitMessageTarget?: boolean; /** Visible source replies must be sent through the message tool when set to message_tool_only. */ sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + /** Action sink available for model-proposed follow-up tasks. */ + taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; inboundEventKind?: InboundEventKind; /** If true, omit the message tool from the tool list. */ disableMessageTool?: boolean; @@ -1040,6 +1045,7 @@ export function createOpenClawCodingTools(options?: { modelHasVision: options?.modelHasVision, requireExplicitMessageTarget: options?.requireExplicitMessageTarget, sourceReplyDeliveryMode: options?.sourceReplyDeliveryMode, + taskSuggestionDeliveryMode: options?.taskSuggestionDeliveryMode, inboundEventKind: options?.inboundEventKind, disableMessageTool: options?.disableMessageTool, enableHeartbeatTool, diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index e6bd9a58778..8d9788e7193 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -128,6 +128,8 @@ function createTestMcpLoopbackServerConfig(port: number) { "x-openclaw-current-inbound-audio": "${OPENCLAW_MCP_CURRENT_INBOUND_AUDIO}", "x-openclaw-inbound-event-kind": "${OPENCLAW_MCP_INBOUND_EVENT_KIND}", "x-openclaw-source-reply-delivery-mode": "${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}", + "x-openclaw-task-suggestion-delivery-mode": + "${OPENCLAW_MCP_TASK_SUGGESTION_DELIVERY_MODE}", "x-openclaw-require-explicit-message-target": "${OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET}", "x-openclaw-cli-capture-key": "${OPENCLAW_MCP_CLI_CAPTURE_KEY}", @@ -2968,6 +2970,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => { currentMessageId: "reply-message-1", currentInboundAudio: true, sourceReplyDeliveryMode: "message_tool_only", + taskSuggestionDeliveryMode: "gateway", requireExplicitMessageTarget: true, }); @@ -2980,12 +2983,14 @@ describe("shouldSkipLocalCliCredentialEpoch", () => { OPENCLAW_MCP_CURRENT_INBOUND_AUDIO: "true", OPENCLAW_MCP_INBOUND_EVENT_KIND: "room_event", OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE: "message_tool_only", + OPENCLAW_MCP_TASK_SUGGESTION_DELIVERY_MODE: "gateway", OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET: "true", OPENCLAW_MCP_CLI_CAPTURE_KEY: "", }); expect(context.mcpDeliveryCapture).toBe(true); expect(resolveMcpLoopbackScopedTools).toHaveBeenCalledWith( expect.objectContaining({ + taskSuggestionDeliveryMode: "gateway", requireExplicitMessageTarget: true, }), ); diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index ce45569fbad..372121196c0 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -595,6 +595,7 @@ export async function prepareCliRunContext( OPENCLAW_MCP_CURRENT_INBOUND_AUDIO: params.currentInboundAudio === true ? "true" : "", OPENCLAW_MCP_INBOUND_EVENT_KIND: params.currentInboundEventKind ?? "", OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE: params.sourceReplyDeliveryMode ?? "", + OPENCLAW_MCP_TASK_SUGGESTION_DELIVERY_MODE: params.taskSuggestionDeliveryMode ?? "", OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET: requireExplicitMessageTarget ? "true" : "", @@ -718,6 +719,7 @@ export async function prepareCliRunContext( accountId: params.agentAccountId, inboundEventKind: undefined, sourceReplyDeliveryMode: bindingSourceReplyDeliveryMode, + taskSuggestionDeliveryMode: params.taskSuggestionDeliveryMode, requireExplicitMessageTarget: bindingRequireExplicitMessageTarget, senderIsOwner: undefined, }).tools diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index 994b3d0e0b8..25ee6151340 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -1,7 +1,10 @@ /** * Shared types for preparing and executing CLI-backed agent runs. */ -import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js"; +import type { + SourceReplyDeliveryMode, + TaskSuggestionDeliveryMode, +} from "../../auto-reply/get-reply-options.types.js"; import type { ReplyOperation } from "../../auto-reply/reply/reply-run-registry.js"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { FastMode } from "../../auto-reply/thinking.shared.js"; @@ -91,6 +94,7 @@ export type RunCliAgentParams = { jobId?: string; extraSystemPrompt?: string; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; requireExplicitMessageTarget?: boolean; silentReplyPromptMode?: SilentReplyPromptMode; allowEmptyAssistantReplyAsSilent?: boolean; diff --git a/src/agents/embedded-agent-runner/run-state.ts b/src/agents/embedded-agent-runner/run-state.ts index 60ef60ae4ad..302f7f72950 100644 --- a/src/agents/embedded-agent-runner/run-state.ts +++ b/src/agents/embedded-agent-runner/run-state.ts @@ -1,7 +1,10 @@ /** * Shared process-local state for active and abandoned embedded-agent runs. */ -import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js"; +import type { + SourceReplyDeliveryMode, + TaskSuggestionDeliveryMode, +} from "../../auto-reply/get-reply-options.types.js"; import { getActiveReplyRunCount, listActiveReplyRunSessionKeys, @@ -29,6 +32,7 @@ export type EmbeddedAgentQueueHandle = { cancel?: (reason?: "user_abort" | "restart" | "superseded") => void; abort: (reason?: "restart") => void; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; }; export type EmbeddedAgentQueueMessageOptions = ReplyBackendQueueMessageOptions; diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts index 308d9479777..04300dda27c 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts @@ -72,6 +72,7 @@ function makeForwardingCase(internalEvents: AgentInternalEvent[]) { bootstrapContextRunKind: "cron", disableMessageTool: true, forceMessageTool: true, + taskSuggestionDeliveryMode: "gateway", requireExplicitMessageTarget: true, chatType: "channel", senderIsOwner: true, @@ -84,6 +85,7 @@ function makeForwardingCase(internalEvents: AgentInternalEvent[]) { bootstrapContextRunKind: "cron", disableMessageTool: true, forceMessageTool: true, + taskSuggestionDeliveryMode: "gateway", requireExplicitMessageTarget: true, chatType: "channel", senderIsOwner: true, diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index e6fc46cf79e..7eaea8859f8 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -2274,6 +2274,7 @@ async function runEmbeddedAgentInternal( onExecutionPhase: params.onExecutionPhase, extraSystemPrompt: params.extraSystemPrompt, sourceReplyDeliveryMode: params.sourceReplyDeliveryMode, + taskSuggestionDeliveryMode: params.taskSuggestionDeliveryMode, inputProvenance: params.inputProvenance, streamParams: params.streamParams, modelRun: params.modelRun, diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index eb209e07c55..742bbd9497f 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -1422,6 +1422,7 @@ export async function runEmbeddedAttempt( requireExplicitMessageTarget: params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey), sourceReplyDeliveryMode: params.sourceReplyDeliveryMode, + taskSuggestionDeliveryMode: params.taskSuggestionDeliveryMode, inboundEventKind: params.currentInboundEventKind, disableMessageTool: params.disableMessageTool, forceMessageTool: params.forceMessageTool, @@ -3898,6 +3899,7 @@ export async function runEmbeddedAttempt( isCompacting: () => subscription.isCompacting(), supportsTranscriptCommitWait: true, sourceReplyDeliveryMode: params.sourceReplyDeliveryMode, + taskSuggestionDeliveryMode: params.taskSuggestionDeliveryMode, cancel: abortActiveRunExternally, abort: (reason) => abortActiveRunExternally(reason), }; diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index 0c0487ccffd..18a1ad92b69 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -6,6 +6,7 @@ import type { BlockReplyContext, PartialReplyPayload, SourceReplyDeliveryMode, + TaskSuggestionDeliveryMode, } from "../../../auto-reply/get-reply-options.types.js"; import type { ReplyPayload } from "../../../auto-reply/reply-payload.js"; import type { ReplyOperation } from "../../../auto-reply/reply/reply-run-registry.js"; @@ -272,6 +273,7 @@ export type RunEmbeddedAgentParams = { enqueue?: CommandQueueEnqueueFn; extraSystemPrompt?: string; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; silentReplyPromptMode?: SilentReplyPromptMode; internalEvents?: AgentInternalEvent[]; inputProvenance?: InputProvenance; diff --git a/src/agents/embedded-agent-runner/runs.test.ts b/src/agents/embedded-agent-runner/runs.test.ts index 40d80b0f0ae..8b57bd138ee 100644 --- a/src/agents/embedded-agent-runner/runs.test.ts +++ b/src/agents/embedded-agent-runner/runs.test.ts @@ -491,6 +491,39 @@ describe("embedded-agent runner run registry", () => { expect(queueMessage).not.toHaveBeenCalled(); }); + it.each([ + { + label: "capable prompt into an incapable run", + handleMode: undefined, + requestMode: "gateway" as const, + }, + { + label: "incapable prompt into a capable run", + handleMode: "gateway" as const, + requestMode: undefined, + }, + ])("rejects $label", ({ handleMode, requestMode }) => { + const queueMessage = vi.fn(async () => {}); + setActiveEmbeddedRun("session-task-suggestions", { + ...createRunHandle(), + taskSuggestionDeliveryMode: handleMode, + queueMessage, + }); + + const outcome = queueEmbeddedAgentMessageWithOutcome("session-task-suggestions", "continue", { + steeringMode: "all", + taskSuggestionDeliveryMode: requestMode, + }); + + expect(outcome).toEqual({ + queued: false, + sessionId: "session-task-suggestions", + reason: "task_suggestion_delivery_mode_mismatch", + gatewayHealth: "live", + }); + expect(queueMessage).not.toHaveBeenCalled(); + }); + it("defaults active embedded steering to all pending messages", () => { const queueMessage = vi.fn(async () => {}); setActiveEmbeddedRun("session-default-steer", { diff --git a/src/agents/embedded-agent-runner/runs.ts b/src/agents/embedded-agent-runner/runs.ts index 8250f5fa6d1..7577e67b64f 100644 --- a/src/agents/embedded-agent-runner/runs.ts +++ b/src/agents/embedded-agent-runner/runs.ts @@ -12,6 +12,7 @@ import { isReplyRunStreamingForSessionId, listActiveReplyRunSessionIds, queueReplyRunMessage, + resolveReplyBackendQueueMessageMismatch, waitForReplyRunEndBySessionId, } from "../../auto-reply/reply/reply-run-registry.js"; import { @@ -63,6 +64,7 @@ export type EmbeddedAgentQueueFailureReason = | "stale_run" | "compacting" | "source_reply_delivery_mode_mismatch" + | "task_suggestion_delivery_mode_mismatch" | "transcript_commit_wait_unsupported" | "runtime_rejected"; @@ -515,16 +517,12 @@ function prepareEmbeddedAgentQueueMessage( outcome: createQueueFailureOutcome(sessionId, "transcript_commit_wait_unsupported"), }; } - if ( - options?.sourceReplyDeliveryMode === "message_tool_only" && - handle.sourceReplyDeliveryMode !== "message_tool_only" - ) { - diag.debug( - `queue message failed: sessionId=${sessionId} reason=source_reply_delivery_mode_mismatch`, - ); + const deliveryModeMismatch = resolveReplyBackendQueueMessageMismatch(handle, options); + if (deliveryModeMismatch) { + diag.debug(`queue message failed: sessionId=${sessionId} reason=${deliveryModeMismatch}`); return { kind: "complete", - outcome: createQueueFailureOutcome(sessionId, "source_reply_delivery_mode_mismatch"), + outcome: createQueueFailureOutcome(sessionId, deliveryModeMismatch), }; } return { kind: "embedded_run", handle }; diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 3a63417dde7..fb2e8231123 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -4,7 +4,10 @@ * Creates the per-run tool inventory from config, channel context, sandbox policy, auth stores, and plugin tools. */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js"; +import type { + SourceReplyDeliveryMode, + TaskSuggestionDeliveryMode, +} from "../auto-reply/get-reply-options.types.js"; import type { InboundEventKind } from "../channels/inbound-event/kind.js"; import { selectApplicableRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -173,6 +176,8 @@ export function createOpenClawTools( requireExplicitMessageTarget?: boolean; /** Visible source replies must be sent through the message tool when set to message_tool_only. */ sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + /** Action sink available for model-proposed follow-up tasks. */ + taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; inboundEventKind?: InboundEventKind; /** If true, omit the message tool from the tool list. */ disableMessageTool?: boolean; @@ -472,7 +477,7 @@ export function createOpenClawTools( : {}), }), ]), - ...(!embedded && taskSuggestionSessionKey + ...(!embedded && taskSuggestionSessionKey && options?.taskSuggestionDeliveryMode === "gateway" ? createTaskSuggestionTools({ sessionKey: taskSuggestionSessionKey, agentId: sessionAgentId, diff --git a/src/agents/openclaw-tools.update-plan.test.ts b/src/agents/openclaw-tools.update-plan.test.ts index c6e928b712c..e15c7c437f9 100644 --- a/src/agents/openclaw-tools.update-plan.test.ts +++ b/src/agents/openclaw-tools.update-plan.test.ts @@ -126,20 +126,29 @@ describe("openclaw-tools update_plan gating", () => { expect(enabledTools).toContain("transcripts"); }); - it("registers task suggestions for gateway-backed sessions", () => { + it("registers task suggestions only for sessions with an actionable gateway sink", () => { const withoutSession = createFastToolNames({ config: {} as OpenClawConfig, cwd: "/repo", + taskSuggestionDeliveryMode: "gateway", }); - const withSession = createFastToolNames({ + const withoutSink = createFastToolNames({ config: {} as OpenClawConfig, agentSessionKey: "agent:main:main", cwd: "/repo", }); + const withSink = createFastToolNames({ + config: {} as OpenClawConfig, + agentSessionKey: "agent:main:main", + cwd: "/repo", + taskSuggestionDeliveryMode: "gateway", + }); expect(withoutSession).not.toContain("spawn_task"); expect(withoutSession).not.toContain("dismiss_task"); - expect(withSession).toEqual(expect.arrayContaining(["spawn_task", "dismiss_task"])); + expect(withoutSink).not.toContain("spawn_task"); + expect(withoutSink).not.toContain("dismiss_task"); + expect(withSink).toEqual(expect.arrayContaining(["spawn_task", "dismiss_task"])); }); it("keeps explicitly allowed message tool in embedded completions", () => { diff --git a/src/auto-reply/get-reply-options.types.ts b/src/auto-reply/get-reply-options.types.ts index 11bc11997e3..a332f80d9a3 100644 --- a/src/auto-reply/get-reply-options.types.ts +++ b/src/auto-reply/get-reply-options.types.ts @@ -36,6 +36,9 @@ export type ReplyThreadingPolicy = { export type SourceReplyDeliveryMode = "automatic" | "message_tool_only"; +/** Action sink available for model-proposed follow-up tasks during this turn. */ +export type TaskSuggestionDeliveryMode = "gateway"; + /** Correlates queued reply ownership transfer with later delivery drains. */ export type QueuedReplyDeliveryCorrelation = { begin: () => (() => void) | void; @@ -245,6 +248,8 @@ export type GetReplyOptions = { * private unless dispatch explicitly marks a source reply as deliverable. */ sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + /** Enables task-suggestion tools only when the initiating surface can action Gateway events. */ + taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; /** Starts delivery tracking when this turn later drains as a queued followup. */ queuedDeliveryCorrelations?: QueuedReplyDeliveryCorrelation[]; /** Tracks ownership transfer when this turn later drains as a queued followup. */ diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index edddd3fed00..8e4b58a5cbb 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -2543,6 +2543,7 @@ async function runAgentTurnWithFallbackInternal( lane: runLane, extraSystemPrompt: params.followupRun.run.extraSystemPrompt, sourceReplyDeliveryMode: params.followupRun.run.sourceReplyDeliveryMode, + taskSuggestionDeliveryMode: params.followupRun.run.taskSuggestionDeliveryMode, silentReplyPromptMode: params.followupRun.run.silentReplyPromptMode, allowEmptyAssistantReplyAsSilent: params.followupRun.run.allowEmptyAssistantReplyAsSilent, diff --git a/src/auto-reply/reply/agent-runner-run-params.ts b/src/auto-reply/reply/agent-runner-run-params.ts index 7fb19cd47a9..0a19dfdf546 100644 --- a/src/auto-reply/reply/agent-runner-run-params.ts +++ b/src/auto-reply/reply/agent-runner-run-params.ts @@ -102,6 +102,7 @@ export function buildEmbeddedRunBaseParams(params: { silentReplyPromptMode: params.run.silentReplyPromptMode, sourceReplyDeliveryMode: params.run.sourceReplyDeliveryMode, clientCaps: params.run.clientCaps, + taskSuggestionDeliveryMode: params.run.taskSuggestionDeliveryMode, provider: params.provider, model: params.model, modelFallbacksOverride, diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index d920fc4aade..d11e0a4c001 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -128,6 +128,7 @@ describe("agent-runner-utils", () => { const run = makeRun({ enforceFinalTag: true, cwd: "/tmp/task-repo", + taskSuggestionDeliveryMode: "gateway", }); const authProfile = resolveProviderScopedAuthProfile({ provider: "openai", @@ -165,6 +166,7 @@ describe("agent-runner-utils", () => { expect(resolved.timeoutMs).toBe(run.timeoutMs); expect(resolved.runId).toBe("run-1"); expect(resolved.promptCacheKey).toBe("webchat-cache-key"); + expect(resolved.taskSuggestionDeliveryMode).toBe("gateway"); }); it("threads prompt cache affinity through embedded execution params", () => { diff --git a/src/auto-reply/reply/agent-runner.media-paths.test.ts b/src/auto-reply/reply/agent-runner.media-paths.test.ts index 73606a181cd..2db4db99a75 100644 --- a/src/auto-reply/reply/agent-runner.media-paths.test.ts +++ b/src/auto-reply/reply/agent-runner.media-paths.test.ts @@ -424,6 +424,8 @@ describe("runReplyAgent media path normalization", () => { target: "embedded_run", gatewayHealth: "live", })); + const followupRun = createMockFollowupRun({ prompt: "generate chart" }); + followupRun.run.taskSuggestionDeliveryMode = "gateway"; await runReplyAgent( makeRunReplyAgentParams({ @@ -432,6 +434,7 @@ describe("runReplyAgent media path normalization", () => { shouldFollowup: true, isActive: true, isStreaming: false, + followupRun, }), ); @@ -440,6 +443,7 @@ describe("runReplyAgent media path normalization", () => { "generate chart", { steeringMode: "all", + taskSuggestionDeliveryMode: "gateway", }, ); expect(enqueueFollowupRunMock).not.toHaveBeenCalled(); @@ -479,7 +483,7 @@ describe("runReplyAgent media path normalization", () => { expect(queueEmbeddedAgentMessageWithOutcomeAsyncMock).toHaveBeenLastCalledWith( "session", "summarize the audio", - { steeringMode: "all" }, + { steeringMode: "all", taskSuggestionDeliveryMode: undefined }, ); expect(enqueueFollowupRunMock).not.toHaveBeenCalled(); }); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 1e142300ae3..7567af4170c 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -1270,6 +1270,10 @@ export async function runReplyAgent(params: { { steeringMode: "all", ...(resolvedQueue.debounceMs !== undefined ? { debounceMs: resolvedQueue.debounceMs } : {}), + ...(followupRun.run.sourceReplyDeliveryMode + ? { sourceReplyDeliveryMode: followupRun.run.sourceReplyDeliveryMode } + : {}), + taskSuggestionDeliveryMode: followupRun.run.taskSuggestionDeliveryMode, ...(followupRun.userTurnTranscriptRecorder ? { userTurnTranscriptRecorder: followupRun.userTurnTranscriptRecorder } : {}), diff --git a/src/auto-reply/reply/commands-steer.test.ts b/src/auto-reply/reply/commands-steer.test.ts index b0d18dcbcd6..bca57884115 100644 --- a/src/auto-reply/reply/commands-steer.test.ts +++ b/src/auto-reply/reply/commands-steer.test.ts @@ -62,6 +62,25 @@ describe("handleSteerCommand", () => { { steeringMode: "all", debounceMs: 0, + taskSuggestionDeliveryMode: undefined, + }, + ); + }); + + it("passes the initiating surface task capability into steering", async () => { + steerRuntimeMocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("session-active"); + const params = buildParams("/steer keep going"); + params.opts = { taskSuggestionDeliveryMode: "gateway" }; + + await handleSteerCommand(params, true); + + expect(steerRuntimeMocks.queueEmbeddedAgentMessageWithOutcomeAsync).toHaveBeenCalledWith( + "session-active", + "keep going", + { + steeringMode: "all", + debounceMs: 0, + taskSuggestionDeliveryMode: "gateway", }, ); }); @@ -85,6 +104,7 @@ describe("handleSteerCommand", () => { { steeringMode: "all", debounceMs: 0, + taskSuggestionDeliveryMode: undefined, }, ); }); @@ -107,6 +127,7 @@ describe("handleSteerCommand", () => { { steeringMode: "all", debounceMs: 0, + taskSuggestionDeliveryMode: undefined, }, ); }); @@ -145,6 +166,7 @@ describe("handleSteerCommand", () => { { steeringMode: "all", debounceMs: 0, + taskSuggestionDeliveryMode: undefined, }, ); }); @@ -173,6 +195,7 @@ describe("handleSteerCommand", () => { { steeringMode: "all", debounceMs: 0, + taskSuggestionDeliveryMode: undefined, }, ); }); diff --git a/src/auto-reply/reply/commands-steer.ts b/src/auto-reply/reply/commands-steer.ts index e317917587c..9728819d17d 100644 --- a/src/auto-reply/reply/commands-steer.ts +++ b/src/auto-reply/reply/commands-steer.ts @@ -173,6 +173,10 @@ export const handleSteerCommand: CommandHandler = async (params, allowTextComman const queueOutcome = await queueEmbeddedAgentMessageWithOutcomeAsync(sessionId, message, { steeringMode: "all", debounceMs: 0, + ...(params.opts?.sourceReplyDeliveryMode + ? { sourceReplyDeliveryMode: params.opts.sourceReplyDeliveryMode } + : {}), + taskSuggestionDeliveryMode: params.opts?.taskSuggestionDeliveryMode, }).catch((err: unknown): CommandHandlerResult => { return continueWithSteerFallback( params, diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index bc5f47487a6..567f0143253 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -1460,6 +1460,7 @@ describe("createFollowupRunner runtime config", () => { model: "claude-opus-4-7", suppressNextUserMessagePersistence: true, sourceReplyDeliveryMode: "message_tool_only", + taskSuggestionDeliveryMode: "gateway", allowEmptyAssistantReplyAsSilent: true, }, }), @@ -1473,6 +1474,7 @@ describe("createFollowupRunner runtime config", () => { expect(call.currentInboundAudio).toBe(true); expect(call.suppressNextUserMessagePersistence).toBe(true); expect(call.sourceReplyDeliveryMode).toBe("message_tool_only"); + expect(call.taskSuggestionDeliveryMode).toBe("gateway"); expect(call.allowEmptyAssistantReplyAsSilent).toBe(true); expect(call.cliSessionId).toBe("cli-session-1"); expect(call.cliSessionBinding).toEqual({ sessionId: "cli-session-1" }); @@ -2448,6 +2450,7 @@ describe("createFollowupRunner runtime config", () => { provider: "openai", model: "gpt-5.4", sourceReplyDeliveryMode: "message_tool_only", + taskSuggestionDeliveryMode: "gateway", }, }), ); @@ -2462,6 +2465,7 @@ describe("createFollowupRunner runtime config", () => { expect(fallbackCall.sessionId).toBe("session"); expect(call.abortSignal).toBe(fallbackCall.abortSignal); expect(call.currentInboundAudio).toBe(true); + expect(call.taskSuggestionDeliveryMode).toBe("gateway"); }); it("does not inherit source abort signals for queued user followups", async () => { diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index b51688ccb3c..b193a71476a 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -1195,6 +1195,7 @@ export function createFollowupRunner(params: { runId, extraSystemPrompt: run.extraSystemPrompt, sourceReplyDeliveryMode: run.sourceReplyDeliveryMode, + taskSuggestionDeliveryMode: run.taskSuggestionDeliveryMode, silentReplyPromptMode: run.silentReplyPromptMode, allowEmptyAssistantReplyAsSilent: run.allowEmptyAssistantReplyAsSilent, extraSystemPromptStatic: run.extraSystemPromptStatic, @@ -1300,6 +1301,7 @@ export function createFollowupRunner(params: { extraSystemPrompt: run.extraSystemPrompt, silentReplyPromptMode: run.silentReplyPromptMode, sourceReplyDeliveryMode: run.sourceReplyDeliveryMode, + taskSuggestionDeliveryMode: run.taskSuggestionDeliveryMode, forceMessageTool: run.sourceReplyDeliveryMode === "message_tool_only", suppressNextUserMessagePersistence: suppressQueuedUserPersistenceForCandidate, onUserMessagePersisted: notifyUserMessagePersisted, diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 9e143f23d4c..d89c4f478e9 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -1557,6 +1557,7 @@ export async function runPreparedReply( inputProvenance, extraSystemPrompt: extraSystemPromptParts.join("\n\n") || undefined, sourceReplyDeliveryMode, + taskSuggestionDeliveryMode: opts?.taskSuggestionDeliveryMode, silentReplyPromptMode, extraSystemPromptStatic, cliSessionBindingFacts, diff --git a/src/auto-reply/reply/queue.collect.test.ts b/src/auto-reply/reply/queue.collect.test.ts index 2df0e108a27..ffc1e805ed9 100644 --- a/src/auto-reply/reply/queue.collect.test.ts +++ b/src/auto-reply/reply/queue.collect.test.ts @@ -348,6 +348,48 @@ describe("followup queue collect routing", () => { ]); }); + it("does not collect when task suggestion delivery differs", async () => { + const key = `test-collect-diff-task-suggestion-delivery-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + const createTaskRun = (prompt: string, taskSuggestionDeliveryMode?: "gateway") => { + const base = createRun({ + prompt, + originatingChannel: "webchat", + originatingTo: "same-target", + originatingChatType: "direct", + }); + return { + ...base, + run: { + ...base.run, + taskSuggestionDeliveryMode, + }, + }; + }; + + enqueueFollowupRun(key, createTaskRun("legacy client"), settings); + enqueueFollowupRun(key, createTaskRun("actionable client", "gateway"), settings); + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + if (calls.length >= 2) { + done.resolve(); + } + }); + await done.promise; + + expect(calls.map((call) => call.run.taskSuggestionDeliveryMode)).toEqual([ + undefined, + "gateway", + ]); + }); + it("keeps overflow summaries on the dropped source chat type", async () => { const key = `test-collect-overflow-chat-type-${Date.now()}`; const calls: FollowupRun[] = []; diff --git a/src/auto-reply/reply/queue/drain.ts b/src/auto-reply/reply/queue/drain.ts index c6bc7d974ab..fd401c04f26 100644 --- a/src/auto-reply/reply/queue/drain.ts +++ b/src/auto-reply/reply/queue/drain.ts @@ -161,6 +161,7 @@ export function resolveFollowupDeliveryContextKey(run: FollowupRun): string { execution.extraSystemPrompt ?? "", execution.extraSystemPromptStatic ?? "", execution.sourceReplyDeliveryMode ?? "", + execution.taskSuggestionDeliveryMode ?? "", execution.silentReplyPromptMode ?? "", execution.enforceFinalTag === true, execution.skipProviderRuntimeHints === true, diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index 68ffafcb512..ccd379b6b42 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -19,6 +19,7 @@ import type { QueuedReplyDeliveryCorrelation, QueuedReplyLifecycle, SourceReplyDeliveryMode, + TaskSuggestionDeliveryMode, } from "../../get-reply-options.types.js"; import type { OriginatingChannelType } from "../../templating.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../directives.js"; @@ -153,6 +154,7 @@ export type FollowupRun = { inputProvenance?: InputProvenance; extraSystemPrompt?: string; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; silentReplyPromptMode?: SilentReplyPromptMode; extraSystemPromptStatic?: string; cliSessionBindingFacts?: CliSessionBindingFacts; diff --git a/src/auto-reply/reply/reply-run-registry.test.ts b/src/auto-reply/reply/reply-run-registry.test.ts index 09c7beb222e..8f5114283cb 100644 --- a/src/auto-reply/reply/reply-run-registry.test.ts +++ b/src/auto-reply/reply/reply-run-registry.test.ts @@ -829,6 +829,40 @@ describe("reply run registry", () => { expect(queueMessage).toHaveBeenCalledWith("hello"); }); + it("queues messages only when the task-suggestion tool surface matches", () => { + const queueMessage = vi.fn(async () => {}); + const operation = createReplyOperation({ + sessionKey: "agent:main:main", + sessionId: "session-task-suggestions", + resetTriggered: false, + }); + operation.attachBackend({ + kind: "embedded", + taskSuggestionDeliveryMode: "gateway", + cancel: vi.fn(), + isStreaming: () => true, + queueMessage, + }); + operation.setPhase("running"); + + expect( + queueReplyRunMessage("session-task-suggestions", "legacy client", { + taskSuggestionDeliveryMode: undefined, + }), + ).toBe(false); + expect( + queueReplyRunMessage("session-task-suggestions", "capable client", { + taskSuggestionDeliveryMode: "gateway", + }), + ).toBe(true); + expect(queueReplyRunMessage("session-task-suggestions", "internal completion")).toBe(true); + expect(queueMessage).toHaveBeenCalledTimes(2); + expect(queueMessage).toHaveBeenNthCalledWith(1, "capable client", { + taskSuggestionDeliveryMode: "gateway", + }); + expect(queueMessage).toHaveBeenNthCalledWith(2, "internal completion"); + }); + it("queues messages through active non-streaming backends with live stopped state", () => { const queueMessage = vi.fn(async () => {}); const operation = createReplyOperation({ diff --git a/src/auto-reply/reply/reply-run-registry.ts b/src/auto-reply/reply/reply-run-registry.ts index b6748bcc0ae..0d7687ecd76 100644 --- a/src/auto-reply/reply/reply-run-registry.ts +++ b/src/auto-reply/reply/reply-run-registry.ts @@ -15,7 +15,10 @@ import { diagnosticLogger as diag } from "../../logging/diagnostic-runtime.js"; import type { UserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.types.js"; import { resolveGlobalSingleton } from "../../shared/global-singleton.js"; import { resolveTimerTimeoutMs } from "../../shared/number-coercion.js"; -import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js"; +import type { + SourceReplyDeliveryMode, + TaskSuggestionDeliveryMode, +} from "../get-reply-options.types.js"; import type { ReplyFollowupAdmissionBarrierTimeoutPolicy } from "./reply-dispatcher.types.js"; export type ReplyRunKey = string; @@ -30,12 +33,15 @@ export type ReplyBackendQueueMessageOptions = { deliveryTimeoutMs?: number; waitForTranscriptCommit?: boolean; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; /** Prepared channel turn to merge only at transcript persistence. */ userTurnTranscriptRecorder?: UserTurnTranscriptRecorder; }; export type ReplyBackendHandle = { readonly kind: ReplyBackendKind; + readonly sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + readonly taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; cancel(reason?: ReplyBackendCancelReason): void; isStreaming(): boolean; isStopped?: () => boolean; @@ -48,6 +54,33 @@ export type ReplyBackendHandle = { isCompacting?: () => boolean; }; +export type ReplyBackendQueueMessageMismatch = + | "source_reply_delivery_mode_mismatch" + | "task_suggestion_delivery_mode_mismatch"; + +/** Prevents steering a turn into a run whose model-facing tool surface differs. */ +export function resolveReplyBackendQueueMessageMismatch( + backend: Pick, + options?: ReplyBackendQueueMessageOptions, +): ReplyBackendQueueMessageMismatch | undefined { + if ( + options?.sourceReplyDeliveryMode === "message_tool_only" && + backend.sourceReplyDeliveryMode !== "message_tool_only" + ) { + return "source_reply_delivery_mode_mismatch"; + } + // User turns carry this own property even when disabled; internal wakeups + // omit it so they inherit the active run's already-negotiated tool surface. + if ( + options !== undefined && + Object.hasOwn(options, "taskSuggestionDeliveryMode") && + options?.taskSuggestionDeliveryMode !== backend.taskSuggestionDeliveryMode + ) { + return "task_suggestion_delivery_mode_mismatch"; + } + return undefined; +} + export type ReplyOperationPhase = | "queued" | "preflight_compacting" @@ -1014,6 +1047,9 @@ export function queueReplyRunMessage( if (!isReplyBackendMessageInjectable(backend)) { return false; } + if (resolveReplyBackendQueueMessageMismatch(backend, options)) { + return false; + } // Injection is user input, not run evidence: stamping activity here would let // sub-10-minute user messages re-arm a wedged run's staleness window forever. const queued = options ? backend.queueMessage(text, options) : backend.queueMessage(text); diff --git a/src/gateway/mcp-http.loopback-runtime.ts b/src/gateway/mcp-http.loopback-runtime.ts index 1b7b5bb526c..0e105ed319f 100644 --- a/src/gateway/mcp-http.loopback-runtime.ts +++ b/src/gateway/mcp-http.loopback-runtime.ts @@ -398,6 +398,7 @@ const MCP_CONTEXT_HEADERS = { "x-openclaw-current-inbound-audio": "${OPENCLAW_MCP_CURRENT_INBOUND_AUDIO}", "x-openclaw-inbound-event-kind": "${OPENCLAW_MCP_INBOUND_EVENT_KIND}", "x-openclaw-source-reply-delivery-mode": "${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}", + "x-openclaw-task-suggestion-delivery-mode": "${OPENCLAW_MCP_TASK_SUGGESTION_DELIVERY_MODE}", "x-openclaw-require-explicit-message-target": "${OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET}", "x-openclaw-cli-capture-key": "${OPENCLAW_MCP_CLI_CAPTURE_KEY}", } as const; diff --git a/src/gateway/mcp-http.request.ts b/src/gateway/mcp-http.request.ts index 96f0c44a738..eb24508c2dc 100644 --- a/src/gateway/mcp-http.request.ts +++ b/src/gateway/mcp-http.request.ts @@ -2,7 +2,10 @@ // Authenticates local MCP POST requests and extracts scoped Gateway context. import type { IncomingMessage, ServerResponse } from "node:http"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js"; +import type { + SourceReplyDeliveryMode, + TaskSuggestionDeliveryMode, +} from "../auto-reply/get-reply-options.types.js"; import type { InboundEventKind } from "../channels/inbound-event/kind.js"; import { resolveMainSessionKey } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -60,6 +63,7 @@ type McpRequestContext = { accountId: string | undefined; inboundEventKind: InboundEventKind | undefined; sourceReplyDeliveryMode: SourceReplyDeliveryMode | undefined; + taskSuggestionDeliveryMode: TaskSuggestionDeliveryMode | undefined; requireExplicitMessageTarget: boolean | undefined; senderIsOwner: boolean | undefined; }; @@ -81,6 +85,12 @@ function normalizeMcpSourceReplyDeliveryMode( return trimmed === "automatic" || trimmed === "message_tool_only" ? trimmed : undefined; } +function normalizeMcpTaskSuggestionDeliveryMode( + value: string | undefined, +): TaskSuggestionDeliveryMode | undefined { + return normalizeOptionalString(value) === "gateway" ? "gateway" : undefined; +} + function normalizeMcpBooleanHeader(value: string | undefined): boolean | undefined { const trimmed = normalizeOptionalString(value); return trimmed ? isTruthyEnvValue(trimmed) : undefined; @@ -379,6 +389,7 @@ export function resolveMcpRequestContext( accountId: undefined, inboundEventKind: undefined, sourceReplyDeliveryMode: undefined, + taskSuggestionDeliveryMode: undefined, requireExplicitMessageTarget: undefined, senderIsOwner: auth.senderIsOwner, }; @@ -399,6 +410,9 @@ export function resolveMcpRequestContext( sourceReplyDeliveryMode: normalizeMcpSourceReplyDeliveryMode( getHeader(req, "x-openclaw-source-reply-delivery-mode"), ), + taskSuggestionDeliveryMode: normalizeMcpTaskSuggestionDeliveryMode( + getHeader(req, "x-openclaw-task-suggestion-delivery-mode"), + ), requireExplicitMessageTarget: normalizeMcpBooleanHeader( getHeader(req, "x-openclaw-require-explicit-message-target"), ), diff --git a/src/gateway/mcp-http.runtime.ts b/src/gateway/mcp-http.runtime.ts index fe57ad574d0..1996b078738 100644 --- a/src/gateway/mcp-http.runtime.ts +++ b/src/gateway/mcp-http.runtime.ts @@ -1,6 +1,9 @@ // MCP loopback runtime scope cache. // Resolves Gateway-visible tools for MCP clients with short-lived schema caching. -import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js"; +import type { + SourceReplyDeliveryMode, + TaskSuggestionDeliveryMode, +} from "../auto-reply/get-reply-options.types.js"; import type { InboundEventKind } from "../channels/inbound-event/kind.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { @@ -39,6 +42,7 @@ type McpLoopbackScopeParams = { accountId: string | undefined; inboundEventKind: InboundEventKind | undefined; sourceReplyDeliveryMode: SourceReplyDeliveryMode | undefined; + taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; requireExplicitMessageTarget?: boolean; senderIsOwner: boolean | undefined; }; @@ -76,6 +80,7 @@ export class McpLoopbackToolCache { params.accountId ?? "", params.inboundEventKind ?? "", params.sourceReplyDeliveryMode ?? "", + params.taskSuggestionDeliveryMode ?? "", params.requireExplicitMessageTarget === true ? "explicit-message-target" : "", params.senderIsOwner === true ? "owner" diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index 4494eb5a7a3..f779cf872d3 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -51,6 +51,7 @@ type ScopedToolsCall = { currentInboundAudio?: boolean; inboundEventKind?: string; sourceReplyDeliveryMode?: string; + taskSuggestionDeliveryMode?: string; requireExplicitMessageTarget?: boolean; senderIsOwner?: boolean; surface?: string; @@ -813,6 +814,7 @@ describe("mcp loopback server", () => { "x-openclaw-current-inbound-audio": "true", "x-openclaw-inbound-event-kind": "room_event", "x-openclaw-source-reply-delivery-mode": "message_tool_only", + "x-openclaw-task-suggestion-delivery-mode": "gateway", "x-openclaw-require-explicit-message-target": "true", }), body: mcpToolsListBody(), @@ -830,6 +832,7 @@ describe("mcp loopback server", () => { expect(call.currentInboundAudio).toBe(true); expect(call.inboundEventKind).toBe("room_event"); expect(call.sourceReplyDeliveryMode).toBe("message_tool_only"); + expect(call.taskSuggestionDeliveryMode).toBe("gateway"); expect(call.requireExplicitMessageTarget).toBe(true); expect(call.surface).toBe("loopback"); expect(Array.from(call.excludeToolNames ?? [])).toEqual([ @@ -945,6 +948,7 @@ describe("mcp loopback server", () => { sourceReplyDeliveryMode?: string, currentInboundAudio?: boolean, requireExplicitMessageTarget?: boolean, + taskSuggestionDeliveryMode?: string, ) => await sendLoopbackToolsList({ token: runtime?.ownerToken, @@ -959,6 +963,9 @@ describe("mcp loopback server", () => { ...(requireExplicitMessageTarget ? { "x-openclaw-require-explicit-message-target": "true" } : {}), + ...(taskSuggestionDeliveryMode + ? { "x-openclaw-task-suggestion-delivery-mode": taskSuggestionDeliveryMode } + : {}), }, }); @@ -967,13 +974,17 @@ describe("mcp loopback server", () => { expect((await sendToolsList("room_event", "message_tool_only")).status).toBe(200); expect((await sendToolsList("room_event", "message_tool_only", true)).status).toBe(200); expect((await sendToolsList("room_event", "message_tool_only", true, true)).status).toBe(200); + expect( + (await sendToolsList("room_event", "message_tool_only", true, true, "gateway")).status, + ).toBe(200); - expect(resolveGatewayScopedToolsMock).toHaveBeenCalledTimes(5); + expect(resolveGatewayScopedToolsMock).toHaveBeenCalledTimes(6); expect(getScopedToolsCall(0).inboundEventKind).toBe("user_request"); expect(getScopedToolsCall(1).inboundEventKind).toBe("room_event"); expect(getScopedToolsCall(2).sourceReplyDeliveryMode).toBe("message_tool_only"); expect(getScopedToolsCall(3).currentInboundAudio).toBe(true); expect(getScopedToolsCall(4).requireExplicitMessageTarget).toBe(true); + expect(getScopedToolsCall(5).taskSuggestionDeliveryMode).toBe("gateway"); }); it("keeps explicit non-owner and unknown-owner loopback cache entries separate", () => { @@ -2194,6 +2205,9 @@ describe("createMcpLoopbackServerConfig", () => { expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-source-reply-delivery-mode"]).toBe( "${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}", ); + expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-task-suggestion-delivery-mode"]).toBe( + "${OPENCLAW_MCP_TASK_SUGGESTION_DELIVERY_MODE}", + ); expect( config.mcpServers?.openclaw?.headers?.["x-openclaw-require-explicit-message-target"], ).toBe("${OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET}"); diff --git a/src/gateway/mcp-http.ts b/src/gateway/mcp-http.ts index 29629a172ae..1174436afa2 100644 --- a/src/gateway/mcp-http.ts +++ b/src/gateway/mcp-http.ts @@ -242,6 +242,7 @@ export async function startMcpLoopbackServer(port = 0): Promise<{ accountId: requestContext.accountId, inboundEventKind: requestContext.inboundEventKind, sourceReplyDeliveryMode: requestContext.sourceReplyDeliveryMode, + taskSuggestionDeliveryMode: requestContext.taskSuggestionDeliveryMode, requireExplicitMessageTarget: requestContext.requireExplicitMessageTarget, senderIsOwner: requestContext.senderIsOwner, }); diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index ce376c6564e..3095a3ff7d5 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -88,6 +88,7 @@ const mockState = vi.hoisted(() => ({ lastDispatchImages: undefined as Array<{ mimeType: string; data: string }> | undefined, lastDispatchImageOrder: undefined as string[] | undefined, lastDispatchThinkingLevelOverride: undefined as string | undefined, + lastTaskSuggestionDeliveryMode: undefined as "gateway" | undefined, lastDispatchUserTurnInput: undefined as unknown, modelCatalog: null as ModelCatalogEntry[] | null, emittedTranscriptUpdates: [] as Array<{ @@ -244,12 +245,14 @@ vi.mock("../../auto-reply/dispatch.js", () => ({ images?: Array<{ mimeType: string; data: string }>; imageOrder?: string[]; thinkingLevelOverride?: string; + taskSuggestionDeliveryMode?: "gateway"; }; }) => { mockState.lastDispatchCtx = params.ctx; mockState.lastDispatchImages = params.replyOptions?.images; mockState.lastDispatchImageOrder = params.replyOptions?.imageOrder; mockState.lastDispatchThinkingLevelOverride = params.replyOptions?.thinkingLevelOverride; + mockState.lastTaskSuggestionDeliveryMode = params.replyOptions?.taskSuggestionDeliveryMode; const recorder = params.replyOptions?.userTurnTranscriptRecorder; mockState.lastDispatchUserTurnInput = recorder?.resolveMessage ? await recorder.resolveMessage() @@ -848,6 +851,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.lastDispatchImages = undefined; mockState.lastDispatchImageOrder = undefined; mockState.lastDispatchThinkingLevelOverride = undefined; + mockState.lastTaskSuggestionDeliveryMode = undefined; mockState.lastDispatchUserTurnInput = undefined; mockState.modelCatalog = null; mockState.emittedTranscriptUpdates = []; @@ -6617,7 +6621,8 @@ describe("chat.send operator UI client sender context", () => { version: "dev", platform: "web", }, - scopes: ["operator.write"], + caps: [GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS], + scopes: ["operator.admin"], }, }, expectBroadcast: false, @@ -6626,5 +6631,113 @@ describe("chat.send operator UI client sender context", () => { expect(mockState.lastDispatchCtx?.SenderId).toBeUndefined(); expect(mockState.lastDispatchCtx?.SenderName).toBeUndefined(); expect(mockState.lastDispatchCtx?.SenderUsername).toBeUndefined(); + expect(mockState.lastTaskSuggestionDeliveryMode).toBe("gateway"); + }); + + it("enables task suggestions for TUI clients", async () => { + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-tui-task-suggestions", + message: "hello from tui", + client: { + connect: { + client: { + id: GATEWAY_CLIENT_NAMES.TUI, + mode: GATEWAY_CLIENT_MODES.UI, + version: "dev", + platform: "terminal", + }, + caps: [GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS], + scopes: ["operator.admin"], + }, + }, + expectBroadcast: false, + }); + + expect(mockState.lastTaskSuggestionDeliveryMode).toBe("gateway"); + }); + + it("withholds task suggestions from operator UI clients that cannot accept them", async () => { + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-write-only-tui-task-suggestions", + message: "hello from a write-only tui", + client: { + connect: { + client: { + id: GATEWAY_CLIENT_NAMES.TUI, + mode: GATEWAY_CLIENT_MODES.UI, + version: "dev", + platform: "terminal", + }, + caps: [GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS], + scopes: ["operator.write"], + }, + }, + expectBroadcast: false, + }); + + expect(mockState.lastTaskSuggestionDeliveryMode).toBeUndefined(); + }); + + it("withholds task suggestions from non-operator gateway clients", async () => { + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-channel-task-suggestions", + message: "hello from a channel bridge", + client: { + connect: { + client: { + id: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + mode: GATEWAY_CLIENT_MODES.BACKEND, + version: "dev", + platform: "server", + }, + caps: [GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS], + scopes: ["operator.write"], + }, + }, + expectBroadcast: false, + }); + + expect(mockState.lastTaskSuggestionDeliveryMode).toBeUndefined(); + }); + + it("withholds task suggestions from operator UI clients without action support", async () => { + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-old-tui-task-suggestions", + message: "hello from an older tui", + client: { + connect: { + client: { + id: GATEWAY_CLIENT_NAMES.TUI, + mode: GATEWAY_CLIENT_MODES.UI, + version: "old", + platform: "terminal", + }, + scopes: ["operator.write"], + }, + }, + expectBroadcast: false, + }); + + expect(mockState.lastTaskSuggestionDeliveryMode).toBeUndefined(); }); }); diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 5454492b6f4..0322ad2cc1a 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -3699,6 +3699,10 @@ export const chatHandlers: GatewayRequestHandlers = { "chat.send": async ({ params, respond, context, client }) => { const chatSendReceivedAtMs = performance.now(); const clientInfo = client?.connect?.client; + const supportsTaskSuggestions = + isOperatorUiClient(clientInfo) && + client?.connect?.scopes?.includes("operator.admin") === true && + hasGatewayClientCap(client?.connect?.caps, GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS); const controlUiReconnectResume = resolveControlUiReconnectResumeParams(params, clientInfo); if (!validateChatSendParams(controlUiReconnectResume.params)) { respond( @@ -4813,6 +4817,9 @@ export const chatHandlers: GatewayRequestHandlers = { }), } : {}), + ...(supportsTaskSuggestions + ? { taskSuggestionDeliveryMode: "gateway" as const } + : {}), requestedSessionId, resumeRequestedSession: controlUiReconnectResume.resumeRequested, abortSignal: activeRunAbort.controller.signal, diff --git a/src/gateway/tool-resolution.test.ts b/src/gateway/tool-resolution.test.ts index 141531b8739..3f50ea5b51c 100644 --- a/src/gateway/tool-resolution.test.ts +++ b/src/gateway/tool-resolution.test.ts @@ -71,6 +71,25 @@ describe("resolveGatewayScopedTools", () => { expect(result.tools.some((tool) => tool.name === "message")).toBe(false); }); + it("exposes task suggestion tools only for actionable loopback turns", () => { + const withoutActions = resolveGatewayScopedTools({ + cfg: {} as OpenClawConfig, + sessionKey: "agent:main:main", + surface: "loopback", + }); + const withActions = resolveGatewayScopedTools({ + cfg: {} as OpenClawConfig, + sessionKey: "agent:main:main", + taskSuggestionDeliveryMode: "gateway", + surface: "loopback", + }); + + expect(withoutActions.tools.some((tool) => tool.name === "spawn_task")).toBe(false); + expect(withActions.tools.map((tool) => tool.name)).toEqual( + expect.arrayContaining(["spawn_task", "dismiss_task"]), + ); + }); + it("passes loopback yield context into sessions_yield", async () => { const onYield = vi.fn(); const result = resolveGatewayScopedTools({ diff --git a/src/gateway/tool-resolution.ts b/src/gateway/tool-resolution.ts index 76ac636f2a7..25c9941e3cb 100644 --- a/src/gateway/tool-resolution.ts +++ b/src/gateway/tool-resolution.ts @@ -29,7 +29,10 @@ import { replaceWithEffectiveCronCreatorToolAllowlist, type CronCreatorToolAllowlistEntry, } from "../agents/tools/cron-tool.js"; -import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js"; +import type { + SourceReplyDeliveryMode, + TaskSuggestionDeliveryMode, +} from "../auto-reply/get-reply-options.types.js"; import type { InboundEventKind } from "../channels/inbound-event/kind.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logWarn } from "../logger.js"; @@ -56,6 +59,7 @@ export function resolveGatewayScopedTools(params: { accountId?: string; inboundEventKind?: InboundEventKind; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode; requireExplicitMessageTarget?: boolean; agentTo?: string; agentThreadId?: string; @@ -176,6 +180,7 @@ export function resolveGatewayScopedTools(params: { agentAccountId: params.accountId, inboundEventKind: params.inboundEventKind, sourceReplyDeliveryMode, + taskSuggestionDeliveryMode: params.taskSuggestionDeliveryMode, agentTo: params.agentTo, agentThreadId: params.agentThreadId, currentChannelId: params.currentChannelId ?? params.agentTo, diff --git a/src/talk/agent-run-control.test.ts b/src/talk/agent-run-control.test.ts index 79666b3c82c..8850b5a9e99 100644 --- a/src/talk/agent-run-control.test.ts +++ b/src/talk/agent-run-control.test.ts @@ -20,7 +20,11 @@ function createDeps(options: { return { abortEmbeddedAgentRun: vi.fn(() => options.abortResult ?? true), queueEmbeddedAgentMessageWithOutcomeAsync: vi.fn( - async (sessionId: string, _text: string, _options?: { steeringMode?: "all" }) => + async ( + sessionId: string, + _text: string, + _options?: { steeringMode?: "all"; taskSuggestionDeliveryMode?: undefined }, + ) => options.queued === false ? { queued: false as const, @@ -143,7 +147,7 @@ describe("controlRealtimeVoiceAgentRun", () => { expect(deps.queueEmbeddedAgentMessageWithOutcomeAsync).toHaveBeenCalledWith( "session-active", "use the safer path", - { steeringMode: "all", debounceMs: 0 }, + { steeringMode: "all", debounceMs: 0, taskSuggestionDeliveryMode: undefined }, ); }); diff --git a/src/talk/agent-run-control.ts b/src/talk/agent-run-control.ts index 5a4e5f696c4..a6ce86efabe 100644 --- a/src/talk/agent-run-control.ts +++ b/src/talk/agent-run-control.ts @@ -45,7 +45,11 @@ type RealtimeVoiceAgentControlDeps = { queueEmbeddedAgentMessageWithOutcomeAsync: ( sessionId: string, text: string, - options?: { steeringMode?: "all"; debounceMs?: number }, + options?: { + steeringMode?: "all"; + debounceMs?: number; + taskSuggestionDeliveryMode?: undefined; + }, ) => Promise; getDiagnosticSessionActivitySnapshot: (params: { sessionId?: string; @@ -157,6 +161,9 @@ export async function controlRealtimeVoiceAgentRun( const outcome = await deps.queueEmbeddedAgentMessageWithOutcomeAsync(sessionId, steerText, { steeringMode: "all", debounceMs: 0, + // Talk cannot present task suggestions, so spoken user input must not inherit + // a capable TUI run's model-facing task tools. + taskSuggestionDeliveryMode: undefined, }); if (!outcome.queued) { return { diff --git a/src/tui/gateway-chat.test.ts b/src/tui/gateway-chat.test.ts index 34d4a8fe051..4fd84427072 100644 --- a/src/tui/gateway-chat.test.ts +++ b/src/tui/gateway-chat.test.ts @@ -583,6 +583,7 @@ describe("GatewayChatClient", () => { expect(constructedOptions).toHaveLength(1); expect(constructedOptions[0]).toMatchObject({ clientName: "openclaw-tui", + caps: ["task-suggestions", "tool-events"], mode: "ui", preauthHandshakeTimeoutMs: 30_000, deviceIdentity: null, @@ -802,4 +803,81 @@ describe("GatewayChatClient", () => { decision: "allow-once", }); }); + + it("lists, accepts, and dismisses task suggestions through the gateway", async () => { + const client = new GatewayChatClient({ + url: "ws://127.0.0.1:18789", + token: "test-token", + allowInsecureLocalOperatorUi: true, + }); + const suggestion = { + id: "task_1", + title: "Remove stale adapter", + prompt: "Delete the stale adapter.", + tldr: "The adapter is unreachable.", + cwd: "/repo", + sessionKey: "agent:main:main", + agentId: "main", + createdAt: 1_000, + }; + const request = vi + .fn() + .mockResolvedValueOnce({ suggestions: [suggestion] }) + .mockResolvedValueOnce({ taskId: "task_1", key: "agent:main:task" }) + .mockResolvedValueOnce({ taskId: "task_2", dismissed: true }); + client.hello = { + features: { + methods: ["taskSuggestions.list", "taskSuggestions.accept", "taskSuggestions.dismiss"], + }, + auth: { role: "operator", scopes: ["operator.admin"] }, + } as never; + (client as unknown as { client: { request: typeof request } }).client.request = request; + + await expect(client.listTaskSuggestions()).resolves.toEqual([suggestion]); + await expect(client.acceptTaskSuggestion("task_1")).resolves.toEqual({ + taskId: "task_1", + key: "agent:main:task", + }); + await expect(client.dismissTaskSuggestion("task_2")).resolves.toEqual({ + taskId: "task_2", + dismissed: true, + }); + + expect(request).toHaveBeenNthCalledWith(1, "taskSuggestions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "taskSuggestions.accept", { taskId: "task_1" }); + expect(request).toHaveBeenNthCalledWith(3, "taskSuggestions.dismiss", { taskId: "task_2" }); + }); + + it("derives task suggestion actions from negotiated methods and scopes", () => { + const client = new GatewayChatClient({ + url: "ws://127.0.0.1:18789", + token: "test-token", + allowInsecureLocalOperatorUi: true, + }); + client.hello = { + features: { + methods: ["taskSuggestions.accept", "taskSuggestions.dismiss"], + }, + auth: { role: "operator", scopes: ["operator.write"] }, + } as never; + + expect(client.getTaskSuggestionActionCapabilities()).toEqual({ + canAccept: false, + canDismiss: true, + }); + }); + + it("skips task suggestion refreshes against older gateways", async () => { + const client = new GatewayChatClient({ + url: "ws://127.0.0.1:18789", + token: "test-token", + allowInsecureLocalOperatorUi: true, + }); + const request = vi.fn(); + client.hello = { features: { methods: ["chat.history"] } } as never; + (client as unknown as { client: { request: typeof request } }).client.request = request; + + await expect(client.listTaskSuggestions()).resolves.toEqual([]); + expect(request).not.toHaveBeenCalled(); + }); }); diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 519a86c286d..c01b5962005 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -15,6 +15,8 @@ import { type SessionsListParams, type SessionsPatchResult, type SessionsPatchParams, + type TaskSuggestionsAcceptResult, + type TaskSuggestionsListResult, } from "../../packages/gateway-protocol/src/index.js"; import { getRuntimeConfig } from "../config/config.js"; import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js"; @@ -29,6 +31,7 @@ import { GatewayClient, GatewayClientRequestError } from "../gateway/client.js"; import { isLoopbackHost } from "../gateway/net.js"; import { formatErrorMessage } from "../infra/errors.js"; import { readActiveGatewayLockPort } from "../infra/gateway-lock.js"; +import { roleScopesAllow } from "../shared/operator-scope-compat.js"; import { sleep } from "../utils/sleep.js"; import { VERSION } from "../version.js"; import { TUI_SETUP_AUTH_SOURCE_CONFIG, TUI_SETUP_AUTH_SOURCE_ENV } from "./setup-launch-env.js"; @@ -145,7 +148,7 @@ export class GatewayChatClient implements TuiBackend { platform: process.platform, mode: GATEWAY_CLIENT_MODES.UI, deviceIdentity: connection.allowInsecureLocalOperatorUi ? null : undefined, - caps: [GATEWAY_CLIENT_CAPS.TOOL_EVENTS], + caps: [GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS, GATEWAY_CLIENT_CAPS.TOOL_EVENTS], instanceId: randomUUID(), minProtocol: MIN_CLIENT_PROTOCOL_VERSION, maxProtocol: PROTOCOL_VERSION, @@ -328,6 +331,51 @@ export class GatewayChatClient implements TuiBackend { decision, }); } + + getTaskSuggestionActionCapabilities() { + const auth = this.hello?.auth; + const methods = this.hello?.features?.methods; + const allows = (method: string, scope: "operator.admin" | "operator.write") => + Array.isArray(methods) && + methods.includes(method) && + Boolean( + auth && + roleScopesAllow({ + role: auth.role, + requestedScopes: [scope], + allowedScopes: auth.scopes, + }), + ); + return { + canAccept: allows("taskSuggestions.accept", "operator.admin"), + canDismiss: allows("taskSuggestions.dismiss", "operator.write"), + }; + } + + async listTaskSuggestions() { + if (this.hello?.features?.methods?.includes("taskSuggestions.list") !== true) { + return []; + } + const actions = this.getTaskSuggestionActionCapabilities(); + if (!actions.canAccept && !actions.canDismiss) { + return []; + } + const result = await this.client.request("taskSuggestions.list", {}); + return result.suggestions; + } + + async acceptTaskSuggestion(taskId: string) { + return await this.client.request("taskSuggestions.accept", { + taskId, + }); + } + + async dismissTaskSuggestion(taskId: string) { + return await this.client.request<{ taskId: string; dismissed: boolean }>( + "taskSuggestions.dismiss", + { taskId }, + ); + } } export async function resolveGatewayConnection( diff --git a/src/tui/tui-backend.ts b/src/tui/tui-backend.ts index 98058d756ea..8bee7e6d6e7 100644 --- a/src/tui/tui-backend.ts +++ b/src/tui/tui-backend.ts @@ -6,6 +6,8 @@ import type { SessionsListParams, SessionsPatchParams, SessionsPatchResult, + TaskSuggestion, + TaskSuggestionsAcceptResult, } from "../../packages/gateway-protocol/src/index.js"; import type { ResponseUsageMode, SessionInfo, SessionScope } from "./tui-types.js"; @@ -29,6 +31,11 @@ export type TuiChatSendResult = { export type TuiApprovalDecision = "allow-once" | "allow-always" | "deny"; +export type TuiTaskSuggestionActionCapabilities = { + canAccept: boolean; + canDismiss: boolean; +}; + export type TuiPluginApproval = { id: string; request: { @@ -195,5 +202,9 @@ export type TuiBackend = { listCommands?: (opts?: CommandsListParams) => Promise; listPluginApprovals?: () => Promise; resolvePluginApproval?: (id: string, decision: TuiApprovalDecision) => Promise<{ ok?: boolean }>; + getTaskSuggestionActionCapabilities?: () => TuiTaskSuggestionActionCapabilities; + listTaskSuggestions?: () => Promise; + acceptTaskSuggestion?: (taskId: string) => Promise; + dismissTaskSuggestion?: (taskId: string) => Promise<{ taskId: string; dismissed: boolean }>; runGoalCommand?: (opts: TuiGoalCommandOptions) => Promise<{ text: string }>; }; diff --git a/src/tui/tui-pty-harness.e2e.test.ts b/src/tui/tui-pty-harness.e2e.test.ts index 99483066cfe..d140fc96ccb 100644 --- a/src/tui/tui-pty-harness.e2e.test.ts +++ b/src/tui/tui-pty-harness.e2e.test.ts @@ -100,6 +100,16 @@ async function writeTuiPtyFixtureScript(dir: string) { expiresAtMs: number; } | null = null; let pendingPluginApprovalRun: { runId: string; sessionKey: string } | null = null; + let pendingTaskSuggestion: { + id: string; + title: string; + prompt: string; + tldr: string; + cwd: string; + sessionKey: string; + agentId: string; + createdAt: number; + } | null = null; function record(method: string, payload?: unknown) { if (!actionLogPath) { @@ -195,6 +205,25 @@ async function writeTuiPtyFixtureScript(dir: string) { }); return { runId }; } + if (opts.message === "task suggestion proof") { + pendingTaskSuggestion = { + id: "task_pty", + title: "Remove stale adapter", + prompt: "Delete the stale adapter and update its tests.", + tldr: "The adapter is unreachable and adds maintenance cost.", + cwd: "/repo/project", + sessionKey: opts.sessionKey, + agentId: "main", + createdAt: Date.now(), + }; + queueMicrotask(() => { + this.onEvent?.({ + event: "task.suggestion", + payload: { action: "created", suggestion: pendingTaskSuggestion }, + }); + }); + return { runId }; + } const responseDelayMs = opts.message === "slow prompt" || opts.message === "streaming prompt" ? 500 : 20; if (opts.message === "streaming prompt") { @@ -374,6 +403,31 @@ async function writeTuiPtyFixtureScript(dir: string) { }); return { ok: true }; } + + async listTaskSuggestions() { + record("listTaskSuggestions", { pending: Boolean(pendingTaskSuggestion) }); + return pendingTaskSuggestion ? [pendingTaskSuggestion] : []; + } + + async acceptTaskSuggestion(taskId: string) { + record("acceptTaskSuggestion", { taskId }); + pendingTaskSuggestion = null; + this.onEvent?.({ + event: "task.suggestion", + payload: { action: "resolved", taskId, resolution: "accepted" }, + }); + return { taskId, key: "agent:main:task-pty" }; + } + + async dismissTaskSuggestion(taskId: string) { + record("dismissTaskSuggestion", { taskId }); + pendingTaskSuggestion = null; + this.onEvent?.({ + event: "task.suggestion", + payload: { action: "resolved", taskId, resolution: "dismissed" }, + }); + return { taskId, dismissed: true }; + } } async function main() { @@ -467,6 +521,7 @@ describe.sequential("TUI PTY harness", () => { it("refreshes pending approvals before loading history", async () => { await fixture.waitForLogEntry((entry) => entry.method === "listPluginApprovals"); + await fixture.waitForLogEntry((entry) => entry.method === "listTaskSuggestions"); await fixture.waitForLogEntry((entry) => entry.method === "loadHistory"); const entries = await readFixtureLog(fixture.logPath); @@ -474,9 +529,12 @@ describe.sequential("TUI PTY harness", () => { (entry) => entry.method === "listPluginApprovals", ); const historyLoadIndex = entries.findIndex((entry) => entry.method === "loadHistory"); + const taskRefreshIndex = entries.findIndex((entry) => entry.method === "listTaskSuggestions"); expect(approvalRefreshIndex).toBeGreaterThanOrEqual(0); expect(approvalRefreshIndex).toBeLessThan(historyLoadIndex); + expect(taskRefreshIndex).toBeGreaterThanOrEqual(0); + expect(taskRefreshIndex).toBeLessThan(historyLoadIndex); }); it( @@ -536,6 +594,27 @@ describe.sequential("TUI PTY harness", () => { TEST_TIMEOUT_MS, ); + it( + "presents and starts a suggested task in the TUI", + async () => { + await fixture.run.write("task suggestion proof\r"); + await fixture.run.waitForOutput("Suggested follow-up: Remove stale adapter"); + await fixture.run.waitForOutput("Project: /repo/project"); + await fixture.run.waitForOutput("The adapter is unreachable and adds maintenance cost."); + + await fixture.run.write("\x1b[A", { delay: false }); + await fixture.run.write("\r", { delay: false }); + await fixture.run.waitForOutput("Press Enter again to start this task in a worktree."); + await fixture.run.write("\r", { delay: false }); + await fixture.waitForLogEntry( + (entry) => + entry.method === "acceptTaskSuggestion" && objectFieldEquals(entry, "taskId", "task_pty"), + ); + await fixture.run.waitForOutput("session agent:main:task-pty"); + }, + TEST_TIMEOUT_MS, + ); + it( "sends multiple prompts in order", async () => { diff --git a/src/tui/tui-task-suggestions.test.ts b/src/tui/tui-task-suggestions.test.ts new file mode 100644 index 00000000000..7ce7a84610b --- /dev/null +++ b/src/tui/tui-task-suggestions.test.ts @@ -0,0 +1,367 @@ +import type { Component, OverlayHandle, SelectItem } from "@earendil-works/pi-tui"; +import { describe, expect, it, vi } from "vitest"; +import { stripAnsi } from "../../packages/terminal-core/src/ansi.js"; +import { + createTuiTaskSuggestionController, + parseTuiTaskSuggestion, +} from "./tui-task-suggestions.js"; + +type TestSelector = Component & { + items: SelectItem[]; + onSelect?: (item: SelectItem) => void; + onCancel?: () => void; + onSelectionChange?: (item: SelectItem) => void; + setSelectedIndex: ReturnType void>>; +}; + +function suggestionPayload(overrides: Record = {}) { + return { + id: "task_1", + title: "Remove stale adapter", + prompt: "Delete the stale adapter and update its tests.", + tldr: "The adapter is unreachable and adds maintenance cost.", + cwd: "/repo/project", + sessionKey: "agent:main:main", + agentId: "main", + createdAt: 1_000, + ...overrides, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function createHarness() { + const selectors: TestSelector[] = []; + const addSystem = vi.fn(); + const closeOverlay = vi.fn(); + const overlayHandles: OverlayHandle[] = []; + const openOverlay = vi.fn((_component: Component) => { + const handle = { + hide: vi.fn(), + setHidden: vi.fn(), + isHidden: vi.fn(() => false), + focus: vi.fn(), + unfocus: vi.fn(), + isFocused: vi.fn(() => true), + } satisfies OverlayHandle; + overlayHandles.push(handle); + return handle; + }); + const requestRender = vi.fn(); + const listTaskSuggestions = vi.fn().mockResolvedValue([]); + const acceptTaskSuggestion = vi + .fn() + .mockResolvedValue({ taskId: "task_1", key: "agent:main:task" }); + const dismissTaskSuggestion = vi.fn().mockResolvedValue({ taskId: "task_1", dismissed: true }); + const onAccepted = vi.fn().mockResolvedValue(undefined); + let agentId = "main"; + let sessionKey = "agent:main:main"; + let actionCapabilities = { canAccept: true, canDismiss: true }; + const controller = createTuiTaskSuggestionController({ + client: { + getTaskSuggestionActionCapabilities: () => actionCapabilities, + listTaskSuggestions, + acceptTaskSuggestion, + dismissTaskSuggestion, + }, + chatLog: { addSystem }, + getAgentId: () => agentId, + getSessionKey: () => sessionKey, + openOverlay, + closeOverlay, + requestRender, + onAccepted, + createSelector: (items) => { + const selector = { + items, + setSelectedIndex: vi.fn<(index: number) => void>(), + render: () => ["TASK ACTIONS"], + handleInput: () => undefined, + invalidate: () => undefined, + } satisfies TestSelector; + selectors.push(selector); + return selector; + }, + }); + return { + controller, + selectors, + addSystem, + closeOverlay, + openOverlay, + overlayHandles, + requestRender, + listTaskSuggestions, + acceptTaskSuggestion, + dismissTaskSuggestion, + onAccepted, + setAgentId: (value: string) => { + agentId = value; + }, + setSessionKey: (value: string) => { + sessionKey = value; + }, + setActionCapabilities: (value: { canAccept: boolean; canDismiss: boolean }) => { + actionCapabilities = value; + }, + }; +} + +describe("TUI task suggestions", () => { + it("parses the Gateway suggestion shape", () => { + expect(parseTuiTaskSuggestion(suggestionPayload())).toEqual(suggestionPayload()); + expect(parseTuiTaskSuggestion({ id: "task_missing_fields" })).toBeNull(); + }); + + it("shows an active-session suggestion and starts it after confirmation", async () => { + const harness = createHarness(); + + harness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload(), + }); + + expect(harness.openOverlay).toHaveBeenCalledTimes(1); + const prompt = harness.openOverlay.mock.calls[0]?.[0]; + const renderedPrompt = stripAnsi(prompt.render(80).join("\n")); + expect(renderedPrompt).toContain("Suggested follow-up: Remove stale adapter"); + expect(renderedPrompt).toContain("Project: /repo/project"); + expect(renderedPrompt).toContain("Why: The adapter is unreachable"); + expect(renderedPrompt).toContain("Instructions:"); + expect(renderedPrompt).toContain("Delete the stale adapter and update its tests."); + expect(harness.selectors[0]?.items.map((item) => item.value)).toEqual(["accept", "dismiss"]); + expect(harness.selectors[0]?.setSelectedIndex).toHaveBeenCalledWith(1); + + const accept = { value: "accept", label: "Start in worktree" }; + harness.selectors[0]?.onSelect?.(accept); + expect(harness.acceptTaskSuggestion).not.toHaveBeenCalled(); + expect(stripAnsi(prompt.render(80).join("\n"))).toContain("Press Enter again"); + harness.selectors[0]?.onSelect?.(accept); + + await vi.waitFor(() => { + expect(harness.acceptTaskSuggestion).toHaveBeenCalledWith("task_1"); + expect(harness.onAccepted).toHaveBeenCalledWith("agent:main:task"); + }); + expect(harness.addSystem).toHaveBeenCalledWith("follow-up task started in agent:main:task"); + }); + + it("keeps actions visible while paging through long instructions", () => { + const harness = createHarness(); + const promptLines = Array.from( + { length: 20 }, + (_, index) => `instruction-${String(index + 1).padStart(2, "0")}`, + ); + harness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload({ prompt: promptLines.join("\n") }), + }); + + const prompt = harness.openOverlay.mock.calls[0]?.[0]; + const firstPage = stripAnsi(prompt.render(80).join("\n")); + expect(firstPage).toContain("instruction-01"); + expect(firstPage).not.toContain("instruction-20"); + expect(firstPage).toContain("PgUp/PgDn to inspect"); + expect(firstPage).toContain("TASK ACTIONS"); + + const pages = [firstPage]; + for (let page = 0; page < 3; page += 1) { + prompt.handleInput?.("\u001b[6~"); + const rendered = stripAnsi(prompt.render(80).join("\n")); + pages.push(rendered); + expect(rendered).toContain("TASK ACTIONS"); + } + expect(pages.join("\n")).toContain("instruction-20"); + expect(harness.requestRender).toHaveBeenCalled(); + }); + + it("keeps every project path segment inspectable before acceptance", () => { + const harness = createHarness(); + const cwd = `/repo/${"nested-segment/".repeat(20)}distinguishing-project`; + harness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload({ cwd }), + }); + + const prompt = harness.openOverlay.mock.calls[0]?.[0]; + const pages: string[] = []; + for (let page = 0; page < 20; page += 1) { + const rendered = stripAnsi(prompt.render(24).join("\n")); + pages.push(rendered); + expect(rendered).toContain("TASK ACTIONS"); + prompt.handleInput?.("\u001b[6~"); + } + expect(pages.join("\n").replace(/\s/g, "")).toContain("distinguishing-project"); + }); + + it("strips bidi controls from every displayed confirmation field", () => { + const harness = createHarness(); + harness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload({ + title: "safe\u202eevil", + cwd: "/repo/\u2066project", + tldr: "why\u200f now", + prompt: "run\u202d exactly", + }), + }); + + const prompt = harness.openOverlay.mock.calls[0]?.[0]; + const rendered = stripAnsi(prompt.render(80).join("\n")); + expect(rendered).toContain("safeevil"); + expect(rendered).toContain("/repo/project"); + expect(rendered).toContain("why now"); + expect(rendered).toContain("run exactly"); + expect(rendered).not.toMatch(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u); + }); + + it("dismisses a suggestion without starting work", async () => { + const harness = createHarness(); + harness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload(), + }); + + harness.selectors[0]?.onSelect?.({ value: "dismiss", label: "Dismiss" }); + + await vi.waitFor(() => { + expect(harness.dismissTaskSuggestion).toHaveBeenCalledWith("task_1"); + }); + expect(harness.acceptTaskSuggestion).not.toHaveBeenCalled(); + expect(harness.addSystem).toHaveBeenCalledWith("follow-up task dismissed"); + }); + + it("offers only actions allowed by the connected operator scopes", () => { + const writeHarness = createHarness(); + writeHarness.setActionCapabilities({ canAccept: false, canDismiss: true }); + writeHarness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload(), + }); + expect(writeHarness.selectors[0]?.items.map((item) => item.value)).toEqual(["dismiss"]); + expect(writeHarness.selectors[0]?.setSelectedIndex).toHaveBeenCalledWith(0); + + const readHarness = createHarness(); + readHarness.setActionCapabilities({ canAccept: false, canDismiss: false }); + readHarness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload(), + }); + expect(readHarness.openOverlay).not.toHaveBeenCalled(); + }); + + it("rebuilds an active selector when reconnect changes action scopes", async () => { + const harness = createHarness(); + const suggestion = suggestionPayload(); + harness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion, + }); + const staleSelector = harness.selectors[0]; + + harness.setActionCapabilities({ canAccept: false, canDismiss: true }); + harness.listTaskSuggestions.mockResolvedValueOnce([suggestion]); + await harness.controller.refresh(); + + expect(harness.closeOverlay).toHaveBeenCalledWith(harness.overlayHandles[0]); + expect(harness.openOverlay).toHaveBeenCalledTimes(2); + expect(harness.selectors[1]?.items.map((item) => item.value)).toEqual(["dismiss"]); + staleSelector?.onSelect?.({ value: "accept", label: "Start in worktree" }); + staleSelector?.onSelect?.({ value: "accept", label: "Start in worktree" }); + expect(harness.acceptTaskSuggestion).not.toHaveBeenCalled(); + }); + + it("shows a still-pending suggestion again when its action fails", async () => { + const harness = createHarness(); + harness.acceptTaskSuggestion.mockRejectedValueOnce(new Error("gateway unavailable")); + harness.listTaskSuggestions.mockResolvedValueOnce([suggestionPayload()]); + harness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload(), + }); + + const accept = { value: "accept", label: "Start in worktree" }; + harness.selectors[0]?.onSelect?.(accept); + harness.selectors[0]?.onSelect?.(accept); + + await vi.waitFor(() => { + expect(harness.openOverlay).toHaveBeenCalledTimes(2); + }); + expect(harness.addSystem).toHaveBeenCalledWith("follow-up task failed: gateway unavailable"); + }); + + it("does not switch sessions after the operator navigates away during acceptance", async () => { + const harness = createHarness(); + const pendingAccept = deferred<{ taskId: string; key: string }>(); + harness.acceptTaskSuggestion.mockReturnValueOnce(pendingAccept.promise); + harness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload(), + }); + + const accept = { value: "accept", label: "Start in worktree" }; + harness.selectors[0]?.onSelect?.(accept); + harness.selectors[0]?.onSelect?.(accept); + harness.setSessionKey("agent:main:other"); + harness.controller.sessionChanged(); + pendingAccept.resolve({ taskId: "task_1", key: "agent:main:task" }); + + await vi.waitFor(() => { + expect(harness.addSystem).toHaveBeenCalledWith("follow-up task started in agent:main:task"); + }); + expect(harness.onAccepted).not.toHaveBeenCalled(); + }); + + it("shows only suggestions for the active session", () => { + const harness = createHarness(); + harness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload({ sessionKey: "agent:other:main", agentId: "other" }), + }); + expect(harness.openOverlay).not.toHaveBeenCalled(); + + harness.setSessionKey("agent:other:main"); + harness.setAgentId("other"); + harness.controller.sessionChanged(); + + expect(harness.openOverlay).toHaveBeenCalledTimes(1); + }); + + it("closes a suggestion resolved by another client", () => { + const harness = createHarness(); + harness.controller.handleEvent("task.suggestion", { + action: "created", + suggestion: suggestionPayload(), + }); + + harness.controller.handleEvent("task.suggestion", { + action: "resolved", + taskId: "task_1", + resolution: "accepted", + }); + + expect(harness.closeOverlay).toHaveBeenCalledWith(harness.overlayHandles[0]); + }); + + it("does not resurrect a resolved suggestion from a stale refresh", async () => { + const harness = createHarness(); + const pendingList = deferred(); + harness.listTaskSuggestions.mockReturnValueOnce(pendingList.promise); + + const refresh = harness.controller.refresh(); + harness.controller.handleEvent("task.suggestion", { + action: "resolved", + taskId: "task_1", + resolution: "dismissed", + }); + pendingList.resolve([suggestionPayload()]); + await refresh; + + expect(harness.openOverlay).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tui/tui-task-suggestions.ts b/src/tui/tui-task-suggestions.ts new file mode 100644 index 00000000000..388b361f527 --- /dev/null +++ b/src/tui/tui-task-suggestions.ts @@ -0,0 +1,456 @@ +// Presents model-proposed follow-up tasks that belong to the active TUI session. +import { + SelectList, + Text, + type Component, + type OverlayHandle, + type SelectItem, +} from "@earendil-works/pi-tui"; +import type { TaskSuggestion } from "../../packages/gateway-protocol/src/index.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { selectListTheme, theme } from "./theme/theme.js"; +import type { TuiBackend } from "./tui-backend.js"; +import { sanitizeRenderableText } from "./tui-formatters.js"; + +type TaskSelector = Component & { + onSelect?: (item: SelectItem) => void; + onCancel?: () => void; + onSelectionChange?: (item: SelectItem) => void; + setSelectedIndex?: (index: number) => void; +}; + +type TaskSuggestionControllerDeps = { + client: Pick< + TuiBackend, + | "getTaskSuggestionActionCapabilities" + | "listTaskSuggestions" + | "acceptTaskSuggestion" + | "dismissTaskSuggestion" + >; + chatLog: { addSystem: (line: string) => void }; + getAgentId: () => string; + getSessionKey: () => string; + openOverlay: (component: Component) => OverlayHandle; + closeOverlay: (handle?: OverlayHandle) => void; + requestRender: () => void; + onAccepted: (sessionKey: string) => Promise | void; + createSelector?: (items: SelectItem[]) => TaskSelector; +}; + +const TASK_BIDI_CONTROL_RE = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g; +const TASK_DETAIL_VIEWPORT_LINES = 12; +const TASK_DETAIL_PAGE_LINES = TASK_DETAIL_VIEWPORT_LINES - 1; +const PAGE_UP_INPUT = "\u001b[5~"; +const PAGE_DOWN_INPUT = "\u001b[6~"; + +const TASK_ACTIONS = [ + { + value: "accept", + label: "Start in worktree", + description: "Create an isolated session and begin this task", + }, + { + value: "dismiss", + label: "Dismiss", + description: "Leave the repository untouched", + }, +] satisfies SelectItem[]; + +function clean(text: string): string { + return sanitizeTaskText(text.replace(/\s+/g, " ").trim()); +} + +function sanitizeTaskText(text: string): string { + return sanitizeRenderableText(text.replace(TASK_BIDI_CONTROL_RE, "")); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** Parses the task suggestion shape carried by Gateway list and event payloads. */ +export function parseTuiTaskSuggestion(value: unknown): TaskSuggestion | null { + if (!isRecord(value)) { + return null; + } + const required = ["id", "title", "prompt", "tldr", "cwd", "sessionKey"] as const; + if (required.some((field) => typeof value[field] !== "string" || !value[field].trim())) { + return null; + } + if (typeof value.createdAt !== "number" || value.createdAt < 0) { + return null; + } + return { + id: (value.id as string).trim(), + title: (value.title as string).trim(), + prompt: (value.prompt as string).trim(), + tldr: (value.tldr as string).trim(), + cwd: (value.cwd as string).trim(), + sessionKey: (value.sessionKey as string).trim(), + ...(typeof value.agentId === "string" && value.agentId.trim() + ? { agentId: value.agentId.trim() } + : {}), + createdAt: value.createdAt, + }; +} + +class TaskPrompt implements Component { + private readonly title: Text; + private readonly metadata: Text; + private readonly summary: Text; + private readonly instructionLabel = new Text(theme.system("Instructions:")); + private readonly instructions: Text; + private readonly detailPosition = new Text(); + private readonly confirmation = new Text(); + private detailOffset = 0; + private detailLineCount = 0; + + constructor( + suggestion: TaskSuggestion, + private readonly selector: TaskSelector, + private readonly requestRender: () => void, + ) { + this.title = new Text(theme.header(`Suggested follow-up: ${clean(suggestion.title)}`)); + this.metadata = new Text(theme.dim(`Project: ${clean(suggestion.cwd)}`)); + this.summary = new Text(theme.system(`Why: ${clean(suggestion.tldr)}`)); + this.instructions = new Text(theme.system(sanitizeTaskText(suggestion.prompt.trim()))); + } + + setConfirmation(text: string): void { + this.confirmation.setText(theme.accent(text)); + } + + invalidate(): void { + for (const component of [ + this.title, + this.metadata, + this.summary, + this.instructionLabel, + this.instructions, + this.detailPosition, + this.confirmation, + this.selector, + ]) { + component.invalidate(); + } + } + + render(width: number): string[] { + // Page the complete confirmation details as one unit. This keeps actions + // visible without hiding a long project-path suffix from the operator. + const detailLines = [ + ...this.metadata.render(width), + ...this.summary.render(width), + ...this.instructionLabel.render(width), + ...this.instructions.render(width), + ]; + this.detailLineCount = detailLines.length; + const maxDetailOffset = Math.max(0, detailLines.length - TASK_DETAIL_VIEWPORT_LINES); + this.detailOffset = Math.min(this.detailOffset, maxDetailOffset); + const visibleDetails = detailLines.slice( + this.detailOffset, + this.detailOffset + TASK_DETAIL_VIEWPORT_LINES, + ); + if (detailLines.length > TASK_DETAIL_VIEWPORT_LINES) { + const visibleEnd = this.detailOffset + visibleDetails.length; + this.detailPosition.setText( + theme.dim( + `Details ${this.detailOffset + 1}-${visibleEnd} of ${detailLines.length} ยท PgUp/PgDn to inspect`, + ), + ); + } else { + this.detailPosition.setText(""); + } + const detailPosition = this.detailPosition.render(width); + const confirmation = this.confirmation.render(width); + return [ + ...this.title.render(width).slice(0, 2), + ...visibleDetails, + ...(detailPosition.some((line) => line.trim()) ? detailPosition : []), + ...(confirmation.some((line) => line.trim()) ? ["", ...confirmation] : []), + "", + ...this.selector.render(width), + ]; + } + + handleInput(data: string): void { + if (data === PAGE_UP_INPUT || data === PAGE_DOWN_INPUT) { + const maxOffset = Math.max(0, this.detailLineCount - TASK_DETAIL_VIEWPORT_LINES); + const delta = data === PAGE_UP_INPUT ? -TASK_DETAIL_PAGE_LINES : TASK_DETAIL_PAGE_LINES; + const nextOffset = Math.min(maxOffset, Math.max(0, this.detailOffset + delta)); + if (nextOffset !== this.detailOffset) { + this.detailOffset = nextOffset; + this.metadata.invalidate(); + this.summary.invalidate(); + this.instructionLabel.invalidate(); + this.instructions.invalidate(); + this.requestRender(); + } + return; + } + this.selector.handleInput?.(data); + } +} + +/** Coordinates Gateway task-suggestion events with the active TUI overlay. */ +export function createTuiTaskSuggestionController(deps: TaskSuggestionControllerDeps) { + const createSelector = + deps.createSelector ?? + ((items: SelectItem[]) => new SelectList(items, items.length, selectListTheme)); + const suggestions = new Map(); + const hiddenIds = new Set(); + let activeId: string | null = null; + let activeOverlay: OverlayHandle | null = null; + let activeSelector: TaskSelector | null = null; + let activeActionKey: string | null = null; + let revision = 0; + let disposed = false; + let refreshInFlight: Promise | null = null; + let refreshAgain = false; + + const closeActive = () => { + if (activeOverlay) { + deps.closeOverlay(activeOverlay); + activeOverlay = null; + } + activeId = null; + activeSelector = null; + activeActionKey = null; + }; + + const remove = (id: string) => { + revision += 1; + suggestions.delete(id); + hiddenIds.delete(id); + if (activeId === id) { + closeActive(); + } + }; + + const matchesSession = (suggestion: TaskSuggestion) => + suggestion.sessionKey === deps.getSessionKey() && + (suggestion.sessionKey !== "global" || suggestion.agentId === deps.getAgentId()); + + const availableActions = () => { + const capabilities = deps.client.getTaskSuggestionActionCapabilities?.() ?? { + canAccept: Boolean(deps.client.acceptTaskSuggestion), + canDismiss: Boolean(deps.client.dismissTaskSuggestion), + }; + return TASK_ACTIONS.filter((action) => + action.value === "accept" ? capabilities.canAccept : capabilities.canDismiss, + ); + }; + + const presentNext = () => { + if (disposed) { + return; + } + const actions = availableActions(); + const actionKey = actions.map((action) => action.value).join(","); + if (activeId) { + if (activeActionKey === actionKey) { + return; + } + closeActive(); + } + const suggestion = [...suggestions.values()] + .toSorted((left, right) => left.createdAt - right.createdAt) + .find((entry) => !hiddenIds.has(entry.id) && matchesSession(entry)); + if (!suggestion) { + return; + } + + if (actions.length === 0) { + return; + } + + activeId = suggestion.id; + const selector = createSelector(actions); + activeSelector = selector; + activeActionKey = actionKey; + const dismissIndex = actions.findIndex((action) => action.value === "dismiss"); + selector.setSelectedIndex?.(Math.max(dismissIndex, 0)); + let acceptArmed = false; + let prompt: TaskPrompt | null = null; + + const resolve = async (action: "accept" | "dismiss") => { + if (activeId !== suggestion.id || activeSelector !== selector) { + return; + } + closeActive(); + hiddenIds.add(suggestion.id); + deps.requestRender(); + try { + if (action === "accept") { + if (!deps.client.acceptTaskSuggestion) { + throw new Error("task suggestion acceptance is unavailable"); + } + const result = await deps.client.acceptTaskSuggestion(suggestion.id); + remove(suggestion.id); + deps.chatLog.addSystem(`follow-up task started in ${result.key}`); + if (matchesSession(suggestion)) { + await deps.onAccepted(result.key); + } + } else { + if (!deps.client.dismissTaskSuggestion) { + throw new Error("task suggestion dismissal is unavailable"); + } + const result = await deps.client.dismissTaskSuggestion(suggestion.id); + if (!result.dismissed) { + throw new Error("task suggestion is no longer pending"); + } + remove(suggestion.id); + deps.chatLog.addSystem("follow-up task dismissed"); + } + } catch (error) { + hiddenIds.delete(suggestion.id); + deps.chatLog.addSystem(`follow-up task failed: ${formatErrorMessage(error)}`); + void refresh().catch((refreshError: unknown) => { + deps.chatLog.addSystem( + `task suggestion refresh failed: ${formatErrorMessage(refreshError)}`, + ); + }); + } + presentNext(); + if (!disposed) { + deps.requestRender(); + } + }; + + selector.onSelectionChange = () => { + acceptArmed = false; + prompt?.setConfirmation(""); + }; + selector.onSelect = (item) => { + if (activeSelector !== selector) { + return; + } + if (!availableActions().some((action) => action.value === item.value)) { + closeActive(); + presentNext(); + deps.requestRender(); + return; + } + if (item.value === "dismiss") { + void resolve("dismiss"); + return; + } + if (item.value !== "accept") { + return; + } + if (acceptArmed) { + void resolve("accept"); + return; + } + acceptArmed = true; + prompt?.setConfirmation("Press Enter again to start this task in a worktree."); + deps.requestRender(); + }; + selector.onCancel = () => { + if (activeSelector !== selector) { + return; + } + hiddenIds.add(suggestion.id); + closeActive(); + deps.chatLog.addSystem("follow-up task hidden; suggestion remains pending"); + presentNext(); + deps.requestRender(); + }; + prompt = new TaskPrompt(suggestion, selector, deps.requestRender); + activeOverlay = deps.openOverlay(prompt); + deps.requestRender(); + }; + + const refresh = async (): Promise => { + if (disposed || !deps.client.listTaskSuggestions) { + return; + } + if (refreshInFlight) { + refreshAgain = true; + return await refreshInFlight; + } + refreshInFlight = (async () => { + do { + refreshAgain = false; + const startRevision = revision; + const listed = await deps.client.listTaskSuggestions?.(); + if (disposed || !listed) { + return; + } + // An event raced this snapshot. Retry instead of resurrecting resolved work. + if (revision !== startRevision) { + refreshAgain = true; + continue; + } + suggestions.clear(); + for (const value of listed) { + const suggestion = parseTuiTaskSuggestion(value); + if (suggestion) { + suggestions.set(suggestion.id, suggestion); + } + } + for (const id of hiddenIds) { + if (!suggestions.has(id)) { + hiddenIds.delete(id); + } + } + } while (refreshAgain); + if (activeId && !suggestions.has(activeId)) { + closeActive(); + } + presentNext(); + deps.requestRender(); + })(); + try { + await refreshInFlight; + } finally { + refreshInFlight = null; + } + }; + + return { + handleEvent(event: string, payload: unknown) { + if (disposed || event !== "task.suggestion" || !isRecord(payload)) { + return; + } + if (payload.action === "created") { + const suggestion = parseTuiTaskSuggestion(payload.suggestion); + if (suggestion) { + revision += 1; + hiddenIds.delete(suggestion.id); + suggestions.set(suggestion.id, suggestion); + presentNext(); + } + return; + } + if (payload.action === "resolved" && typeof payload.taskId === "string") { + remove(payload.taskId); + presentNext(); + deps.requestRender(); + } + }, + refresh, + sessionChanged() { + if (disposed) { + return; + } + hiddenIds.clear(); + const active = activeId ? suggestions.get(activeId) : undefined; + if (active && !matchesSession(active)) { + closeActive(); + } + presentNext(); + deps.requestRender(); + }, + dispose() { + if (disposed) { + return; + } + disposed = true; + suggestions.clear(); + hiddenIds.clear(); + closeActive(); + deps.requestRender(); + }, + }; +} diff --git a/src/tui/tui.ts b/src/tui/tui.ts index c802640082e..cfbe017caf8 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -67,6 +67,7 @@ import { shouldEnableWindowsGitBashPasteFallback, type TuiSubmitAction, } from "./tui-submit.js"; +import { createTuiTaskSuggestionController } from "./tui-task-suggestions.js"; import type { AgentSummary, SessionInfo, @@ -613,6 +614,7 @@ export async function runTui(opts: RunTuiOptions): Promise { set currentAgentId(value) { currentAgentId = value; pluginApprovals?.sessionChanged(); + taskSuggestions?.sessionChanged(); }, get currentSessionKey() { return currentSessionKey; @@ -620,6 +622,7 @@ export async function runTui(opts: RunTuiOptions): Promise { set currentSessionKey(value) { currentSessionKey = value; pluginApprovals?.sessionChanged(); + taskSuggestions?.sessionChanged(); }, get currentSessionId() { return currentSessionId; @@ -1320,6 +1323,16 @@ export async function runTui(opts: RunTuiOptions): Promise { setSession, abortActive, } = sessionActions; + const taskSuggestions = createTuiTaskSuggestionController({ + client, + chatLog, + getAgentId: () => currentAgentId, + getSessionKey: () => currentSessionKey, + openOverlay, + closeOverlay, + requestRender: () => tui.requestRender(), + onAccepted: setSession, + }); const { handleChatEvent, @@ -1369,6 +1382,7 @@ export async function runTui(opts: RunTuiOptions): Promise { ...(result?.crestodianMessage ? { crestodianMessage: result.crestodianMessage } : {}), }; pluginApprovals?.dispose(); + taskSuggestions?.dispose(); const hardExitTimer = setTimeout( forceExit, resolveTuiShutdownHardExitMs({ localMode: isLocalMode }), @@ -1551,6 +1565,7 @@ export async function runTui(opts: RunTuiOptions): Promise { client.onEvent = (evt) => { pluginApprovals?.handleEvent(evt.event, evt.payload); + taskSuggestions?.handleEvent(evt.event, evt.payload); if (evt.event === "chat") { handleChatEvent(evt.payload); } @@ -1594,6 +1609,11 @@ export async function runTui(opts: RunTuiOptions): Promise { } catch (err) { chatLog.addSystem(`plugin approval refresh failed: ${String(err)}`); } + try { + await taskSuggestions?.refresh(); + } catch (err) { + chatLog.addSystem(`task suggestion refresh failed: ${String(err)}`); + } await loadHistory(); if (activityStatus === "starting up") { setActivityStatus("idle"); @@ -1658,6 +1678,11 @@ export async function runTui(opts: RunTuiOptions): Promise { } catch (err) { chatLog.addSystem(`plugin approval refresh failed: ${String(err)}`); } + try { + await taskSuggestions?.refresh(); + } catch (err) { + chatLog.addSystem(`task suggestion refresh failed: ${String(err)}`); + } })(); tui.requestRender(); }; @@ -1685,6 +1710,7 @@ export async function runTui(opts: RunTuiOptions): Promise { await new Promise((resolve) => { const finish = () => { pluginApprovals?.dispose(); + taskSuggestions?.dispose(); if (isLocalMode) { setConsoleSubsystemFilter(previousConsoleSubsystemFilter); } diff --git a/ui/src/api/gateway.node.test.ts b/ui/src/api/gateway.node.test.ts index 05ff13655be..c412ca276e6 100644 --- a/ui/src/api/gateway.node.test.ts +++ b/ui/src/api/gateway.node.test.ts @@ -1,5 +1,6 @@ // @vitest-environment node import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GATEWAY_CLIENT_CAPS } from "../../../packages/gateway-protocol/src/client-info.js"; import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js"; import { MIN_CLIENT_PROTOCOL_VERSION, @@ -140,8 +141,8 @@ type ConnectFrame = { auth?: { token?: string; password?: string; deviceToken?: string }; maxProtocol?: number; minProtocol?: number; - scopes?: string[]; caps?: string[]; + scopes?: string[]; }; }; @@ -413,8 +414,12 @@ describe("GatewayBrowserClient", () => { expect(connectFrame.method).toBe("connect"); expect(connectFrame.params?.minProtocol).toBe(MIN_CLIENT_PROTOCOL_VERSION); expect(connectFrame.params?.maxProtocol).toBe(PROTOCOL_VERSION); + expect(connectFrame.params?.caps).toEqual([ + GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS, + GATEWAY_CLIENT_CAPS.TOOL_EVENTS, + GATEWAY_CLIENT_CAPS.INLINE_WIDGETS, + ]); expect(connectFrame.params?.scopes).toEqual([...CONTROL_UI_OPERATOR_SCOPES]); - expect(connectFrame.params?.caps).toEqual(["tool-events", "inline-widgets"]); }); it("adds the current Control UI protocol to bare protocol mismatch errors", () => { diff --git a/ui/src/api/gateway.ts b/ui/src/api/gateway.ts index eeb0ae5dbdf..92f560453b1 100644 --- a/ui/src/api/gateway.ts +++ b/ui/src/api/gateway.ts @@ -784,7 +784,11 @@ export class GatewayBrowserClient { role: plan.role, scopes: plan.scopes, device: plan.device, - caps: [GATEWAY_CLIENT_CAPS.TOOL_EVENTS, GATEWAY_CLIENT_CAPS.INLINE_WIDGETS], + caps: [ + GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS, + GATEWAY_CLIENT_CAPS.TOOL_EVENTS, + GATEWAY_CLIENT_CAPS.INLINE_WIDGETS, + ], auth: plan.auth, userAgent: navigator.userAgent, locale: navigator.language,