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
132 changes: 132 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15560,6 +15560,138 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);

it.effect(
"defers goal completion until an ambient subagent finishes and Claude checks again",
() =>
Effect.gen(function* () {
const harness = yield* makeWakeHarness;
const condition = "the review finds no bugs";
const taskId = "task-goal-review";
const toolUseId = "toolu-goal-review";
const now = yield* DateTime.now;
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now,
attemptId: RunAttemptId.make("goal-background-attempt"),
text: `/goal ${condition}`,
attachments: [],
}),
);
for (const frame of [
syntheticFrame("goal-background-set", `Goal set: ${condition}`),
claudeSdkFrame({
...makeSubagentTaskStartedFrame({ taskId, toolUseId, uuid: "goal-review-start" }),
ambient: true,
skip_transcript: true,
}),
makeAssistantTextFrame({
uuid: "goal-background-wait",
text: "Waiting for the review.",
}),
makeResultFrame({ uuid: "goal-background-result", result: "Waiting for the review." }),
]) {
yield* Queue.offer(harness.sdkMessages, frame);
}
assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed");
assert.deepEqual(goalStatuses(harness.events).at(-1), {
objective: condition,
status: "active",
checks: 0,
});

yield* harness.offerAndWait(
makeSubagentNotificationFrame({
taskId,
toolUseId,
summary: "No bugs found.",
uuid: "goal-review-done",
}),
);
yield* harness.offerAndWait(wakeTurnInit);
yield* Queue.take(harness.continuationReceipts);
assert.equal(goalStatuses(harness.events).at(-1)?.status, "active");
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now,
attemptId: RunAttemptId.make("goal-background-wake"),
text: "Background task completed.",
attachments: [],
providerTurnOrdinal: 2,
messageCreatedBy: "agent",
messageCreationSource: "provider",
}),
);
yield* harness.offerAndWait(
makeAssistantTextFrame({ uuid: "goal-background-wake-work", text: "No bugs found." }),
);
yield* harness.offerAndWait(
makeResultFrame({
uuid: "goal-background-wake-result",
result: "No bugs found.",
origin: { kind: "task-notification" },
}),
);
assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed");
assert.deepEqual(goalStatuses(harness.events).at(-1), {
objective: condition,
status: "complete",
checks: 0,
});
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
);

it.effect.each([
{ taskType: "mcp_task", status: "complete" },
{ taskType: undefined, status: "active" },
] as const)("leaves the goal $status with a running $taskType task", ({ taskType, status }) =>
Effect.gen(function* () {
const harness = yield* makeWakeHarness;
const condition = "the docs build";
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make("goal-task-type-attempt"),
text: `/goal ${condition}`,
attachments: [],
}),
);
for (const frame of [
syntheticFrame("goal-task-type-set", `Goal set: ${condition}`),
claudeSdkFrame({
type: "system",
subtype: "task_started",
task_id: "task-goal-docs",
description: "Watch the docs",
...(taskType === undefined ? {} : { task_type: taskType }),
uuid: "goal-task-type-start",
session_id: WAKE_NATIVE_SESSION,
}),
makeAssistantTextFrame({ uuid: "goal-task-type-work", text: "The docs build." }),
makeResultFrame({ uuid: "goal-task-type-result", result: "The docs build." }),
]) {
yield* Queue.offer(harness.sdkMessages, frame);
}
assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed");
assert.equal(goalStatuses(harness.events).at(-1)?.status, status);
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
);

it.effect("keeps a goal active after a command turn with no model output", () =>
Effect.gen(function* () {
const harness = yield* makeWakeHarness;
Expand Down
25 changes: 24 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2076,6 +2076,15 @@ const CLAUDE_OPAQUE_BACKGROUND_TASK_KINDS: ReadonlyMap<
Exclude<OrchestrationV2PendingBackgroundTask["kind"], "subagent">
> = new Map([["local_bash", "command"]]);

// Claude Code 2.1.292 defers its goal Stop hook for these subagent types.
// local_bash tasks defer it through the opaque-task roster.
const CLAUDE_GOAL_DEFERRING_TASK_TYPES: ReadonlySet<string> = new Set([
"local_agent",
"remote_agent",
"in_process_teammate",
"local_workflow",
]);

function isClaudeOpaqueBackgroundTaskType(taskType: string | null | undefined): boolean {
return typeof taskType === "string" && CLAUDE_OPAQUE_BACKGROUND_TASK_KINDS.has(taskType);
}
Expand Down Expand Up @@ -3075,6 +3084,7 @@ interface ActiveClaudeProviderRetry {

interface ActiveClaudeSubagent {
task: OrchestrationV2Subagent;
readonly nativeTaskType: string | undefined;
readonly confirmedOutcome: ProviderAdapter.ProviderAdapterV2SubagentOutcome | undefined;
readonly rootNodeId: OrchestrationV2ExecutionNode["id"];
readonly childThreadId: ThreadId;
Expand Down Expand Up @@ -4498,6 +4508,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
const ids = claudeSubagentIds(resume.context, resume.taskId);
const now = yield* DateTime.now;
const subagent: ActiveClaudeSubagent = {
nativeTaskType: undefined,
confirmedOutcome: undefined,
childThread: null,
task: {
Expand Down Expand Up @@ -4979,6 +4990,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
readonly model?: string;
readonly effort?: string;
readonly owner?: ActiveClaudeSubagent;
readonly nativeTaskType?: string;
readonly progress?: string;
readonly workflowFrame?: unknown;
readonly workflowName?: string;
Expand Down Expand Up @@ -5137,6 +5149,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
} satisfies OrchestrationV2Subagent;
const subagent = {
task,
nativeTaskType: input.nativeTaskType ?? existingSubagent?.nativeTaskType,
confirmedOutcome:
input.nativeOutcome !== undefined
? confirmedSubagentOutcome(task)
Expand Down Expand Up @@ -6252,13 +6265,18 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
nativeThreadId === null ? undefined : goalsByNativeThread.get(nativeThreadId);
const goalChecked = goalCheckedTurns.delete(input.context.providerTurnId);
const terminalReason = input.result?.terminal_reason;
const liveQuery = yield* Ref.get(queryContext);
if (
nativeThreadId !== null &&
goal?.status === "active" &&
goalChecked &&
input.status === "completed" &&
(terminalReason === undefined || terminalReason === "completed") &&
roster.size === 0
roster.size === 0 &&
!(
liveQuery?.nativeThreadId === nativeThreadId &&
(yield* liveProcessRunsBackgroundWork(liveQuery, { forGoalCheck: true }))
)
) {
goalsByNativeThread.set(nativeThreadId, {
objective: goal.objective,
Expand Down Expand Up @@ -7457,6 +7475,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
yield* updateClaudeSubagentNode({
context,
taskId: message.task_id,
...(message.task_type === undefined ? {} : { nativeTaskType: message.task_type }),
...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }),
...(message.prompt === undefined ? {} : { prompt: message.prompt }),
...(model === undefined ? {} : { model }),
Expand Down Expand Up @@ -8396,6 +8415,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (

const liveProcessRunsBackgroundWork = Effect.fnUntraced(function* (
live: ClaudeLiveQueryContext,
options?: { readonly forGoalCheck?: boolean },
) {
if (
rosterForNativeThread(
Expand All @@ -8410,6 +8430,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
if (
subagent.task.status === "running" &&
!live.subagentsFromEarlierProcesses.has(subagent) &&
(options?.forGoalCheck !== true ||
subagent.nativeTaskType === undefined ||
CLAUDE_GOAL_DEFERRING_TASK_TYPES.has(subagent.nativeTaskType)) &&
!buffered.some(
(message) =>
message.type === "system" &&
Expand Down
Loading