Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -858,23 +858,25 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
}

sendQueuedImmediatelyInFlightRef.current = queuedMessage.id;
// Release the duplicate-send guard only if it still points at this attempt; a
// newer queued message (or a clear) may have already reset it in the meantime.
const clearInFlightGuardIfCurrent = () => {
if (sendQueuedImmediatelyInFlightRef.current === queuedMessage.id) {
sendQueuedImmediatelyInFlightRef.current = null;
}
};
try {
// Set "interrupting" state immediately so UI shows "interrupting..." without flash.
storeRaw.setInterrupting(workspaceId);
const interruptResult = await api.workspace.interruptStream({
workspaceId,
options: { sendQueuedImmediately: true },
});
if (
!interruptResult.success &&
sendQueuedImmediatelyInFlightRef.current === queuedMessage.id
) {
sendQueuedImmediatelyInFlightRef.current = null;
if (!interruptResult.success) {
clearInFlightGuardIfCurrent();
}
} catch (error) {
if (sendQueuedImmediatelyInFlightRef.current === queuedMessage.id) {
sendQueuedImmediatelyInFlightRef.current = null;
}
clearInFlightGuardIfCurrent();
throw error;
}
}, [api, workspaceId, workspaceState?.queuedMessage, workspaceState?.canInterrupt, storeRaw]);
Expand Down
10 changes: 7 additions & 3 deletions src/browser/features/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,10 @@ function estimateBase64DataUrlBytes(dataUrl: string): number | null {
}
const MAX_PERSISTED_ATTACHMENT_DRAFT_CHARS = 4_000_000;

// Shared so the three "blocked while editing a message" attachment guards surface identical copy
// and can't drift if one is reworded.
const EDIT_MODE_ATTACHMENT_ERROR_MESSAGE = "Attachments cannot be added while editing a message.";

export type { ChatInputProps, ChatInputAPI };

interface SendOverrides {
Expand Down Expand Up @@ -2002,7 +2006,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
if (editingMessageForUi) {
pushToast({
type: "error",
message: "Attachments cannot be added while editing a message.",
message: EDIT_MODE_ATTACHMENT_ERROR_MESSAGE,
});
return;
}
Expand Down Expand Up @@ -2041,7 +2045,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
if (editingMessageForUi) {
pushToast({
type: "error",
message: "Attachments cannot be added while editing a message.",
message: EDIT_MODE_ATTACHMENT_ERROR_MESSAGE,
});
return;
}
Expand Down Expand Up @@ -2219,7 +2223,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
if (editingMessageForUi) {
pushToast({
type: "error",
message: "Attachments cannot be added while editing a message.",
message: EDIT_MODE_ATTACHMENT_ERROR_MESSAGE,
});
return;
}
Expand Down
11 changes: 8 additions & 3 deletions src/browser/features/Tools/Goal/goalToolUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,15 @@ export function formatGoalElapsed(startedAtMs: number, nowMs: number = Date.now(
return `${hours}h ${minutes % 60}m`;
}

// Shared turn-count pluralization so the goal toolcards render "1 turn" /
// "N turns" consistently (was open-coded in formatGoalTurns and the set_goal
// toolcard's requested-turns formatter).
export function pluralizeTurns(count: number): string {
return `${count} turn${count === 1 ? "" : "s"}`;
}

export function formatGoalTurns(turnsUsed: number, turnCap: number | null): string {
return turnCap == null
? `${turnsUsed} turn${turnsUsed === 1 ? "" : "s"}`
: `${turnsUsed} / ${turnCap} turns`;
return turnCap == null ? pluralizeTurns(turnsUsed) : `${turnsUsed} / ${turnCap} turns`;
}

export function formatGoalBudgetSummary(costCents: number, budgetCents: number | null): string {
Expand Down
3 changes: 2 additions & 1 deletion src/browser/features/Tools/SetGoalToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
extractGoalFromResult,
formatGoalCents,
formatGoalTurns,
pluralizeTurns,
} from "./Goal/goalToolUtils";

interface SetGoalToolCallProps {
Expand All @@ -43,7 +44,7 @@ function formatAppliedBudget(budgetCents: number | null): string {
}

function formatOptionalTurnCap(turnCap: number | null | undefined): string {
return turnCap == null ? "Workspace default" : `${turnCap} turn${turnCap === 1 ? "" : "s"}`;
return turnCap == null ? "Workspace default" : pluralizeTurns(turnCap);
}

export const SetGoalToolCall: React.FC<SetGoalToolCallProps> = ({
Expand Down
71 changes: 31 additions & 40 deletions src/node/services/agentSkills/agentSkillsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,42 +187,42 @@ async function readSkillDescriptorFromDir(
): Promise<AgentSkillDescriptor | null> {
const skillFilePath = runtime.normalizePath("SKILL.md", skillDir);

let stat;
try {
stat = await runtime.stat(skillFilePath);
} catch {
// Every invalid-skill diagnostic for this directory shares the same identity
// (directoryName / scope / displayPath); only message + hint vary per failure.
const pushInvalidSkill = (message: string, hint: string): void => {
options?.invalidSkills?.push({
directoryName,
scope,
displayPath: skillFilePath,
message: "SKILL.md is missing or unreadable.",
hint: "Create a SKILL.md file with YAML frontmatter (--- ... ---).",
message,
hint,
});
};

let stat;
try {
stat = await runtime.stat(skillFilePath);
} catch {
pushInvalidSkill(
"SKILL.md is missing or unreadable.",
"Create a SKILL.md file with YAML frontmatter (--- ... ---)."
);
return null;
}

if (stat.isDirectory) {
options?.invalidSkills?.push({
directoryName,
scope,
displayPath: skillFilePath,
message: "SKILL.md is a directory (expected a file).",
hint: "Replace SKILL.md with a regular file.",
});
pushInvalidSkill(
"SKILL.md is a directory (expected a file).",
"Replace SKILL.md with a regular file."
);
return null;
}

// Avoid reading very large files into memory (parseSkillMarkdown enforces the same limit).
const sizeValidation = validateFileSize(stat);
if (sizeValidation) {
log.warn(`Skipping skill '${directoryName}' (${scope}): ${sizeValidation.error}`);
options?.invalidSkills?.push({
directoryName,
scope,
displayPath: skillFilePath,
message: sizeValidation.error,
hint: "Reduce SKILL.md size below 1MB.",
});
pushInvalidSkill(sizeValidation.error, "Reduce SKILL.md size below 1MB.");
return null;
}

Expand All @@ -232,13 +232,10 @@ async function readSkillDescriptorFromDir(
} catch (err) {
const message = getErrorMessage(err);
log.warn(`Failed to read SKILL.md for ${directoryName}: ${message}`);
options?.invalidSkills?.push({
directoryName,
scope,
displayPath: skillFilePath,
message: `Failed to read SKILL.md: ${message}`,
hint: "Check file permissions and ensure the file is UTF-8 text.",
});
pushInvalidSkill(
`Failed to read SKILL.md: ${message}`,
"Check file permissions and ensure the file is UTF-8 text."
);
return null;
}

Expand All @@ -259,27 +256,21 @@ async function readSkillDescriptorFromDir(
const validated = AgentSkillDescriptorSchema.safeParse(descriptor);
if (!validated.success) {
log.warn(`Invalid agent skill descriptor for ${directoryName}: ${validated.error.message}`);
options?.invalidSkills?.push({
directoryName,
scope,
displayPath: skillFilePath,
message: `Invalid agent skill descriptor: ${validated.error.message}`,
hint: "Fix SKILL.md frontmatter fields to satisfy the skill schema.",
});
pushInvalidSkill(
`Invalid agent skill descriptor: ${validated.error.message}`,
"Fix SKILL.md frontmatter fields to satisfy the skill schema."
);
return null;
}

return validated.data;
} catch (err) {
const message = err instanceof AgentSkillParseError ? err.message : getErrorMessage(err);
log.warn(`Skipping invalid skill '${directoryName}' (${scope}): ${message}`);
options?.invalidSkills?.push({
directoryName,
scope,
displayPath: skillFilePath,
pushInvalidSkill(
message,
hint: "Fix SKILL.md frontmatter (name + description) and ensure it matches the directory name.",
});
"Fix SKILL.md frontmatter (name + description) and ensure it matches the directory name."
);
return null;
}
}
Expand Down
27 changes: 17 additions & 10 deletions src/node/services/taskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1698,12 +1698,22 @@ export class TaskService {

let config = this.config.loadConfigOrDefault();
let taskIndex = this.buildAgentTaskIndex(config);
let awaitingReportTasks = this.listAgentTaskWorkspaces(config).filter(
(t) => t.taskStatus === "awaiting_report"
);
let runningTasks = this.listAgentTaskWorkspaces(config).filter(
(t) => t.taskStatus === "running"
);
// Recompute the startup recovery candidate lists from a config snapshot. Hoisted into a
// closure so the post-interrupt refresh below reuses the exact same status filters.
const listStartupRecoveryCandidates = (
sourceConfig: ProjectsConfig
): {
awaitingReportTasks: AgentTaskWorkspaceEntry[];
runningTasks: AgentTaskWorkspaceEntry[];
} => ({
awaitingReportTasks: this.listAgentTaskWorkspaces(sourceConfig).filter(
(t) => t.taskStatus === "awaiting_report"
),
runningTasks: this.listAgentTaskWorkspaces(sourceConfig).filter(
(t) => t.taskStatus === "running"
),
});
let { awaitingReportTasks, runningTasks } = listStartupRecoveryCandidates(config);

let interruptedInactiveWorkflowOwnerAtStartup = false;
for (const task of [...awaitingReportTasks, ...runningTasks]) {
Expand All @@ -1725,10 +1735,7 @@ export class TaskService {
// blocked by a child that this startup pass just interrupted.
config = this.config.loadConfigOrDefault();
taskIndex = this.buildAgentTaskIndex(config);
awaitingReportTasks = this.listAgentTaskWorkspaces(config).filter(
(t) => t.taskStatus === "awaiting_report"
);
runningTasks = this.listAgentTaskWorkspaces(config).filter((t) => t.taskStatus === "running");
({ awaitingReportTasks, runningTasks } = listStartupRecoveryCandidates(config));
}

let resumedAwaitingReportCount = 0;
Expand Down
46 changes: 24 additions & 22 deletions src/node/services/tools/heartbeat.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { tool } from "ai";
import assert from "@/common/utils/assert";
import type { ToolFactory, WorkspaceHeartbeatSettingsUpdate } from "@/common/utils/tools/tools";
import type {
ToolFactory,
WorkspaceHeartbeatSettings,
WorkspaceHeartbeatSettingsUpdate,
} from "@/common/utils/tools/tools";
import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions";
import type { HeartbeatToolArgs, HeartbeatToolResult } from "@/common/types/tools";
import { getErrorMessage } from "@/common/utils/errors";
Expand Down Expand Up @@ -51,6 +55,22 @@ function summarize(
return `Heartbeat is ${status} for this workspace at ${formatInterval(settings.intervalMs)}.`;
}

// Build the shared success payload for every heartbeat action so the get/set/unset branches
// don't each re-assemble the same { action, configured, settings, summary } object. `configured`
// is derived from settings: null only for unset and an unconfigured get, non-null otherwise.
function buildSuccessResult(
action: HeartbeatToolArgs["action"],
settings: WorkspaceHeartbeatSettings | null
): HeartbeatToolResult {
return {
success: true,
action,
configured: settings != null,
settings,
summary: summarize({ action, settings }),
};
}

export const createHeartbeatTool: ToolFactory = (config) =>
tool({
description: TOOL_DEFINITIONS.heartbeat.description,
Expand All @@ -66,27 +86,15 @@ export const createHeartbeatTool: ToolFactory = (config) =>

if (args.action === "get") {
const settings = heartbeatService.getHeartbeatSettings(workspaceId);
return {
success: true,
action: args.action,
configured: settings != null,
settings,
summary: summarize({ action: args.action, settings }),
};
return buildSuccessResult(args.action, settings);
}

if (args.action === "unset") {
const unsetResult = await heartbeatService.unsetHeartbeatSettings(workspaceId);
if (!unsetResult.success) {
return { success: false, error: unsetResult.error };
}
return {
success: true,
action: args.action,
configured: false,
settings: null,
summary: summarize({ action: args.action, settings: null }),
};
return buildSuccessResult(args.action, null);
}

const settingsUpdate: WorkspaceHeartbeatSettingsUpdate = {};
Expand All @@ -109,13 +117,7 @@ export const createHeartbeatTool: ToolFactory = (config) =>
}

const settings = setResult.data;
return {
success: true,
action: args.action,
configured: true,
settings,
summary: summarize({ action: args.action, settings }),
};
return buildSuccessResult(args.action, settings);
} catch (error) {
return { success: false, error: getErrorMessage(error) };
}
Expand Down
9 changes: 7 additions & 2 deletions src/node/services/tools/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ import {
import { normalizeModelInput } from "@/common/utils/ai/normalizeModelInput";
import { coerceNonEmptyString } from "@/node/services/taskUtils";

// Plan agent is read-only: only `explore` sub-agent tasks may be spawned. Shared by both the
// workspace-turn guard and the per-launch agent-id guard so the message can't drift between them.
const PLAN_AGENT_EXPLORE_ONLY_ERROR =
'In the plan agent you may only spawn agentId: "explore" tasks.';

const BUILT_IN_TASK_TOOL_MARKER = Symbol("muxBuiltInTaskTool");

export function markBuiltInTaskTool<TParameters, TResult>(
Expand Down Expand Up @@ -402,7 +407,7 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => {
const parentRuntimeAiSettings = buildParentRuntimeAiSettings(config);

if (config.planFileOnly && kind === "workspace") {
throw new Error('In the plan agent you may only spawn agentId: "explore" tasks.');
throw new Error(PLAN_AGENT_EXPLORE_ONLY_ERROR);
}

if (kind === "workspace") {
Expand Down Expand Up @@ -504,7 +509,7 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => {

// Plan agent is explicitly non-executing. Allow only read-only exploration tasks.
if (config.planFileOnly && requestedAgentId !== "explore") {
throw new Error('In the plan agent you may only spawn agentId: "explore" tasks.');
throw new Error(PLAN_AGENT_EXPLORE_ONLY_ERROR);
}

// Parent runtime model and thinking are forwarded as a low-priority fallback so
Expand Down
Loading
Loading