Skip to content
Closed
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
103 changes: 103 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import * as TestClock from "effect/testing/TestClock";
import { Tool } from "effect/ai";
import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeCompaction";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
import { SpawnExecutableResolution } from "@t3tools/shared/shell";

Expand Down Expand Up @@ -10193,6 +10194,108 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);

it.effect("keeps a finished helper's own background command pending until it ends", () =>
Effect.scoped(
Effect.gen(function* () {
const harness = yield* makeWakeHarness;
const now = yield* DateTime.now;
const latestRoster = () =>
providerThreadRosterEvents(harness.events).at(-1)?.providerThread
.pendingBackgroundTasks ?? [];
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now,
attemptId: RunAttemptId.make("attempt-claude-helper-shell"),
text: "Start the dev server from a helper.",
attachments: [],
}),
);
yield* Queue.offer(
harness.sdkMessages,
makeSubagentTaskStartedFrame({
taskId: "helper",
toolUseId: "toolu_helper",
uuid: "00000000-0000-4000-8000-000000000211",
}),
);
yield* Queue.offer(
harness.sdkMessages,
makeResultFrame({
uuid: "00000000-0000-4000-8000-000000000212",
result: "A helper is starting the dev server.",
}),
);
yield* harness.awaitTerminal(1);

yield* Queue.offer(
harness.sdkMessages,
claudeSdkFrame({
type: "system",
subtype: "task_started",
task_id: "helper-shell",
tool_use_id: "toolu_helper_shell",
description: "npm run dev",
is_backgrounded: true,
task_type: "local_bash",
parent_task_id: "helper",
uuid: "00000000-0000-4000-8000-000000000213",
session_id: WAKE_NATIVE_SESSION,
}),
);
yield* harness.until(() => latestRoster()[0]?.parentTaskId === "helper");
// The helper ends its turn while its command runs; the command still holds the thread.
yield* harness.offerAndWait(
makeSubagentNotificationFrame({
taskId: "helper",
toolUseId: "toolu_helper",
summary: "The dev server is running.",
uuid: "00000000-0000-4000-8000-000000000214",
}),
);
yield* harness.until(() =>
harness.events.some(
(event) => event.type === "subagent.updated" && event.subagent.status === "completed",
),
);
assert.deepStrictEqual(
latestRoster().map((task) => [task.taskId, task.parentTaskId]),
[["helper-shell", "helper"]],
);
assert.isTrue(backgroundWorkHoldsCompletion(latestRoster()));
assert.isTrue(yield* harness.hasPendingBackgroundWork);

yield* Queue.offer(
harness.sdkMessages,
claudeSdkFrame({
type: "system",
subtype: "task_notification",
task_id: "helper-shell",
tool_use_id: "toolu_helper_shell",
status: "completed",
output_file: "/tmp/helper-shell.output",
summary: "npm run dev exited.",
uuid: "00000000-0000-4000-8000-000000000215",
session_id: WAKE_NATIVE_SESSION,
}),
);
yield* harness.until(() => latestRoster().length === 0);
assert.isFalse(backgroundWorkHoldsCompletion(latestRoster()));
assert.isFalse(yield* harness.hasPendingBackgroundWork);
}).pipe(
Effect.provide(
Layer.mergeAll(
IdAllocator.layer,
McpProviderSessions.layer,
NodeServices.layer,
NodeCrypto.layer,
),
),
),
),
);

it.effect("holds only the background tasks the agent marks awaited, until they end", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
10 changes: 5 additions & 5 deletions docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,11 @@ open.

Threads that are working or waiting on work they will resume from move into a collapsed
**Working** section below the active list.
That includes subagents, watched pull requests, and background commands or monitors the agent
says it is waiting on. A dev server or other process an agent leaves running does not keep a thread
there. A thread returns to the top of the active list when it finishes, fails, or needs an approval or
answer. The Working section lists the thread you last sent work to first. Pinned threads stay in
the pinned section.
That includes subagents and any commands they leave running, watched pull requests, and background
commands or monitors the agent says it is waiting on. A dev server or other process the thread's own
agent leaves running does not keep a thread there. A thread returns to the top of the active list
when it finishes, fails, or needs an approval or answer. The Working section lists the thread you
last sent work to first. Pinned threads stay in the pinned section.

This is on by default. Turn it off with **Settings → General → Working section** on web and desktop,
or **Settings → General → Working section** on iOS and Android. Each device keeps its own
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ describe("backgroundWorkHoldsCompletion", () => {
["a dev server's watchdog monitor", false, [{ taskId: "watch", kind: "monitor" }]],
["a command the agent waits on", true, [{ taskId: "tests", kind: "command", awaited: true }]],
["a subagent", true, [{ taskId: "review", kind: "subagent" }]],
[
"a subagent's own command",
true,
[{ taskId: "tests", kind: "command", parentTaskId: "review" }],
],
] as const)("with %s pending, holds completion: %s", (_case, holds, tasks) => {
expect(backgroundWorkHoldsCompletion(tasks)).toBe(holds);
});
Expand Down
12 changes: 8 additions & 4 deletions packages/shared/src/orchestrationV2PendingBackgroundWork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,19 @@ export function deriveBlockedOnThreadWait(input: {
}

export function backgroundWorkHoldsCompletion(
tasks: ReadonlyArray<Pick<PendingBackgroundWorkTask, "kind" | "awaited">>,
tasks: ReadonlyArray<Pick<PendingBackgroundWorkTask, "kind" | "awaited" | "parentTaskId">>,
): boolean {
return tasks.some(
(task) => task.awaited === true || backgroundWorkKindHoldsCompletion(task.kind),
(task) =>
task.awaited === true ||
// A subagent that ends its turn on its own background work is still working.
task.parentTaskId !== undefined ||
backgroundWorkKindHoldsCompletion(task.kind),
);
}

// Commands and monitors are as often a dev server or its watchdog as a wait, so they hold
// only when the agent marks them awaited.
// A thread's own commands and monitors are as often a dev server or its watchdog as a wait,
// so they hold only when the agent marks them awaited.
function backgroundWorkKindHoldsCompletion(kind: PendingBackgroundWorkTask["kind"]): boolean {
switch (kind) {
case "command":
Expand Down
Loading