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
57 changes: 57 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10049,6 +10049,32 @@ describe("ClaudeAdapterV2 background wake turns", () => {
attachments: [],
}),
);
yield* Queue.offer(
harness.sdkMessages,
claudeSdkFrame({
type: "assistant",
message: {
model: "claude-sonnet-4-6",
id: "msg_background_command",
type: "message",
role: "assistant",
content: [
{
type: "tool_use",
id: WAKE_TOOL_USE_ID,
name: "Bash",
input: {
command: "python3 audit.py > audit.log 2>&1",
run_in_background: true,
},
},
],
},
parent_tool_use_id: null,
uuid: "background-command-launch",
session_id: WAKE_NATIVE_SESSION,
}),
);
yield* Queue.offer(harness.sdkMessages, wakeTaskStarted);
yield* Queue.offer(harness.sdkMessages, turnOneResult);
yield* harness.awaitTerminal(1);
Expand All @@ -10062,6 +10088,37 @@ describe("ClaudeAdapterV2 background wake turns", () => {
WAKE_TASK_ID,
);

const command = harness.events.find(
(event) =>
event.type === "turn_item.updated" &&
event.turnItem.type === "command_execution" &&
event.turnItem.nativeItemRef?.nativeId === WAKE_TOOL_USE_ID,
);
assert.equal(command?.type, "turn_item.updated");
if (command?.type !== "turn_item.updated") return;
const details = {
command: "python3 audit.py > audit.log 2>&1",
itemId: command.turnItem.id,
};
assert.deepInclude(
afterStart.at(-1)?.providerThread.pendingBackgroundTasks?.[0],
details,
);
yield* harness.offerAndWait(
claudeSdkFrame({
type: "system",
subtype: "background_tasks_changed",
tasks: [{ task_id: WAKE_TASK_ID, task_type: "local_bash", description: "Audit" }],
uuid: "background-command-snapshot",
session_id: WAKE_NATIVE_SESSION,
}),
);
assert.deepInclude(
providerThreadRosterEvents(harness.events).at(-1)?.providerThread
.pendingBackgroundTasks?.[0],
details,
);

yield* Queue.offer(
harness.sdkMessages,
claudeSdkFrame({
Expand Down
32 changes: 27 additions & 5 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5777,7 +5777,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
} else {
updated.set(
nativeThreadId,
new Map(tasks.map((task) => [task.taskId, task] as const)),
new Map(
tasks.map((task) => {
const previous = current.get(nativeThreadId)?.get(task.taskId);
return [task.taskId, { ...previous, ...task }] as const;
}),
),
);
}
return updated;
Expand Down Expand Up @@ -8719,9 +8724,26 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
if (!isClaudeNonSubagentTask(message) || message.is_backgrounded === false) {
return false;
}
yield* upsertPendingBackgroundTask(
input.nativeThreadId,
claudePendingBackgroundTask({
const launch =
message.tool_use_id === undefined || input.activeContext === null
? undefined
: findToolCall(input.activeContext, message.tool_use_id);
const command =
launch?.classification.itemType === "command_execution"
? firstStringInputField(launch.input, ["command"])
: undefined;
yield* upsertPendingBackgroundTask(input.nativeThreadId, {
...(launch === undefined || command === undefined
? {}
: {
itemId: idAllocator.derive.turnItemFromProviderItem({
driver: CLAUDE_PROVIDER,
nativeItemId: launch.nativeItemId,
}),
sourceThreadId: launch.threadId,
}),
...(command === undefined ? {} : { command }),
...claudePendingBackgroundTask({
taskId: message.task_id,
taskType: claudeTaskTypeFromSdkMessage(message),
startedByMonitor: yield* isClaudeMonitorTask({
Expand All @@ -8733,7 +8755,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
typeof message.description === "string" ? message.description : undefined,
parentTaskId: Reflect.get(message, "parent_task_id"),
}),
);
});
rosterChanged = true;
} else if (message.type === "system" && message.subtype === "task_notification") {
yield* endClaudeMonitorTasks((taskId) => taskId === message.task_id);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import "~/index.css";
import { EnvironmentId, ProviderDriverKind, ThreadId, TurnItemId } from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import { expect, it, vi } from "vite-plus/test";
import { page } from "vite-plus/test/browser";
vi.mock(import("./CommandOutputPanel"), () => ({
useCommandOutputStreaming: () => true,
CommandOutputPanel: () => <div>Command output stream</div>,
}));

import { render } from "../../../test/render";
import { backgroundWorkNowItem } from "./ThreadActivityBanners";
import { ThreadNow } from "./ThreadNow";

it("opens full command details for short labels and keeps pending tasks current", async () => {
const command = "python3 audit.py -j 6\n> scratch/audit.log 2>&1";
const commandItem = (status: "running" | "completed") => ({
id: TurnItemId.make("bash-1"),
threadId: ThreadId.make("thread"),
runId: null,
nodeId: null,
providerThreadId: null,
providerTurnId: null,
nativeItemRef: null,
parentItemId: null,
ordinal: 1,
status,
title: null,
startedAt: null,
completedAt: null,
updatedAt: DateTime.makeUnsafe("2026-11-09T00:00:00Z"),
type: "command_execution" as const,
input: command,
output: "Command running in background with ID: native-task",
});
const view = (
description?: string,
status: "running" | "completed" = "completed",
explicitItem = true,
) => (
<ThreadNow
attention={null}
syncPhase={null}
goal={null}
work={backgroundWorkNowItem({
environmentId: EnvironmentId.make("test"),
threadId: ThreadId.make("thread"),
tasks: [
{
taskId: "native-task",
itemId: explicitItem ? TurnItemId.make("bash-1") : undefined,
kind: "command",
description,
command,
awaited: true,
},
],
turnItems: [
commandItem(status),
{
...commandItem("running"),
id: TurnItemId.make("native-match"),
nativeItemRef: {
driver: ProviderDriverKind.make("codex"),
nativeId: "native-task",
strength: "strong",
},
ordinal: 2,
},
],
stopDisabled: false,
stopping: false,
onStop: async () => undefined,
onOpenThread: () => undefined,
})}
/>
);
await using screen = await render(view("Audit"));
await screen.getByRole("button", { name: "Details", exact: true }).click();
await vi.waitFor(() => expect(page.getByRole("dialog").element().textContent).toContain(command));
await expect.element(page.getByText("Command output stream")).not.toBeInTheDocument();

await screen.rerender(view(command));
await expect.element(page.getByRole("heading", { name: "Command", exact: true })).toBeVisible();
await screen.rerender(view());
await expect.element(page.getByRole("heading", { name: "Command", exact: true })).toBeVisible();

await screen.rerender(view("Check remaining modules", "running"));
await expect
.element(page.getByRole("heading", { name: "Check remaining modules" }))
.toBeVisible();
await expect.element(page.getByText("Command output stream")).toBeVisible();
await screen.rerender(view("Native fallback", "completed", false));
await expect.element(page.getByText("Command output stream")).toBeVisible();
await page.getByRole("button", { name: "Close", exact: true }).click();
await expect.element(page.getByRole("dialog")).not.toBeInTheDocument();
});
97 changes: 97 additions & 0 deletions apps/web/src/components/chat/BackgroundWorkDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type {
EnvironmentId,
OrchestrationV2PendingBackgroundTask,
ThreadId,
} from "@t3tools/contracts";
import type { ThreadStructure } from "@t3tools/client-runtime/state/shell";
import { useState } from "react";

import { InlineButton } from "../ui/button";
import {
Dialog,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
DialogTrigger,
} from "../ui/dialog";
import { CommandOutputPanel, useCommandOutputStreaming } from "./CommandOutputPanel";
import { ShellCommandBlock } from "./ShellCommandBlock";

export function BackgroundWorkDetails(props: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
readonly tasks: ReadonlyArray<OrchestrationV2PendingBackgroundTask>;
readonly turnItems: ThreadStructure["turnItems"];
readonly onOpenThread: (threadId: ThreadId) => void;
}) {
const [open, setOpen] = useState(false);
const streamsOutput = useCommandOutputStreaming(props.environmentId);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<InlineButton />}>Details</DialogTrigger>
<DialogPopup>
<DialogHeader>
<DialogTitle>Background work</DialogTitle>
</DialogHeader>
<DialogPanel>
<div className="space-y-5">
{props.tasks.map((task) => {
const item = props.turnItems.findLast((candidate) =>
task.itemId === undefined
? (candidate.nativeItemRef?.nativeId || candidate.id) === task.taskId
: candidate.id === task.itemId,
);
const command =
task.command ?? (item?.type === "command_execution" ? item.input : undefined);
const description = task.description?.trim();
const title =
description && description !== command?.trim()
? description
: command
? "Command"
: task.kind.replaceAll("_", " ");
const itemId =
task.itemId ?? (item?.type === "command_execution" ? item.id : undefined);
const childThreadId = task.kind === "subagent" ? task.childThreadId : undefined;
return (
<section key={task.taskId} className="space-y-2 text-sm">
<div className="flex items-baseline justify-between gap-3">
<h3 className="font-medium">{title}</h3>
<span className="shrink-0 text-xs text-muted-foreground">
{task.awaited ? "Waiting" : "Running"}
</span>
</div>
{command ? (
<div className="rounded-md border border-border bg-muted/20 p-3 font-mono text-xs whitespace-pre-wrap break-words">
<ShellCommandBlock command={command} />
</div>
) : null}
{itemId && streamsOutput && item?.status === "running" ? (
<div className="font-mono text-xs whitespace-pre-wrap break-words">
<CommandOutputPanel
environmentId={props.environmentId}
threadId={task.sourceThreadId ?? item?.threadId ?? props.threadId}
itemId={itemId}
/>
</div>
) : null}
{childThreadId ? (
<InlineButton
onClick={() => {
setOpen(false);
props.onOpenThread(childThreadId);
}}
>
Open thread
</InlineButton>
) : null}
</section>
);
})}
</div>
</DialogPanel>
</DialogPopup>
</Dialog>
);
}
9 changes: 9 additions & 0 deletions apps/web/src/components/chat/ThreadActivityBanners.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from "lucide-react";
import { Fragment } from "react";
import { tickTextContent } from "~/lib/tickTextContent";
import { BackgroundWorkDetails } from "./BackgroundWorkDetails";
import { Button, InlineButton } from "../ui/button";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import type { ComposerBannerStackItem } from "./ComposerBannerStack";
Expand Down Expand Up @@ -168,6 +169,7 @@ export function runStopNowItem(input: {

/** Background commands and subagents, for the end of the timeline. */
export function backgroundWorkNowItem(input: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
readonly tasks: ReadonlyArray<OrchestrationV2PendingBackgroundTask>;
readonly turnItems: ThreadStructure["turnItems"];
Expand Down Expand Up @@ -246,6 +248,13 @@ export function backgroundWorkNowItem(input: {
className="tabular-nums"
/>
)}
<BackgroundWorkDetails
environmentId={input.environmentId}
threadId={input.threadId}
tasks={input.tasks}
turnItems={input.turnItems}
onOpenThread={onOpenThread}
/>
{onlyThreadId === undefined ? null : (
<InlineButton tone="muted" onClick={() => onOpenThread(onlyThreadId)}>
Open
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/useThreadLifecycle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) {
: null;
const backgroundWorkNow = activeThread
? backgroundWorkNowItem({
environmentId,
threadId: activeThread.id,
tasks: activeBackgroundTasks,
turnItems,
Expand Down
3 changes: 3 additions & 0 deletions packages/contracts/src/orchestrationV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,9 @@ function kindUnionWithFallback<
}

const PendingBackgroundTaskFields = {
command: Schema.optional(Schema.String),
itemId: Schema.optional(TurnItemId),
sourceThreadId: Schema.optional(ThreadId),
taskId: TrimmedNonEmptyString,
description: Schema.optional(TrimmedNonEmptyString),
/** The subagent task that started this work and waits on it. */
Expand Down
Loading