mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-20 21:23:23 +00:00
feat(agents): add structured execution item events
This commit is contained in:
@@ -8,7 +8,10 @@ import {
|
||||
overflowBaseRunParams,
|
||||
resetRunOverflowCompactionHarnessMocks,
|
||||
} from "./run.overflow-compaction.harness.js";
|
||||
import { resolvePlanningOnlyRetryInstruction } from "./run/incomplete-turn.js";
|
||||
import {
|
||||
extractPlanningOnlyPlanDetails,
|
||||
resolvePlanningOnlyRetryInstruction,
|
||||
} from "./run/incomplete-turn.js";
|
||||
import type { EmbeddedRunAttemptResult } from "./run/types.js";
|
||||
|
||||
let runEmbeddedPiAgent: typeof import("./run.js").runEmbeddedPiAgent;
|
||||
@@ -97,4 +100,15 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
|
||||
|
||||
expect(retryInstruction).toBeNull();
|
||||
});
|
||||
|
||||
it("extracts structured steps from planning-only narration", () => {
|
||||
expect(
|
||||
extractPlanningOnlyPlanDetails(
|
||||
"I'll inspect the code. Then I'll patch the issue. Finally I'll run tests.",
|
||||
),
|
||||
).toEqual({
|
||||
explanation: "I'll inspect the code. Then I'll patch the issue. Finally I'll run tests.",
|
||||
steps: ["I'll inspect the code.", "Then I'll patch the issue.", "Finally I'll run tests."],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ensureContextEnginesInitialized,
|
||||
resolveContextEngine,
|
||||
} from "../../context-engine/index.js";
|
||||
import { emitAgentPlanEvent } from "../../infra/agent-events.js";
|
||||
import { sleepWithAbort } from "../../infra/backoff.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import { enqueueCommandInLane } from "../../process/command-queue.js";
|
||||
@@ -82,6 +83,7 @@ import {
|
||||
} from "./run/helpers.js";
|
||||
import {
|
||||
resolveIncompleteTurnPayloadText,
|
||||
extractPlanningOnlyPlanDetails,
|
||||
resolvePlanningOnlyRetryInstruction,
|
||||
} from "./run/incomplete-turn.js";
|
||||
import type { RunEmbeddedPiAgentParams } from "./run/params.js";
|
||||
@@ -1357,6 +1359,31 @@ export async function runEmbeddedPiAgent(
|
||||
nextPlanningOnlyRetryInstruction &&
|
||||
planningOnlyRetryAttempts < 1
|
||||
) {
|
||||
const planningOnlyText = attempt.assistantTexts.join("\n\n").trim();
|
||||
const planDetails = extractPlanningOnlyPlanDetails(planningOnlyText);
|
||||
if (planDetails) {
|
||||
emitAgentPlanEvent({
|
||||
runId: params.runId,
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
data: {
|
||||
phase: "update",
|
||||
title: "Assistant proposed a plan",
|
||||
explanation: planDetails.explanation,
|
||||
steps: planDetails.steps,
|
||||
source: "planning_only_retry",
|
||||
},
|
||||
});
|
||||
void params.onAgentEvent?.({
|
||||
stream: "plan",
|
||||
data: {
|
||||
phase: "update",
|
||||
title: "Assistant proposed a plan",
|
||||
explanation: planDetails.explanation,
|
||||
steps: planDetails.steps,
|
||||
source: "planning_only_retry",
|
||||
},
|
||||
});
|
||||
}
|
||||
planningOnlyRetryAttempts += 1;
|
||||
planningOnlyRetryInstruction = nextPlanningOnlyRetryInstruction;
|
||||
log.warn(
|
||||
|
||||
@@ -38,6 +38,11 @@ const PLANNING_ONLY_COMPLETION_RE =
|
||||
export const PLANNING_ONLY_RETRY_INSTRUCTION =
|
||||
"The previous assistant turn only described the plan. Do not restate the plan. Act now: take the first concrete tool action you can. If a real blocker prevents action, reply with the exact blocker in one sentence.";
|
||||
|
||||
export type PlanningOnlyPlanDetails = {
|
||||
explanation: string;
|
||||
steps: string[];
|
||||
};
|
||||
|
||||
export function buildAttemptReplayMetadata(
|
||||
params: ReplayMetadataAttempt,
|
||||
): EmbeddedRunAttemptResult["replayMetadata"] {
|
||||
@@ -88,6 +93,36 @@ function shouldApplyPlanningOnlyRetryGuard(params: {
|
||||
return /^gpt-5(?:[.-]|$)/i.test(params.modelId ?? "");
|
||||
}
|
||||
|
||||
function extractPlanningOnlySteps(text: string): string[] {
|
||||
const lines = text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const bulletLines = lines
|
||||
.map((line) => line.replace(/^[-*•]\s+|^\d+[.)]\s+/u, "").trim())
|
||||
.filter(Boolean);
|
||||
if (bulletLines.length >= 2) {
|
||||
return bulletLines.slice(0, 4);
|
||||
}
|
||||
return text
|
||||
.split(/(?<=[.!?])\s+/u)
|
||||
.map((step) => step.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 4);
|
||||
}
|
||||
|
||||
export function extractPlanningOnlyPlanDetails(text: string): PlanningOnlyPlanDetails | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const steps = extractPlanningOnlySteps(trimmed);
|
||||
return {
|
||||
explanation: trimmed,
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePlanningOnlyRetryInstruction(params: {
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
|
||||
@@ -17,14 +17,16 @@ function createTestContext(): {
|
||||
ctx: ToolHandlerContext;
|
||||
warn: ReturnType<typeof vi.fn>;
|
||||
onBlockReplyFlush: ReturnType<typeof vi.fn>;
|
||||
onAgentEvent: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const onBlockReplyFlush = vi.fn();
|
||||
const onAgentEvent = vi.fn();
|
||||
const warn = vi.fn();
|
||||
const ctx: ToolHandlerContext = {
|
||||
params: {
|
||||
runId: "run-test",
|
||||
onBlockReplyFlush,
|
||||
onAgentEvent: undefined,
|
||||
onAgentEvent,
|
||||
onToolResult: undefined,
|
||||
},
|
||||
flushBlockReplyBuffer: vi.fn(),
|
||||
@@ -59,7 +61,7 @@ function createTestContext(): {
|
||||
trimMessagingToolSent: vi.fn(),
|
||||
};
|
||||
|
||||
return { ctx, warn, onBlockReplyFlush };
|
||||
return { ctx, warn, onBlockReplyFlush, onAgentEvent };
|
||||
}
|
||||
|
||||
describe("handleToolExecutionStart read path checks", () => {
|
||||
@@ -124,8 +126,9 @@ describe("handleToolExecutionStart read path checks", () => {
|
||||
await pending;
|
||||
|
||||
expect(ctx.state.toolMetaById.has("tool-await-flush")).toBe(true);
|
||||
expect(ctx.state.itemStartedCount).toBe(1);
|
||||
expect(ctx.state.itemStartedCount).toBe(2);
|
||||
expect(ctx.state.itemActiveIds.has("tool:tool-await-flush")).toBe(true);
|
||||
expect(ctx.state.itemActiveIds.has("command:tool-await-flush")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -482,6 +485,157 @@ describe("handleToolExecutionEnd exec approval prompts", () => {
|
||||
|
||||
expect(ctx.state.deterministicApprovalPromptSent).toBe(false);
|
||||
});
|
||||
|
||||
it("emits approval + blocked command item events when exec needs approval", async () => {
|
||||
const { ctx, onAgentEvent } = createTestContext();
|
||||
|
||||
await handleToolExecutionStart(
|
||||
ctx as never,
|
||||
{
|
||||
type: "tool_execution_start",
|
||||
toolName: "exec",
|
||||
toolCallId: "tool-exec-approval-events",
|
||||
args: { command: "npm test" },
|
||||
} as never,
|
||||
);
|
||||
|
||||
await handleToolExecutionEnd(
|
||||
ctx as never,
|
||||
{
|
||||
type: "tool_execution_end",
|
||||
toolName: "exec",
|
||||
toolCallId: "tool-exec-approval-events",
|
||||
isError: false,
|
||||
result: {
|
||||
details: {
|
||||
status: "approval-pending",
|
||||
approvalId: "12345678-1234-1234-1234-123456789012",
|
||||
approvalSlug: "12345678",
|
||||
host: "gateway",
|
||||
command: "npm test",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(onAgentEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stream: "approval",
|
||||
data: expect.objectContaining({
|
||||
phase: "requested",
|
||||
status: "pending",
|
||||
itemId: "command:tool-exec-approval-events",
|
||||
approvalId: "12345678-1234-1234-1234-123456789012",
|
||||
approvalSlug: "12345678",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(onAgentEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stream: "item",
|
||||
data: expect.objectContaining({
|
||||
itemId: "command:tool-exec-approval-events",
|
||||
phase: "end",
|
||||
status: "blocked",
|
||||
summary: "Awaiting approval before command can run.",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleToolExecutionEnd derived tool events", () => {
|
||||
it("emits command output events for exec results", async () => {
|
||||
const { ctx, onAgentEvent } = createTestContext();
|
||||
|
||||
await handleToolExecutionStart(
|
||||
ctx as never,
|
||||
{
|
||||
type: "tool_execution_start",
|
||||
toolName: "exec",
|
||||
toolCallId: "tool-exec-output",
|
||||
args: { command: "ls" },
|
||||
} as never,
|
||||
);
|
||||
|
||||
await handleToolExecutionEnd(
|
||||
ctx as never,
|
||||
{
|
||||
type: "tool_execution_end",
|
||||
toolName: "exec",
|
||||
toolCallId: "tool-exec-output",
|
||||
isError: false,
|
||||
result: {
|
||||
details: {
|
||||
status: "completed",
|
||||
aggregated: "README.md",
|
||||
exitCode: 0,
|
||||
durationMs: 10,
|
||||
cwd: "/tmp/work",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(onAgentEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stream: "command_output",
|
||||
data: expect.objectContaining({
|
||||
itemId: "command:tool-exec-output",
|
||||
phase: "end",
|
||||
output: "README.md",
|
||||
exitCode: 0,
|
||||
cwd: "/tmp/work",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits patch summary events for apply_patch results", async () => {
|
||||
const { ctx, onAgentEvent } = createTestContext();
|
||||
|
||||
await handleToolExecutionStart(
|
||||
ctx as never,
|
||||
{
|
||||
type: "tool_execution_start",
|
||||
toolName: "apply_patch",
|
||||
toolCallId: "tool-patch-summary",
|
||||
args: { patch: "*** Begin Patch" },
|
||||
} as never,
|
||||
);
|
||||
|
||||
await handleToolExecutionEnd(
|
||||
ctx as never,
|
||||
{
|
||||
type: "tool_execution_end",
|
||||
toolName: "apply_patch",
|
||||
toolCallId: "tool-patch-summary",
|
||||
isError: false,
|
||||
result: {
|
||||
details: {
|
||||
summary: {
|
||||
added: ["a.ts"],
|
||||
modified: ["b.ts"],
|
||||
deleted: ["c.ts"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(onAgentEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stream: "patch",
|
||||
data: expect.objectContaining({
|
||||
itemId: "patch:tool-patch-summary",
|
||||
added: ["a.ts"],
|
||||
modified: ["b.ts"],
|
||||
deleted: ["c.ts"],
|
||||
summary: "1 added, 1 modified, 1 deleted",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("messaging tool media URL tracking", () => {
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import type { AgentEvent } from "@mariozechner/pi-agent-core";
|
||||
import type { AgentItemEventData } from "../infra/agent-events.js";
|
||||
import { emitAgentEvent, emitAgentItemEvent } from "../infra/agent-events.js";
|
||||
import type {
|
||||
AgentApprovalEventData,
|
||||
AgentCommandOutputEventData,
|
||||
AgentItemEventData,
|
||||
AgentPatchSummaryEventData,
|
||||
} from "../infra/agent-events.js";
|
||||
import {
|
||||
emitAgentApprovalEvent,
|
||||
emitAgentCommandOutputEvent,
|
||||
emitAgentEvent,
|
||||
emitAgentItemEvent,
|
||||
emitAgentPatchSummaryEvent,
|
||||
} from "../infra/agent-events.js";
|
||||
import {
|
||||
buildExecApprovalPendingReplyPayload,
|
||||
buildExecApprovalUnavailableReplyPayload,
|
||||
@@ -8,6 +19,9 @@ import {
|
||||
import type { ExecApprovalDecision } from "../infra/exec-approvals.js";
|
||||
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
||||
import type { PluginHookAfterToolCallEvent } from "../plugins/types.js";
|
||||
import type { ApplyPatchSummary } from "./apply-patch.js";
|
||||
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
|
||||
import { parseExecApprovalResultText } from "./exec-approval-result.js";
|
||||
import { normalizeTextForComparison } from "./pi-embedded-helpers.js";
|
||||
import { isMessagingTool, isMessagingToolSendAction } from "./pi-embedded-messaging.js";
|
||||
import type {
|
||||
@@ -67,6 +81,102 @@ function buildToolItemTitle(toolName: string, meta?: string): string {
|
||||
return meta ? `${toolName} ${meta}` : toolName;
|
||||
}
|
||||
|
||||
function isExecToolName(toolName: string): boolean {
|
||||
return toolName === "exec" || toolName === "bash";
|
||||
}
|
||||
|
||||
function isPatchToolName(toolName: string): boolean {
|
||||
return toolName === "apply_patch";
|
||||
}
|
||||
|
||||
function buildCommandItemId(toolCallId: string): string {
|
||||
return `command:${toolCallId}`;
|
||||
}
|
||||
|
||||
function buildPatchItemId(toolCallId: string): string {
|
||||
return `patch:${toolCallId}`;
|
||||
}
|
||||
|
||||
function buildCommandItemTitle(toolName: string, meta?: string): string {
|
||||
return meta ? `command ${meta}` : `${toolName} command`;
|
||||
}
|
||||
|
||||
function buildPatchItemTitle(meta?: string): string {
|
||||
return meta ? `patch ${meta}` : "apply patch";
|
||||
}
|
||||
|
||||
function emitTrackedItemEvent(ctx: ToolHandlerContext, itemData: AgentItemEventData): void {
|
||||
if (itemData.phase === "start") {
|
||||
ctx.state.itemActiveIds.add(itemData.itemId);
|
||||
ctx.state.itemStartedCount += 1;
|
||||
} else if (itemData.phase === "end") {
|
||||
ctx.state.itemActiveIds.delete(itemData.itemId);
|
||||
ctx.state.itemCompletedCount += 1;
|
||||
}
|
||||
emitAgentItemEvent({
|
||||
runId: ctx.params.runId,
|
||||
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
|
||||
data: itemData,
|
||||
});
|
||||
void ctx.params.onAgentEvent?.({
|
||||
stream: "item",
|
||||
data: itemData,
|
||||
});
|
||||
}
|
||||
|
||||
function readToolResultDetailsRecord(result: unknown): Record<string, unknown> | undefined {
|
||||
if (!result || typeof result !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const details = (result as { details?: unknown }).details;
|
||||
return details && typeof details === "object" && !Array.isArray(details)
|
||||
? (details as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readExecToolDetails(result: unknown): ExecToolDetails | null {
|
||||
const details = readToolResultDetailsRecord(result);
|
||||
if (!details || typeof details.status !== "string") {
|
||||
return null;
|
||||
}
|
||||
return details as ExecToolDetails;
|
||||
}
|
||||
|
||||
function readApplyPatchSummary(result: unknown): ApplyPatchSummary | null {
|
||||
const details = readToolResultDetailsRecord(result);
|
||||
const summary =
|
||||
details?.summary && typeof details.summary === "object" && !Array.isArray(details.summary)
|
||||
? (details.summary as Record<string, unknown>)
|
||||
: null;
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
const added = Array.isArray(summary.added)
|
||||
? summary.added.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
const modified = Array.isArray(summary.modified)
|
||||
? summary.modified.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
const deleted = Array.isArray(summary.deleted)
|
||||
? summary.deleted.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
return { added, modified, deleted };
|
||||
}
|
||||
|
||||
function buildPatchSummaryText(summary: ApplyPatchSummary): string {
|
||||
const parts: string[] = [];
|
||||
if (summary.added.length > 0) {
|
||||
parts.push(`${summary.added.length} added`);
|
||||
}
|
||||
if (summary.modified.length > 0) {
|
||||
parts.push(`${summary.modified.length} modified`);
|
||||
}
|
||||
if (summary.deleted.length > 0) {
|
||||
parts.push(`${summary.deleted.length} deleted`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(", ") : "no file changes recorded";
|
||||
}
|
||||
|
||||
function extendExecMeta(toolName: string, args: unknown, meta?: string): string | undefined {
|
||||
const normalized = toolName.trim().toLowerCase();
|
||||
if (normalized !== "exec" && normalized !== "bash") {
|
||||
@@ -416,22 +526,38 @@ export function handleToolExecutionStart(
|
||||
toolCallId,
|
||||
startedAt,
|
||||
};
|
||||
ctx.state.itemActiveIds.add(itemData.itemId);
|
||||
ctx.state.itemStartedCount += 1;
|
||||
emitAgentItemEvent({
|
||||
runId: ctx.params.runId,
|
||||
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
|
||||
data: itemData,
|
||||
});
|
||||
emitTrackedItemEvent(ctx, itemData);
|
||||
// Best-effort typing signal; do not block tool summaries on slow emitters.
|
||||
void ctx.params.onAgentEvent?.({
|
||||
stream: "tool",
|
||||
data: { phase: "start", name: toolName, toolCallId },
|
||||
});
|
||||
void ctx.params.onAgentEvent?.({
|
||||
stream: "item",
|
||||
data: itemData,
|
||||
});
|
||||
|
||||
if (isExecToolName(toolName)) {
|
||||
emitTrackedItemEvent(ctx, {
|
||||
itemId: buildCommandItemId(toolCallId),
|
||||
phase: "start",
|
||||
kind: "command",
|
||||
title: buildCommandItemTitle(toolName, meta),
|
||||
status: "running",
|
||||
name: toolName,
|
||||
meta,
|
||||
toolCallId,
|
||||
startedAt,
|
||||
});
|
||||
} else if (isPatchToolName(toolName)) {
|
||||
emitTrackedItemEvent(ctx, {
|
||||
itemId: buildPatchItemId(toolCallId),
|
||||
phase: "start",
|
||||
kind: "patch",
|
||||
title: buildPatchItemTitle(meta),
|
||||
status: "running",
|
||||
name: toolName,
|
||||
meta,
|
||||
toolCallId,
|
||||
startedAt,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
ctx.params.onToolResult &&
|
||||
@@ -506,11 +632,7 @@ export function handleToolExecutionUpdate(
|
||||
meta: ctx.state.toolMetaById.get(toolCallId)?.meta,
|
||||
toolCallId,
|
||||
};
|
||||
emitAgentItemEvent({
|
||||
runId: ctx.params.runId,
|
||||
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
|
||||
data: itemData,
|
||||
});
|
||||
emitTrackedItemEvent(ctx, itemData);
|
||||
void ctx.params.onAgentEvent?.({
|
||||
stream: "tool",
|
||||
data: {
|
||||
@@ -519,10 +641,41 @@ export function handleToolExecutionUpdate(
|
||||
toolCallId,
|
||||
},
|
||||
});
|
||||
void ctx.params.onAgentEvent?.({
|
||||
stream: "item",
|
||||
data: itemData,
|
||||
});
|
||||
if (isExecToolName(toolName)) {
|
||||
const output = extractToolResultText(sanitized);
|
||||
const commandData: AgentItemEventData = {
|
||||
itemId: buildCommandItemId(toolCallId),
|
||||
phase: "update",
|
||||
kind: "command",
|
||||
title: buildCommandItemTitle(toolName, ctx.state.toolMetaById.get(toolCallId)?.meta),
|
||||
status: "running",
|
||||
name: toolName,
|
||||
meta: ctx.state.toolMetaById.get(toolCallId)?.meta,
|
||||
toolCallId,
|
||||
...(output ? { progressText: output } : {}),
|
||||
};
|
||||
emitTrackedItemEvent(ctx, commandData);
|
||||
if (output) {
|
||||
const outputData: AgentCommandOutputEventData = {
|
||||
itemId: commandData.itemId,
|
||||
phase: "delta",
|
||||
title: commandData.title,
|
||||
toolCallId,
|
||||
name: toolName,
|
||||
output,
|
||||
status: "running",
|
||||
};
|
||||
emitAgentCommandOutputEvent({
|
||||
runId: ctx.params.runId,
|
||||
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
|
||||
data: outputData,
|
||||
});
|
||||
void ctx.params.onAgentEvent?.({
|
||||
stream: "command_output",
|
||||
data: outputData,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleToolExecutionEnd(
|
||||
@@ -639,8 +792,6 @@ export async function handleToolExecutionEnd(
|
||||
});
|
||||
const endedAt = Date.now();
|
||||
const itemId = buildToolItemId(toolCallId);
|
||||
ctx.state.itemActiveIds.delete(itemId);
|
||||
ctx.state.itemCompletedCount += 1;
|
||||
const itemData: AgentItemEventData = {
|
||||
itemId,
|
||||
phase: "end",
|
||||
@@ -656,11 +807,7 @@ export async function handleToolExecutionEnd(
|
||||
? { error: extractToolErrorMessage(sanitizedResult) }
|
||||
: {}),
|
||||
};
|
||||
emitAgentItemEvent({
|
||||
runId: ctx.params.runId,
|
||||
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
|
||||
data: itemData,
|
||||
});
|
||||
emitTrackedItemEvent(ctx, itemData);
|
||||
void ctx.params.onAgentEvent?.({
|
||||
stream: "tool",
|
||||
data: {
|
||||
@@ -671,10 +818,186 @@ export async function handleToolExecutionEnd(
|
||||
isError: isToolError,
|
||||
},
|
||||
});
|
||||
void ctx.params.onAgentEvent?.({
|
||||
stream: "item",
|
||||
data: itemData,
|
||||
});
|
||||
|
||||
if (isExecToolName(toolName)) {
|
||||
const execDetails = readExecToolDetails(result);
|
||||
const commandItemId = buildCommandItemId(toolCallId);
|
||||
if (
|
||||
execDetails?.status === "approval-pending" ||
|
||||
execDetails?.status === "approval-unavailable"
|
||||
) {
|
||||
const approvalStatus = execDetails.status === "approval-pending" ? "pending" : "unavailable";
|
||||
const approvalData: AgentApprovalEventData = {
|
||||
phase: "requested",
|
||||
kind: "exec",
|
||||
status: approvalStatus,
|
||||
title:
|
||||
approvalStatus === "pending"
|
||||
? "Command approval requested"
|
||||
: "Command approval unavailable",
|
||||
itemId: commandItemId,
|
||||
toolCallId,
|
||||
...(execDetails.status === "approval-pending"
|
||||
? {
|
||||
approvalId: execDetails.approvalId,
|
||||
approvalSlug: execDetails.approvalSlug,
|
||||
}
|
||||
: {}),
|
||||
command: execDetails.command,
|
||||
host: execDetails.host,
|
||||
...(execDetails.status === "approval-unavailable" ? { reason: execDetails.reason } : {}),
|
||||
message: execDetails.warningText,
|
||||
};
|
||||
emitAgentApprovalEvent({
|
||||
runId: ctx.params.runId,
|
||||
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
|
||||
data: approvalData,
|
||||
});
|
||||
void ctx.params.onAgentEvent?.({
|
||||
stream: "approval",
|
||||
data: approvalData,
|
||||
});
|
||||
emitTrackedItemEvent(ctx, {
|
||||
itemId: commandItemId,
|
||||
phase: "end",
|
||||
kind: "command",
|
||||
title: buildCommandItemTitle(toolName, meta),
|
||||
status: "blocked",
|
||||
name: toolName,
|
||||
meta,
|
||||
toolCallId,
|
||||
startedAt: startData?.startTime,
|
||||
endedAt,
|
||||
...(execDetails.status === "approval-pending"
|
||||
? {
|
||||
approvalId: execDetails.approvalId,
|
||||
approvalSlug: execDetails.approvalSlug,
|
||||
summary: "Awaiting approval before command can run.",
|
||||
}
|
||||
: {
|
||||
summary: "Command is blocked because no interactive approval route is available.",
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
const output =
|
||||
execDetails && "aggregated" in execDetails
|
||||
? execDetails.aggregated
|
||||
: extractToolResultText(sanitizedResult);
|
||||
const commandStatus =
|
||||
execDetails?.status === "failed" || isToolError ? "failed" : "completed";
|
||||
emitTrackedItemEvent(ctx, {
|
||||
itemId: commandItemId,
|
||||
phase: "end",
|
||||
kind: "command",
|
||||
title: buildCommandItemTitle(toolName, meta),
|
||||
status: commandStatus,
|
||||
name: toolName,
|
||||
meta,
|
||||
toolCallId,
|
||||
startedAt: startData?.startTime,
|
||||
endedAt,
|
||||
...(output ? { summary: output } : {}),
|
||||
...(isToolError && extractToolErrorMessage(sanitizedResult)
|
||||
? { error: extractToolErrorMessage(sanitizedResult) }
|
||||
: {}),
|
||||
});
|
||||
const outputData: AgentCommandOutputEventData = {
|
||||
itemId: commandItemId,
|
||||
phase: "end",
|
||||
title: buildCommandItemTitle(toolName, meta),
|
||||
toolCallId,
|
||||
name: toolName,
|
||||
...(output ? { output } : {}),
|
||||
status: commandStatus,
|
||||
...(execDetails && "exitCode" in execDetails ? { exitCode: execDetails.exitCode } : {}),
|
||||
...(execDetails && "durationMs" in execDetails
|
||||
? { durationMs: execDetails.durationMs }
|
||||
: {}),
|
||||
...(execDetails && "cwd" in execDetails && typeof execDetails.cwd === "string"
|
||||
? { cwd: execDetails.cwd }
|
||||
: {}),
|
||||
};
|
||||
emitAgentCommandOutputEvent({
|
||||
runId: ctx.params.runId,
|
||||
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
|
||||
data: outputData,
|
||||
});
|
||||
void ctx.params.onAgentEvent?.({
|
||||
stream: "command_output",
|
||||
data: outputData,
|
||||
});
|
||||
|
||||
if (typeof output === "string") {
|
||||
const parsedApprovalResult = parseExecApprovalResultText(output);
|
||||
if (parsedApprovalResult.kind === "denied") {
|
||||
const approvalData: AgentApprovalEventData = {
|
||||
phase: "resolved",
|
||||
kind: "exec",
|
||||
status: parsedApprovalResult.metadata.toLowerCase().includes("approval-request-failed")
|
||||
? "failed"
|
||||
: "denied",
|
||||
title: "Command approval resolved",
|
||||
itemId: commandItemId,
|
||||
toolCallId,
|
||||
message: parsedApprovalResult.body || parsedApprovalResult.raw,
|
||||
};
|
||||
emitAgentApprovalEvent({
|
||||
runId: ctx.params.runId,
|
||||
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
|
||||
data: approvalData,
|
||||
});
|
||||
void ctx.params.onAgentEvent?.({
|
||||
stream: "approval",
|
||||
data: approvalData,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isPatchToolName(toolName)) {
|
||||
const patchSummary = readApplyPatchSummary(result);
|
||||
const patchItemId = buildPatchItemId(toolCallId);
|
||||
const summaryText = patchSummary ? buildPatchSummaryText(patchSummary) : undefined;
|
||||
emitTrackedItemEvent(ctx, {
|
||||
itemId: patchItemId,
|
||||
phase: "end",
|
||||
kind: "patch",
|
||||
title: buildPatchItemTitle(meta),
|
||||
status: isToolError ? "failed" : "completed",
|
||||
name: toolName,
|
||||
meta,
|
||||
toolCallId,
|
||||
startedAt: startData?.startTime,
|
||||
endedAt,
|
||||
...(summaryText ? { summary: summaryText } : {}),
|
||||
...(isToolError && extractToolErrorMessage(sanitizedResult)
|
||||
? { error: extractToolErrorMessage(sanitizedResult) }
|
||||
: {}),
|
||||
});
|
||||
if (patchSummary) {
|
||||
const patchData: AgentPatchSummaryEventData = {
|
||||
itemId: patchItemId,
|
||||
phase: "end",
|
||||
title: buildPatchItemTitle(meta),
|
||||
toolCallId,
|
||||
name: toolName,
|
||||
added: patchSummary.added,
|
||||
modified: patchSummary.modified,
|
||||
deleted: patchSummary.deleted,
|
||||
summary: summaryText ?? buildPatchSummaryText(patchSummary),
|
||||
};
|
||||
emitAgentPatchSummaryEvent({
|
||||
runId: ctx.params.runId,
|
||||
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
|
||||
data: patchData,
|
||||
});
|
||||
void ctx.params.onAgentEvent?.({
|
||||
stream: "patch",
|
||||
data: patchData,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ctx.log.debug(
|
||||
`embedded run tool end: runId=${ctx.params.runId} tool=${toolName} toolCallId=${toolCallId}`,
|
||||
|
||||
@@ -124,18 +124,14 @@ type EmbeddedAgentParams = {
|
||||
name?: string;
|
||||
phase?: string;
|
||||
status?: string;
|
||||
summary?: string;
|
||||
progressText?: string;
|
||||
approvalId?: string;
|
||||
approvalSlug?: string;
|
||||
}) => Promise<void> | void;
|
||||
onAgentEvent?: (payload: {
|
||||
stream: string;
|
||||
data: {
|
||||
itemId?: string;
|
||||
kind?: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
phase?: string;
|
||||
status?: string;
|
||||
completed?: boolean;
|
||||
};
|
||||
data: Record<string, unknown>;
|
||||
}) => Promise<void> | void;
|
||||
};
|
||||
|
||||
@@ -309,6 +305,134 @@ describe("runAgentTurnWithFallback", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards plan, approval, command output, and patch events", async () => {
|
||||
const onPlanUpdate = vi.fn();
|
||||
const onApprovalEvent = vi.fn();
|
||||
const onCommandOutput = vi.fn();
|
||||
const onPatchSummary = vi.fn();
|
||||
state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => {
|
||||
await params.onAgentEvent?.({
|
||||
stream: "plan",
|
||||
data: {
|
||||
phase: "update",
|
||||
title: "Assistant proposed a plan",
|
||||
explanation: "Inspect code, patch it, run tests.",
|
||||
steps: ["Inspect code", "Patch code", "Run tests"],
|
||||
},
|
||||
});
|
||||
await params.onAgentEvent?.({
|
||||
stream: "approval",
|
||||
data: {
|
||||
phase: "requested",
|
||||
kind: "exec",
|
||||
status: "pending",
|
||||
title: "Command approval requested",
|
||||
approvalId: "approval-1",
|
||||
},
|
||||
});
|
||||
await params.onAgentEvent?.({
|
||||
stream: "command_output",
|
||||
data: {
|
||||
itemId: "command:exec-1",
|
||||
phase: "delta",
|
||||
title: "command ls",
|
||||
toolCallId: "exec-1",
|
||||
output: "README.md",
|
||||
},
|
||||
});
|
||||
await params.onAgentEvent?.({
|
||||
stream: "patch",
|
||||
data: {
|
||||
itemId: "patch:patch-1",
|
||||
phase: "end",
|
||||
title: "apply patch",
|
||||
toolCallId: "patch-1",
|
||||
added: ["a.ts"],
|
||||
modified: ["b.ts"],
|
||||
deleted: [],
|
||||
summary: "1 added, 1 modified",
|
||||
},
|
||||
});
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
});
|
||||
|
||||
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
|
||||
const pendingToolTasks = new Set<Promise<void>>();
|
||||
await runAgentTurnWithFallback({
|
||||
commandBody: "hello",
|
||||
followupRun: createFollowupRun(),
|
||||
sessionCtx: {
|
||||
Provider: "whatsapp",
|
||||
MessageSid: "msg",
|
||||
} as unknown as TemplateContext,
|
||||
opts: {
|
||||
onPlanUpdate,
|
||||
onApprovalEvent,
|
||||
onCommandOutput,
|
||||
onPatchSummary,
|
||||
} satisfies GetReplyOptions,
|
||||
typingSignals: createMockTypingSignaler(),
|
||||
blockReplyPipeline: null,
|
||||
blockStreamingEnabled: false,
|
||||
resolvedBlockStreamingBreak: "message_end",
|
||||
applyReplyToMode: (payload) => payload,
|
||||
shouldEmitToolResult: () => true,
|
||||
shouldEmitToolOutput: () => false,
|
||||
pendingToolTasks,
|
||||
resetSessionAfterCompactionFailure: async () => false,
|
||||
resetSessionAfterRoleOrderingConflict: async () => false,
|
||||
isHeartbeat: false,
|
||||
sessionKey: "main",
|
||||
getActiveSessionEntry: () => undefined,
|
||||
resolvedVerboseLevel: "off",
|
||||
});
|
||||
|
||||
expect(onPlanUpdate).toHaveBeenCalledWith({
|
||||
phase: "update",
|
||||
title: "Assistant proposed a plan",
|
||||
explanation: "Inspect code, patch it, run tests.",
|
||||
steps: ["Inspect code", "Patch code", "Run tests"],
|
||||
source: undefined,
|
||||
});
|
||||
expect(onApprovalEvent).toHaveBeenCalledWith({
|
||||
phase: "requested",
|
||||
kind: "exec",
|
||||
status: "pending",
|
||||
title: "Command approval requested",
|
||||
itemId: undefined,
|
||||
toolCallId: undefined,
|
||||
approvalId: "approval-1",
|
||||
approvalSlug: undefined,
|
||||
command: undefined,
|
||||
host: undefined,
|
||||
reason: undefined,
|
||||
message: undefined,
|
||||
});
|
||||
expect(onCommandOutput).toHaveBeenCalledWith({
|
||||
itemId: "command:exec-1",
|
||||
phase: "delta",
|
||||
title: "command ls",
|
||||
toolCallId: "exec-1",
|
||||
name: undefined,
|
||||
output: "README.md",
|
||||
status: undefined,
|
||||
exitCode: undefined,
|
||||
durationMs: undefined,
|
||||
cwd: undefined,
|
||||
});
|
||||
expect(onPatchSummary).toHaveBeenCalledWith({
|
||||
itemId: "patch:patch-1",
|
||||
phase: "end",
|
||||
title: "apply patch",
|
||||
toolCallId: "patch-1",
|
||||
name: undefined,
|
||||
added: ["a.ts"],
|
||||
modified: ["b.ts"],
|
||||
deleted: [],
|
||||
summary: "1 added, 1 modified",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps compaction start notices silent by default", async () => {
|
||||
const onBlockReply = vi.fn();
|
||||
state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => {
|
||||
|
||||
@@ -726,6 +726,95 @@ export async function runAgentTurnWithFallback(params: {
|
||||
name: typeof evt.data.name === "string" ? evt.data.name : undefined,
|
||||
phase: typeof evt.data.phase === "string" ? evt.data.phase : undefined,
|
||||
status: typeof evt.data.status === "string" ? evt.data.status : undefined,
|
||||
summary: typeof evt.data.summary === "string" ? evt.data.summary : undefined,
|
||||
progressText:
|
||||
typeof evt.data.progressText === "string"
|
||||
? evt.data.progressText
|
||||
: undefined,
|
||||
approvalId:
|
||||
typeof evt.data.approvalId === "string" ? evt.data.approvalId : undefined,
|
||||
approvalSlug:
|
||||
typeof evt.data.approvalSlug === "string"
|
||||
? evt.data.approvalSlug
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
if (evt.stream === "plan") {
|
||||
await params.opts?.onPlanUpdate?.({
|
||||
phase: typeof evt.data.phase === "string" ? evt.data.phase : undefined,
|
||||
title: typeof evt.data.title === "string" ? evt.data.title : undefined,
|
||||
explanation:
|
||||
typeof evt.data.explanation === "string" ? evt.data.explanation : undefined,
|
||||
steps: Array.isArray(evt.data.steps)
|
||||
? evt.data.steps.filter((step): step is string => typeof step === "string")
|
||||
: undefined,
|
||||
source: typeof evt.data.source === "string" ? evt.data.source : undefined,
|
||||
});
|
||||
}
|
||||
if (evt.stream === "approval") {
|
||||
await params.opts?.onApprovalEvent?.({
|
||||
phase: typeof evt.data.phase === "string" ? evt.data.phase : undefined,
|
||||
kind: typeof evt.data.kind === "string" ? evt.data.kind : undefined,
|
||||
status: typeof evt.data.status === "string" ? evt.data.status : undefined,
|
||||
title: typeof evt.data.title === "string" ? evt.data.title : undefined,
|
||||
itemId: typeof evt.data.itemId === "string" ? evt.data.itemId : undefined,
|
||||
toolCallId:
|
||||
typeof evt.data.toolCallId === "string" ? evt.data.toolCallId : undefined,
|
||||
approvalId:
|
||||
typeof evt.data.approvalId === "string" ? evt.data.approvalId : undefined,
|
||||
approvalSlug:
|
||||
typeof evt.data.approvalSlug === "string"
|
||||
? evt.data.approvalSlug
|
||||
: undefined,
|
||||
command: typeof evt.data.command === "string" ? evt.data.command : undefined,
|
||||
host: typeof evt.data.host === "string" ? evt.data.host : undefined,
|
||||
reason: typeof evt.data.reason === "string" ? evt.data.reason : undefined,
|
||||
message: typeof evt.data.message === "string" ? evt.data.message : undefined,
|
||||
});
|
||||
}
|
||||
if (evt.stream === "command_output") {
|
||||
await params.opts?.onCommandOutput?.({
|
||||
itemId: typeof evt.data.itemId === "string" ? evt.data.itemId : undefined,
|
||||
phase: typeof evt.data.phase === "string" ? evt.data.phase : undefined,
|
||||
title: typeof evt.data.title === "string" ? evt.data.title : undefined,
|
||||
toolCallId:
|
||||
typeof evt.data.toolCallId === "string" ? evt.data.toolCallId : undefined,
|
||||
name: typeof evt.data.name === "string" ? evt.data.name : undefined,
|
||||
output: typeof evt.data.output === "string" ? evt.data.output : undefined,
|
||||
status: typeof evt.data.status === "string" ? evt.data.status : undefined,
|
||||
exitCode:
|
||||
typeof evt.data.exitCode === "number" || evt.data.exitCode === null
|
||||
? evt.data.exitCode
|
||||
: undefined,
|
||||
durationMs:
|
||||
typeof evt.data.durationMs === "number" ? evt.data.durationMs : undefined,
|
||||
cwd: typeof evt.data.cwd === "string" ? evt.data.cwd : undefined,
|
||||
});
|
||||
}
|
||||
if (evt.stream === "patch") {
|
||||
await params.opts?.onPatchSummary?.({
|
||||
itemId: typeof evt.data.itemId === "string" ? evt.data.itemId : undefined,
|
||||
phase: typeof evt.data.phase === "string" ? evt.data.phase : undefined,
|
||||
title: typeof evt.data.title === "string" ? evt.data.title : undefined,
|
||||
toolCallId:
|
||||
typeof evt.data.toolCallId === "string" ? evt.data.toolCallId : undefined,
|
||||
name: typeof evt.data.name === "string" ? evt.data.name : undefined,
|
||||
added: Array.isArray(evt.data.added)
|
||||
? evt.data.added.filter(
|
||||
(entry): entry is string => typeof entry === "string",
|
||||
)
|
||||
: undefined,
|
||||
modified: Array.isArray(evt.data.modified)
|
||||
? evt.data.modified.filter(
|
||||
(entry): entry is string => typeof entry === "string",
|
||||
)
|
||||
: undefined,
|
||||
deleted: Array.isArray(evt.data.deleted)
|
||||
? evt.data.deleted.filter(
|
||||
(entry): entry is string => typeof entry === "string",
|
||||
)
|
||||
: undefined,
|
||||
summary: typeof evt.data.summary === "string" ? evt.data.summary : undefined,
|
||||
});
|
||||
}
|
||||
// Track auto-compaction and notify higher layers.
|
||||
|
||||
@@ -984,6 +984,79 @@ describe("dispatchReplyFromConfig", () => {
|
||||
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
||||
});
|
||||
|
||||
it("renders concise plan and approval progress updates for direct sessions", async () => {
|
||||
setNoAbort();
|
||||
const cfg = emptyConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
Provider: "telegram",
|
||||
ChatType: "direct",
|
||||
});
|
||||
|
||||
const replyResolver = async (
|
||||
_ctx: MsgContext,
|
||||
opts?: GetReplyOptions,
|
||||
_cfg?: OpenClawConfig,
|
||||
) => {
|
||||
await opts?.onPlanUpdate?.({
|
||||
phase: "update",
|
||||
explanation: "Inspect code, patch it, run tests.",
|
||||
steps: ["Inspect code", "Patch code", "Run tests"],
|
||||
});
|
||||
await opts?.onApprovalEvent?.({
|
||||
phase: "requested",
|
||||
status: "pending",
|
||||
command: "pnpm test",
|
||||
});
|
||||
return { text: "done" } satisfies ReplyPayload;
|
||||
};
|
||||
|
||||
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||
|
||||
expect(dispatcher.sendToolResult).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ text: "Working: Inspect code" }),
|
||||
);
|
||||
expect(dispatcher.sendToolResult).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ text: "Working: awaiting approval: pnpm test" }),
|
||||
);
|
||||
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(2);
|
||||
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
||||
});
|
||||
|
||||
it("renders concise patch summaries for direct sessions", async () => {
|
||||
setNoAbort();
|
||||
const cfg = emptyConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
Provider: "telegram",
|
||||
ChatType: "direct",
|
||||
});
|
||||
|
||||
const replyResolver = async (
|
||||
_ctx: MsgContext,
|
||||
opts?: GetReplyOptions,
|
||||
_cfg?: OpenClawConfig,
|
||||
) => {
|
||||
await opts?.onPatchSummary?.({
|
||||
phase: "end",
|
||||
title: "apply patch",
|
||||
summary: "1 added, 2 modified",
|
||||
});
|
||||
return { text: "done" } satisfies ReplyPayload;
|
||||
};
|
||||
|
||||
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||
|
||||
expect(dispatcher.sendToolResult).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ text: "Working: 1 added, 2 modified" }),
|
||||
);
|
||||
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
|
||||
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
||||
});
|
||||
|
||||
it("delivers deterministic exec approval tool payloads for native commands", async () => {
|
||||
setNoAbort();
|
||||
const cfg = emptyConfig;
|
||||
|
||||
@@ -603,8 +603,25 @@ export async function dispatchReplyFromConfig(params: {
|
||||
const shouldSendToolStartStatuses = ctx.ChatType !== "group" || ctx.IsForum === true;
|
||||
const toolStartStatusesSent = new Set<string>();
|
||||
let toolStartStatusCount = 0;
|
||||
const normalizeWorkingLabel = (label: string) => {
|
||||
const collapsed = label.replace(/\s+/g, " ").trim();
|
||||
if (collapsed.length <= 80) {
|
||||
return collapsed;
|
||||
}
|
||||
return `${collapsed.slice(0, 77).trimEnd()}...`;
|
||||
};
|
||||
const summarizePlanLabel = (payload: { explanation?: string; steps?: string[] }) => {
|
||||
const firstStep = payload.steps?.find((step) => typeof step === "string" && step.trim());
|
||||
if (firstStep) {
|
||||
return normalizeWorkingLabel(firstStep);
|
||||
}
|
||||
if (payload.explanation?.trim()) {
|
||||
return normalizeWorkingLabel(payload.explanation);
|
||||
}
|
||||
return "planning next steps";
|
||||
};
|
||||
const maybeSendWorkingStatus = (label: string) => {
|
||||
const normalizedLabel = label.trim();
|
||||
const normalizedLabel = normalizeWorkingLabel(label);
|
||||
if (
|
||||
!shouldSendToolStartStatuses ||
|
||||
!normalizedLabel ||
|
||||
@@ -623,6 +640,34 @@ export async function dispatchReplyFromConfig(params: {
|
||||
}
|
||||
dispatcher.sendToolResult(payload);
|
||||
};
|
||||
const summarizeApprovalLabel = (payload: {
|
||||
status?: string;
|
||||
command?: string;
|
||||
message?: string;
|
||||
}) => {
|
||||
if (payload.status === "pending") {
|
||||
if (payload.command?.trim()) {
|
||||
return normalizeWorkingLabel(`awaiting approval: ${payload.command}`);
|
||||
}
|
||||
return "awaiting approval";
|
||||
}
|
||||
if (payload.status === "unavailable") {
|
||||
if (payload.message?.trim()) {
|
||||
return normalizeWorkingLabel(payload.message);
|
||||
}
|
||||
return "approval unavailable";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
const summarizePatchLabel = (payload: { summary?: string; title?: string }) => {
|
||||
if (payload.summary?.trim()) {
|
||||
return normalizeWorkingLabel(payload.summary);
|
||||
}
|
||||
if (payload.title?.trim()) {
|
||||
return normalizeWorkingLabel(payload.title);
|
||||
}
|
||||
return "";
|
||||
};
|
||||
const acpDispatch = await dispatchAcpRuntime.tryDispatchAcpReply({
|
||||
ctx,
|
||||
cfg,
|
||||
@@ -741,6 +786,32 @@ export async function dispatchReplyFromConfig(params: {
|
||||
return maybeSendWorkingStatus(title);
|
||||
}
|
||||
},
|
||||
onPlanUpdate: ({ phase, explanation, steps }) => {
|
||||
if (phase !== "update") {
|
||||
return;
|
||||
}
|
||||
return maybeSendWorkingStatus(summarizePlanLabel({ explanation, steps }));
|
||||
},
|
||||
onApprovalEvent: ({ phase, status, command, message }) => {
|
||||
if (phase !== "requested") {
|
||||
return;
|
||||
}
|
||||
const label = summarizeApprovalLabel({ status, command, message });
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
return maybeSendWorkingStatus(label);
|
||||
},
|
||||
onPatchSummary: ({ phase, summary, title }) => {
|
||||
if (phase !== "end") {
|
||||
return;
|
||||
}
|
||||
const label = summarizePatchLabel({ summary, title });
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
return maybeSendWorkingStatus(label);
|
||||
},
|
||||
onBlockReply: (payload: ReplyPayload, context?: BlockReplyContext) => {
|
||||
const run = async () => {
|
||||
// Suppress reasoning payloads — channels using this generic dispatch
|
||||
|
||||
@@ -72,6 +72,58 @@ export type GetReplyOptions = {
|
||||
name?: string;
|
||||
phase?: string;
|
||||
status?: string;
|
||||
summary?: string;
|
||||
progressText?: string;
|
||||
approvalId?: string;
|
||||
approvalSlug?: string;
|
||||
}) => Promise<void> | void;
|
||||
/** Called when the agent emits a structured plan update. */
|
||||
onPlanUpdate?: (payload: {
|
||||
phase?: string;
|
||||
title?: string;
|
||||
explanation?: string;
|
||||
steps?: string[];
|
||||
source?: string;
|
||||
}) => Promise<void> | void;
|
||||
/** Called when an approval becomes pending or resolves. */
|
||||
onApprovalEvent?: (payload: {
|
||||
phase?: string;
|
||||
kind?: string;
|
||||
status?: string;
|
||||
title?: string;
|
||||
itemId?: string;
|
||||
toolCallId?: string;
|
||||
approvalId?: string;
|
||||
approvalSlug?: string;
|
||||
command?: string;
|
||||
host?: string;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
}) => Promise<void> | void;
|
||||
/** Called when command output streams or completes. */
|
||||
onCommandOutput?: (payload: {
|
||||
itemId?: string;
|
||||
phase?: string;
|
||||
title?: string;
|
||||
toolCallId?: string;
|
||||
name?: string;
|
||||
output?: string;
|
||||
status?: string;
|
||||
exitCode?: number | null;
|
||||
durationMs?: number;
|
||||
cwd?: string;
|
||||
}) => Promise<void> | void;
|
||||
/** Called when a patch completes with a file summary. */
|
||||
onPatchSummary?: (payload: {
|
||||
itemId?: string;
|
||||
phase?: string;
|
||||
title?: string;
|
||||
toolCallId?: string;
|
||||
name?: string;
|
||||
added?: string[];
|
||||
modified?: string[];
|
||||
deleted?: string[];
|
||||
summary?: string;
|
||||
}) => Promise<void> | void;
|
||||
/** Called when context auto-compaction starts (allows UX feedback during the pause). */
|
||||
onCompactionStart?: () => Promise<void> | void;
|
||||
|
||||
@@ -2,7 +2,19 @@ import type { VerboseLevel } from "../auto-reply/thinking.js";
|
||||
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
|
||||
import { notifyListeners, registerListener } from "../shared/listeners.js";
|
||||
|
||||
export type AgentEventStream = "lifecycle" | "tool" | "assistant" | "error" | (string & {});
|
||||
export type AgentEventStream =
|
||||
| "lifecycle"
|
||||
| "tool"
|
||||
| "assistant"
|
||||
| "error"
|
||||
| "item"
|
||||
| "plan"
|
||||
| "approval"
|
||||
| "command_output"
|
||||
| "patch"
|
||||
| "compaction"
|
||||
| "thinking"
|
||||
| (string & {});
|
||||
|
||||
export type AgentItemEventPhase = "start" | "update" | "end";
|
||||
export type AgentItemEventStatus = "running" | "completed" | "failed" | "blocked";
|
||||
@@ -26,6 +38,62 @@ export type AgentItemEventData = {
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
error?: string;
|
||||
summary?: string;
|
||||
progressText?: string;
|
||||
approvalId?: string;
|
||||
approvalSlug?: string;
|
||||
};
|
||||
|
||||
export type AgentPlanEventData = {
|
||||
phase: "update";
|
||||
title: string;
|
||||
explanation?: string;
|
||||
steps?: string[];
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type AgentApprovalEventPhase = "requested" | "resolved";
|
||||
export type AgentApprovalEventStatus = "pending" | "unavailable" | "approved" | "denied" | "failed";
|
||||
export type AgentApprovalEventKind = "exec" | "plugin" | "unknown";
|
||||
|
||||
export type AgentApprovalEventData = {
|
||||
phase: AgentApprovalEventPhase;
|
||||
kind: AgentApprovalEventKind;
|
||||
status: AgentApprovalEventStatus;
|
||||
title: string;
|
||||
itemId?: string;
|
||||
toolCallId?: string;
|
||||
approvalId?: string;
|
||||
approvalSlug?: string;
|
||||
command?: string;
|
||||
host?: string;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type AgentCommandOutputEventData = {
|
||||
itemId: string;
|
||||
phase: "delta" | "end";
|
||||
title: string;
|
||||
toolCallId: string;
|
||||
name?: string;
|
||||
output?: string;
|
||||
status?: AgentItemEventStatus | "running";
|
||||
exitCode?: number | null;
|
||||
durationMs?: number;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
export type AgentPatchSummaryEventData = {
|
||||
itemId: string;
|
||||
phase: "end";
|
||||
title: string;
|
||||
toolCallId: string;
|
||||
name?: string;
|
||||
added: string[];
|
||||
modified: string[];
|
||||
deleted: string[];
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export type AgentEventPayload = {
|
||||
@@ -128,6 +196,58 @@ export function emitAgentItemEvent(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export function emitAgentPlanEvent(params: {
|
||||
runId: string;
|
||||
data: AgentPlanEventData;
|
||||
sessionKey?: string;
|
||||
}) {
|
||||
emitAgentEvent({
|
||||
runId: params.runId,
|
||||
stream: "plan",
|
||||
data: params.data as unknown as Record<string, unknown>,
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function emitAgentApprovalEvent(params: {
|
||||
runId: string;
|
||||
data: AgentApprovalEventData;
|
||||
sessionKey?: string;
|
||||
}) {
|
||||
emitAgentEvent({
|
||||
runId: params.runId,
|
||||
stream: "approval",
|
||||
data: params.data as unknown as Record<string, unknown>,
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function emitAgentCommandOutputEvent(params: {
|
||||
runId: string;
|
||||
data: AgentCommandOutputEventData;
|
||||
sessionKey?: string;
|
||||
}) {
|
||||
emitAgentEvent({
|
||||
runId: params.runId,
|
||||
stream: "command_output",
|
||||
data: params.data as unknown as Record<string, unknown>,
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function emitAgentPatchSummaryEvent(params: {
|
||||
runId: string;
|
||||
data: AgentPatchSummaryEventData;
|
||||
sessionKey?: string;
|
||||
}) {
|
||||
emitAgentEvent({
|
||||
runId: params.runId,
|
||||
stream: "patch",
|
||||
data: params.data as unknown as Record<string, unknown>,
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function onAgentEvent(listener: (evt: AgentEventPayload) => void) {
|
||||
const state = getAgentEventState();
|
||||
return registerListener(state.listeners, listener);
|
||||
|
||||
Reference in New Issue
Block a user