Skip to content
Merged
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
36 changes: 21 additions & 15 deletions docs/hooks/tools.mdx

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions src/common/types/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,15 @@ export type MuxMessageMetadata = MuxMessageMetadataBase &
rawCommand: string;
runId: string;
}
| {
// Internal correlation marker for full-workspace turns launched through task(kind="workspace").
// The workspace remains a normal workspace; this metadata only lets TaskService correlate the
// final assistant stream-end/error back to the durable workspace-turn handle.
type: "workspace-turn-task";
taskHandleId: string;
ownerWorkspaceId: string;
turnId: string;
}
| {
// /btw — user-side marker for a side question.
//
Expand Down
22 changes: 22 additions & 0 deletions src/common/types/workspaceTurn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { z } from "zod";

export const WorkspaceTurnFinalMessageRefSchema = z
.object({
messageId: z.string().min(1),
model: z.string().optional(),
agentId: z.string().optional(),
finishReason: z.string().optional(),
usageSummary: z
.object({
inputTokens: z.number().optional(),
outputTokens: z.number().optional(),
totalTokens: z.number().optional(),
})
.strict()
.optional(),
partCount: z.number().int().min(0).optional(),
textCharCount: z.number().int().min(0).optional(),
})
.strict();

export type WorkspaceTurnFinalMessageRef = z.infer<typeof WorkspaceTurnFinalMessageRefSchema>;
56 changes: 56 additions & 0 deletions src/common/utils/tools/toolDefinitions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,62 @@ describe("TOOL_DEFINITIONS", () => {
).toBe(false);
});

it("accepts workspace task args without an agent id", () => {
const parsed = TaskToolArgsSchema.safeParse({
kind: "workspace",
prompt: "Summarize this repository",
title: "Repository summary",
run_in_background: true,
});

expect(parsed.success).toBe(true);
if (parsed.success) {
expect(parsed.data.kind).toBe("workspace");
expect(parsed.data.agentId).toBeUndefined();
expect(parsed.data.subagent_type).toBeUndefined();
}
});

it("rejects workspace task fanout until workspace handles support it", () => {
expect(
TaskToolArgsSchema.safeParse({
kind: "workspace",
prompt: "Summarize this repository",
title: "Repository summary",
n: 2,
}).success
).toBe(false);

expect(
TaskToolArgsSchema.safeParse({
kind: "workspace",
prompt: "Summarize ${variant}",
title: "Repository summary",
variants: ["frontend", "backend"],
}).success
).toBe(false);
});

it("requires workspaceId for existing workspace task targets", () => {
expect(
TaskToolArgsSchema.safeParse({
kind: "workspace",
prompt: "Continue in that workspace",
title: "Follow-up",
workspace: { mode: "existing" },
}).success
).toBe(false);

expect(
TaskToolArgsSchema.safeParse({
kind: "workspace",
prompt: "Continue in that workspace",
title: "Follow-up",
workspace: { mode: "existing", workspaceId: "child-workspace" },
}).success
).toBe(true);
});

it("accepts bash tool calls using command (alias for script)", () => {
const parsed = TOOL_DEFINITIONS.bash.schema.safeParse({
command: "ls",
Expand Down
75 changes: 75 additions & 0 deletions src/common/utils/tools/toolDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { THINKING_LEVELS } from "@/common/types/thinking";
import { zodToJsonSchema } from "zod-to-json-schema";
import { extractToolFilePath } from "@/common/utils/tools/toolInputFilePath";
import { TASK_VARIANT_PLACEHOLDER, TASK_GROUP_KIND_VALUES } from "@/common/utils/tools/taskGroups";
import { WorkspaceTurnFinalMessageRefSchema } from "@/common/types/workspaceTurn";

import {
HEARTBEAT_CONTEXT_MODE_VALUES,
Expand Down Expand Up @@ -333,20 +334,67 @@ export function buildTaskToolDescription(runtimeMode: RuntimeMode | undefined):
);
}

const WorkspaceTaskKindSchema = z.enum(["subagent", "workspace"]);
const WorkspaceTaskModeSchema = z.enum(["new", "fork", "existing"]);
const WorkspaceTaskTargetSchema = z
.object({
mode: WorkspaceTaskModeSchema.nullish(),
workspaceId: z.string().trim().min(1).nullish(),
branchName: z.string().trim().min(1).nullish(),
trunkBranch: z.string().trim().min(1).nullish(),
disposable: z.boolean().nullish(),
})
.strict();

/** Shared validation across both task-arg schema variants (with/without `isolation`). */
function refineTaskToolAgentArgs(
args: {
kind?: "subagent" | "workspace" | null;
agentId?: string | null;
subagent_type?: string | null;
prompt: string;
n?: number | null;
variants?: string[] | null;
workspace?: { mode?: "new" | "fork" | "existing" | null; workspaceId?: string | null } | null;
},
ctx: z.RefinementCtx
): void {
const kind = args.kind ?? "subagent";
const hasAgentId = typeof args.agentId === "string" && args.agentId.length > 0;
const hasSubagentType = typeof args.subagent_type === "string" && args.subagent_type.length > 0;

if (kind === "workspace") {
if (hasAgentId || hasSubagentType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Workspace tasks do not accept agentId or subagent_type",
path: ["agentId"],
});
}
if (args.n != null || args.variants != null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Workspace tasks do not support n or variants yet",
path: args.n != null ? ["n"] : ["variants"],
});
}
if ((args.workspace?.mode ?? "new") === "fork") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'workspace.mode="fork" is not supported for workspace tasks yet',
path: ["workspace", "mode"],
});
}
if ((args.workspace?.mode ?? "new") === "existing" && args.workspace?.workspaceId == null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "workspace.workspaceId is required when workspace.mode is existing",
path: ["workspace", "workspaceId"],
});
}
return;
}

if (!hasAgentId && !hasSubagentType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
Expand Down Expand Up @@ -398,6 +446,9 @@ function refineTaskToolAgentArgs(
}

const taskToolBaseShape = {
kind: WorkspaceTaskKindSchema.nullish().describe(
'Task kind. Omit or use "subagent" for the existing child-workspace sub-agent flow; use "workspace" to start a normal full workspace turn.'
),
// Prefer agentId. subagent_type is a deprecated alias for backwards compatibility.
agentId: TaskAgentIdSchema.nullish(),
subagent_type: SubagentTypeSchema.nullish(),
Expand All @@ -410,6 +461,9 @@ const taskToolBaseShape = {
variants: TaskToolVariantsSchema.nullish().describe(
`Optional labels for sibling runs of the same prompt template. Use variants when the task should be repeated across labeled lanes such as issue numbers, commit windows, or frontend/backend/tests/docs review lanes. Mutually exclusive with n. When provided, Mux launches one sibling per label and substitutes ${TASK_VARIANT_PLACEHOLDER} in the prompt.`
),
workspace: WorkspaceTaskTargetSchema.nullish().describe(
'Workspace target for kind="workspace". Omit for a new full workspace; use mode="existing" with workspaceId only for workspaces previously created by this caller.'
),
model: TaskToolModelSchema.nullish().describe(
"Optional model override for the sub-agent, parsed with the same alias logic as the UI (an alias or a full 'provider:model' string). Omit this unless the user explicitly instructed a specific model — by default the sub-agent inherits the parent's model. Do not assume any particular model is available."
),
Expand Down Expand Up @@ -446,10 +500,13 @@ export function buildTaskToolAgentArgsSchema(options: {
return options.includeIsolation ? TaskToolArgsSchema : TaskToolArgsSchemaWithoutIsolation;
}

const TaskHandleKindSchema = z.enum(["agent_task", "workspace_turn"]);
const TaskToolSpawnedTaskSchema = z
.object({
taskId: z.string(),
status: z.enum(["queued", "starting", "running", "completed", "interrupted"]),
handleKind: TaskHandleKindSchema.optional(),
workspaceId: z.string().optional(),
groupKind: z.enum(TASK_GROUP_KIND_VALUES).optional(),
label: z.string().optional(),
})
Expand All @@ -463,6 +520,10 @@ const TaskToolCompletedReportSchema = z
structuredOutput: z.unknown().optional(),
agentId: z.string().optional(),
agentType: z.string().optional(),
handleKind: TaskHandleKindSchema.optional(),
workspaceId: z.string().optional(),
messageId: z.string().optional(),
finalMessageRef: WorkspaceTurnFinalMessageRefSchema.optional(),
groupKind: z.enum(TASK_GROUP_KIND_VALUES).optional(),
label: z.string().optional(),
})
Expand All @@ -473,6 +534,8 @@ export const TaskToolQueuedResultSchema = z
status: z.enum(["queued", "starting", "running"]),
taskId: z.string().optional(),
taskIds: z.array(z.string()).min(1).optional(),
handleKind: TaskHandleKindSchema.optional(),
workspaceId: z.string().optional(),
tasks: z.array(TaskToolSpawnedTaskSchema).min(1).optional(),
reports: z.array(TaskToolCompletedReportSchema).min(1).optional(),
note: z
Expand Down Expand Up @@ -505,6 +568,10 @@ export const TaskToolCompletedResultSchema = z
structuredOutput: z.unknown().optional(),
agentId: z.string().optional(),
agentType: z.string().optional(),
handleKind: TaskHandleKindSchema.optional(),
workspaceId: z.string().optional(),
messageId: z.string().optional(),
finalMessageRef: WorkspaceTurnFinalMessageRefSchema.optional(),
reports: z.array(TaskToolCompletedReportSchema).min(1).optional(),
})
.strict()
Expand Down Expand Up @@ -674,6 +741,10 @@ export const TaskAwaitToolCompletedResultSchema = z
status: z.literal("completed"),
taskId: z.string(),
reportMarkdown: z.string(),
handleKind: TaskHandleKindSchema.optional(),
workspaceId: z.string().optional(),
messageId: z.string().optional(),
finalMessageRef: WorkspaceTurnFinalMessageRefSchema.optional(),
structuredOutput: z.unknown().optional(),
title: z.string().optional(),
output: z.string().optional(),
Expand All @@ -695,6 +766,8 @@ export const TaskAwaitToolActiveResultSchema = z
"interrupted",
]),
taskId: z.string(),
handleKind: TaskHandleKindSchema.optional(),
workspaceId: z.string().optional(),
output: z.string().optional(),
elapsed_ms: z.number().optional(),
note: z.string().optional(),
Expand Down Expand Up @@ -962,6 +1035,8 @@ export const TaskListToolTaskSchema = z
workspaceName: z.string().optional(),
title: z.string().optional(),
createdAt: z.string().optional(),
handleKind: TaskHandleKindSchema.optional(),
workspaceId: z.string().optional(),
modelString: z.string().optional(),
thinkingLevel: TaskListThinkingLevelSchema.optional(),
depth: z.number().int().min(0),
Expand Down
20 changes: 20 additions & 0 deletions src/node/services/agentSession.editMessageId.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,26 @@ describe("AgentSession.sendMessage (editMessageId)", () => {
expect(streamMessage.mock.calls).toHaveLength(1);
});

it("passes muxMetadata through to the stream request", async () => {
const { session, streamMessage } = await createSessionHarness("ws-mux-metadata");
const muxMetadata = {
type: "workspace-turn-task" as const,
taskHandleId: "wst_handle",
ownerWorkspaceId: "owner-workspace",
turnId: "turn-id",
};

const result = await session.sendMessage("hello", {
model: TEST_MODEL,
agentId: "exec",
muxMetadata,
});

expect(result.success).toBe(true);
expect(streamMessage).toHaveBeenCalledTimes(1);
expect(streamMessage.mock.calls[0]?.[0]).toMatchObject({ muxMetadata });
});

it("does not truncate history when edit validation fails", async () => {
const workspaceId = "ws-edit-validation";
const { session, historyService, streamMessage } = await createSessionHarness(workspaceId);
Expand Down
77 changes: 77 additions & 0 deletions src/node/services/agentSession.startupAutoRetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,83 @@ describe("AgentSession startup auto-retry recovery", () => {
session.dispose();
});

test("startup auto-retry reuses workspace-turn metadata from the retry user message", async () => {
const workspaceId = "startup-retry-workspace-turn-metadata";
const { session, historyService, aiService, cleanup } = await createSessionBundle(workspaceId);
cleanups.push(cleanup);
const muxMetadata = {
type: "workspace-turn-task" as const,
taskHandleId: "wst_handle",
ownerWorkspaceId: "owner-workspace",
turnId: "turn-id",
};
const appendResult = await historyService.appendToHistory(
workspaceId,
createMuxMessage("user-1", "user", "Complete the workspace turn", {
timestamp: Date.now(),
retrySendOptions: { model: "openai:gpt-4o", agentId: "exec" },
muxMetadata,
})
);
expect(appendResult.success).toBe(true);
const streamMessageMock = mock((_payload: Parameters<AIService["streamMessage"]>[0]) =>
Promise.resolve(Ok(undefined))
);
aiService.streamMessage = streamMessageMock as unknown as AIService["streamMessage"];
const privateSession = session as unknown as {
retryActiveStream: () => Promise<void>;
startupAutoRetryCheckPromise: Promise<void> | null;
lastAutoRetryResumeRequest?: AutoRetryResumeRequest;
};

session.ensureStartupAutoRetryCheck();
await privateSession.startupAutoRetryCheckPromise;
expect(privateSession.lastAutoRetryResumeRequest?.options.muxMetadata).toBeUndefined();

await privateSession.retryActiveStream();

expect(streamMessageMock).toHaveBeenCalledTimes(1);
expect(streamMessageMock.mock.calls[0]?.[0]).toMatchObject({ muxMetadata });

session.dispose();
});

test("startup auto-retry does not stamp workflow-result metadata on assistant streams", async () => {
const workspaceId = "startup-retry-workflow-result-metadata";
const { session, historyService, aiService, cleanup } = await createSessionBundle(workspaceId);
cleanups.push(cleanup);
const appendResult = await historyService.appendToHistory(
workspaceId,
createMuxMessage("user-1", "user", "Use the workflow result", {
timestamp: Date.now(),
retrySendOptions: { model: "openai:gpt-4o", agentId: "exec" },
muxMetadata: {
type: "workflow-result",
rawCommand: "/deep-research mux",
runId: "wfr_1",
},
})
);
expect(appendResult.success).toBe(true);
const streamMessageMock = mock((_payload: Parameters<AIService["streamMessage"]>[0]) =>
Promise.resolve(Ok(undefined))
);
aiService.streamMessage = streamMessageMock as unknown as AIService["streamMessage"];
const privateSession = session as unknown as {
retryActiveStream: () => Promise<void>;
startupAutoRetryCheckPromise: Promise<void> | null;
};

session.ensureStartupAutoRetryCheck();
await privateSession.startupAutoRetryCheckPromise;
await privateSession.retryActiveStream();

expect(streamMessageMock).toHaveBeenCalledTimes(1);
expect(streamMessageMock.mock.calls[0]?.[0].muxMetadata).toBeUndefined();

session.dispose();
});

test("re-runs startup auto-retry check after busy startup state clears", async () => {
const workspaceId = "startup-retry-busy-rerun";
const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId);
Expand Down
Loading
Loading