mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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
This commit is contained in:
parent
c9b3358d43
commit
5f9e0e20d4
52 changed files with 1540 additions and 32 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1557,6 +1557,7 @@ export async function runPreparedReply(
|
|||
inputProvenance,
|
||||
extraSystemPrompt: extraSystemPromptParts.join("\n\n") || undefined,
|
||||
sourceReplyDeliveryMode,
|
||||
taskSuggestionDeliveryMode: opts?.taskSuggestionDeliveryMode,
|
||||
silentReplyPromptMode,
|
||||
extraSystemPromptStatic,
|
||||
cliSessionBindingFacts,
|
||||
|
|
|
|||
|
|
@ -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<void>();
|
||||
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[] = [];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<ReplyBackendHandle, "sourceReplyDeliveryMode" | "taskSuggestionDeliveryMode">,
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,11 @@ type RealtimeVoiceAgentControlDeps = {
|
|||
queueEmbeddedAgentMessageWithOutcomeAsync: (
|
||||
sessionId: string,
|
||||
text: string,
|
||||
options?: { steeringMode?: "all"; debounceMs?: number },
|
||||
options?: {
|
||||
steeringMode?: "all";
|
||||
debounceMs?: number;
|
||||
taskSuggestionDeliveryMode?: undefined;
|
||||
},
|
||||
) => Promise<EmbeddedAgentQueueMessageOutcome>;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<TaskSuggestionsListResult>("taskSuggestions.list", {});
|
||||
return result.suggestions;
|
||||
}
|
||||
|
||||
async acceptTaskSuggestion(taskId: string) {
|
||||
return await this.client.request<TaskSuggestionsAcceptResult>("taskSuggestions.accept", {
|
||||
taskId,
|
||||
});
|
||||
}
|
||||
|
||||
async dismissTaskSuggestion(taskId: string) {
|
||||
return await this.client.request<{ taskId: string; dismissed: boolean }>(
|
||||
"taskSuggestions.dismiss",
|
||||
{ taskId },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveGatewayConnection(
|
||||
|
|
|
|||
|
|
@ -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<CommandEntry[]>;
|
||||
listPluginApprovals?: () => Promise<unknown>;
|
||||
resolvePluginApproval?: (id: string, decision: TuiApprovalDecision) => Promise<{ ok?: boolean }>;
|
||||
getTaskSuggestionActionCapabilities?: () => TuiTaskSuggestionActionCapabilities;
|
||||
listTaskSuggestions?: () => Promise<TaskSuggestion[]>;
|
||||
acceptTaskSuggestion?: (taskId: string) => Promise<TaskSuggestionsAcceptResult>;
|
||||
dismissTaskSuggestion?: (taskId: string) => Promise<{ taskId: string; dismissed: boolean }>;
|
||||
runGoalCommand?: (opts: TuiGoalCommandOptions) => Promise<{ text: string }>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
367
src/tui/tui-task-suggestions.test.ts
Normal file
367
src/tui/tui-task-suggestions.test.ts
Normal file
|
|
@ -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<typeof vi.fn<(index: number) => void>>;
|
||||
};
|
||||
|
||||
function suggestionPayload(overrides: Record<string, unknown> = {}) {
|
||||
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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((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<unknown[]>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
456
src/tui/tui-task-suggestions.ts
Normal file
456
src/tui/tui-task-suggestions.ts
Normal file
|
|
@ -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> | 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<string, unknown> {
|
||||
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<string, TaskSuggestion>();
|
||||
const hiddenIds = new Set<string>();
|
||||
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<void> | 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<void> => {
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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<TuiResult> {
|
|||
set currentAgentId(value) {
|
||||
currentAgentId = value;
|
||||
pluginApprovals?.sessionChanged();
|
||||
taskSuggestions?.sessionChanged();
|
||||
},
|
||||
get currentSessionKey() {
|
||||
return currentSessionKey;
|
||||
|
|
@ -620,6 +622,7 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
|
|||
set currentSessionKey(value) {
|
||||
currentSessionKey = value;
|
||||
pluginApprovals?.sessionChanged();
|
||||
taskSuggestions?.sessionChanged();
|
||||
},
|
||||
get currentSessionId() {
|
||||
return currentSessionId;
|
||||
|
|
@ -1320,6 +1323,16 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
|
|||
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<TuiResult> {
|
|||
...(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<TuiResult> {
|
|||
|
||||
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<TuiResult> {
|
|||
} 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<TuiResult> {
|
|||
} 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<TuiResult> {
|
|||
await new Promise<void>((resolve) => {
|
||||
const finish = () => {
|
||||
pluginApprovals?.dispose();
|
||||
taskSuggestions?.dispose();
|
||||
if (isLocalMode) {
|
||||
setConsoleSubsystemFilter(previousConsoleSubsystemFilter);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue