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
22 changes: 22 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@ export interface ThreadDetailScreenProps {
readonly onNativePasteText: (paste: ComposerTextPaste) => Promise<void>;
readonly onRemoveDraftImage: (imageId: string) => void;
readonly onStopThread: () => void;
/** Waits for, or stops waiting for, the background commands the thread runs. */
readonly onSetBackgroundWorkHeld: (held: boolean) => void;
readonly onSendMessage: (followUp?: ActiveTurnComposerAction) => Promise<MessageId | null>;
readonly onReconnectEnvironment: () => void;
/** Whether the model picker may offer providers other than this thread's. */
Expand Down Expand Up @@ -439,6 +441,25 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const pendingBackgroundWork = presentPendingBackgroundWork(
props.selectedThread.pendingBackgroundTasks,
);
// A command is not held by default, because a dev server can run for hours
// after the agent is done. Holding one keeps the thread in Working.
const backgroundCommandHold =
props.serverConfig?.environment.capabilities.threadBackgroundWorkHold === true
? (pendingBackgroundWork?.commandHold ?? null)
: null;
const confirmBackgroundCommandHold = () => {
if (backgroundCommandHold === "wait") {
Alert.alert("Wait for this command?", "The thread stays in Working until it finishes.", [
{ text: "Cancel", style: "cancel" },
{ text: "Wait", onPress: () => props.onSetBackgroundWorkHeld(true) },
]);
} else if (backgroundCommandHold === "release") {
Alert.alert("Stop waiting?", "The command keeps running, and the thread shows as done.", [
{ text: "Cancel", style: "cancel" },
{ text: "Don't wait", onPress: () => props.onSetBackgroundWorkHeld(false) },
]);
}
};
const floatingStatus = ((): FloatingWorkingStatus | null => {
const connectionStatus = connectionFloatingStatus({
connectionError: props.connectionError,
Expand Down Expand Up @@ -480,6 +501,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
.map((item) => item.label)
.join(", ")}`,
waiting: pendingBackgroundWork.waiting,
onPress: backgroundCommandHold === null ? null : confirmBackgroundCommandHold,
};
}
return null;
Expand Down
15 changes: 15 additions & 0 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,10 @@ function ThreadRouteContent(
const gitActions = useSelectedThreadGitActions();
const requests = useSelectedThreadRequests();
const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
const setBackgroundWorkHeld = useAtomCommand(
threadEnvironment.setBackgroundWorkHeld,
"wait for background commands",
);
const loadEarlierHistory = useAtomCommand(threadEnvironment.loadEarlierHistory, {
label: "load earlier thread history",
reportFailure: false,
Expand Down Expand Up @@ -685,6 +689,16 @@ function ThreadRouteContent(
},
});
}, [composer.interruptibleRunId, interruptThreadTurn, selectedThread]);
const handleSetBackgroundWorkHeld = useCallback(
(held: boolean) => {
if (!selectedThread) return;
void setBackgroundWorkHeld({
environmentId: selectedThread.environmentId,
input: { threadId: selectedThread.id, held },
});
},
[selectedThread, setBackgroundWorkHeld],
);

const handleOpenTerminal = useCallback(
(nextTerminalId?: string | null) => {
Expand Down Expand Up @@ -1066,6 +1080,7 @@ function ThreadRouteContent(
onRemoveDraftImage={composer.onRemoveDraftImage}
serverConfig={serverConfig}
onStopThread={awaitingBootstrapTurn ? handleCancelWorktreeSetup : handleStopThread}
onSetBackgroundWorkHeld={handleSetBackgroundWorkHeld}
onSendMessage={composer.onSendMessage}
onReconnectEnvironment={handleReconnectEnvironment}
canSwitchThreadProvider={composer.canSwitchThreadProvider}
Expand Down
9 changes: 7 additions & 2 deletions apps/mobile/src/features/threads/floating-working-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,14 +152,16 @@ export function FloatingWorkingControl(props: {
}

// The queue, agents, and reconnect labels have separate tap targets.
const statusInteractive = props.status?.kind === "connection";
const statusInteractive =
props.status?.kind === "connection" ||
(props.status?.kind === "background" && props.status.onPress !== null);
const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasDevicePreview;
// The host stays centered on the capsule, but its measurement constraint
// comes from the overlay, independent of the capsule's current width.
const statusContent =
props.status !== null ? (
<View
pointerEvents={props.status.kind === "connection" ? "box-none" : "none"}
pointerEvents={statusInteractive ? "box-none" : "none"}
className="h-11 items-center justify-center"
>
<Animated.View className="h-11" style={capsuleSizerStyle} />
Expand Down Expand Up @@ -390,6 +392,9 @@ function FloatingStatusLabel(props: {
<StatusLabelRow
key="background"
accessibilityLabel={props.status.accessibilityLabel}
{...(props.status.onPress === null
? {}
: { accessibilityRole: "button" as const, onPress: props.status.onPress })}
className="gap-2"
onLayout={props.onLayout}
>
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/features/threads/floating-working-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ export type FloatingWorkingStatus =
| { readonly kind: "syncing"; readonly label: string }
| { readonly kind: "compacting" }
// The turn settled while background work it started still runs. `waiting`
// is false when only commands remain, such as a dev server: the agent is done.
// is false when only unheld commands remain, such as a dev server: the agent
// is done.
// `onPress` offers to wait, or stop waiting, for those commands.
| {
readonly kind: "background";
readonly label: string;
readonly accessibilityLabel: string;
readonly waiting: boolean;
readonly onPress: (() => void) | null;
}
// A task whose thread the server has not created yet: the worktree may
// still be checking out, so there is no turn to time.
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ export const make = Effect.gen(function* () {
threadPinReorder: true,
threadActiveReorder: true,
threadAutoSettleOptOut: true,
threadBackgroundWorkHold: true,
threadTitleRegeneration: true,
threadVisitedTracking: true,
threadPullRequests: true,
Expand Down
25 changes: 25 additions & 0 deletions apps/server/src/mcp/toolkits/thread/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,31 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({
queuedRunId: input.queuedRunId,
targetRunId: input.targetRunId,
})),
wait_for_background_commands: (input) =>
Effect.gen(function* () {
const { threads, projection } = yield* readWritableThread();
const result = yield* threads
.dispatch({
type: "thread.background-work.hold",
commandId: yield* newCommandId(),
threadId: projection.thread.id,
held: input.wait ?? true,
})
.pipe(
// The orchestrator rejects a hold before commit only when no
// background command runs. Anything else stays retryable.
Effect.mapError((error) =>
error._tag === "OrchestratorCommandRejectedError"
? new OrchestratorMcpFailure({
code: "invalid_request",
message:
"Could not wait: this thread has no background command running. Start the command in the background first.",
})
: unavailable(),
),
);
return { sequence: result.sequence };
}),
t3_thread_organize: (input) =>
Effect.gen(function* () {
const { threads, projection } = yield* readWritableThread(input.threadId);
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/mcp/toolkits/thread/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,16 @@ const QueuePromoteTool = Tool.make("t3_queue_promote_to_steer", {
parameters: Schema.Struct({ ...queueTarget, targetRunId: RunId }),
}).annotate(Tool.Destructive, true);

const WaitForBackgroundCommandsTool = Tool.make("wait_for_background_commands", {
...commandTool,
description:
"Keep this thread in the user's Working list until the background commands it runs now finish. Call it right after you start a background command that you will continue from when it exits, such as a build, test run, or benchmark. Without it, T3 Code treats a background command as one you leave running, such as a dev server, and shows the thread as done when your turn ends. Commands you start later are not included. Pass wait=false to stop waiting.",
parameters: Schema.Struct({ wait: Schema.optional(Schema.Boolean) }),
})
.annotate(Tool.Title, "Wait for background commands")
.annotate(Tool.Destructive, false)
.annotate(Tool.Idempotent, true);

const requestTarget = { threadId: Schema.optional(ThreadId), requestId: RuntimeRequestId };
const question = Schema.Struct({
id: Schema.String,
Expand Down Expand Up @@ -276,4 +286,5 @@ export const ThreadToolkit = Toolkit.make(
QueueCancelTool,
QueueReorderTool,
QueuePromoteTool,
WaitForBackgroundCommandsTool,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { assert, it } from "@effect/vitest";
import {
CommandId,
EventId,
MessageId,
NodeId,
ProjectId,
ProviderDriverKind,
ProviderInstanceId,
RunId,
ThreadId,
TurnItemId,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
import * as Orchestrator from "./Orchestrator.ts";
import * as ProjectionStore from "./ProjectionStore.ts";
import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts";
import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts";

const instanceId = ProviderInstanceId.make("codex");
const driver = ProviderDriverKind.make("codex");
const modelSelection = { instanceId, model: "gpt-5.1-codex" };
const adapter = {
instanceId,
driver,
getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2),
planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }),
openSession: () => Effect.die("No provider process needed for background work holds"),
} as ProviderAdapterV2Shape;
const database = SqlitePersistenceMemory;
const testLayer = Layer.mergeAll(
database,
ProjectionStore.layer.pipe(Layer.provide(database)),
makeOrchestratorV2ReplayLayerWithRegistry(
{ name: "background-work-hold" },
ProviderAdapterRegistry.makeLayer([adapter]),
{ databaseLayer: database, runEffectWorker: false },
),
);

it.layer(testLayer)("thread.background-work.hold", (it) => {
it.effect("waits for the commands that run now, then stops waiting", () =>
Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const projections = yield* ProjectionStore.ProjectionStoreV2;
const threadId = ThreadId.make("thread:background-work-hold");
const runId = RunId.make("run:background-work-hold");
const nodeId = NodeId.make("node:background-work-hold");
const now = yield* DateTime.now;
const hold = (commandId: string, held: boolean) =>
orchestrator.dispatch({
type: "thread.background-work.hold",
commandId: CommandId.make(commandId),
threadId,
held,
});
yield* orchestrator.dispatch({
type: "thread.create",
commandId: CommandId.make("create-background-work-hold"),
threadId,
projectId: ProjectId.make("project:background-work-hold"),
title: "Benchmarks",
modelSelection,
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
createdBy: "user",
creationSource: "web",
});

// Nothing runs yet, so there is nothing to wait for. The MCP tool relies
// on this tag to tell the rejection apart from an outage.
const nothing = yield* Effect.flip(hold("hold-nothing", true));
assert.equal(nothing._tag, "OrchestratorCommandRejectedError");

// The turn ended and left its benchmark running in the background.
yield* projections.apply({
id: EventId.make("event:background-work-hold:run"),
type: "run.created",
threadId,
runId,
occurredAt: now,
payload: {
id: runId,
threadId,
ordinal: 1,
providerInstanceId: instanceId,
modelSelection,
providerThreadId: null,
userMessageId: MessageId.make("message:background-work-hold"),
rootNodeId: nodeId,
activeAttemptId: null,
status: "completed",
requestedAt: now,
startedAt: now,
completedAt: now,
checkpointId: null,
contextHandoffId: null,
},
});
yield* projections.apply({
id: EventId.make("event:background-work-hold:command"),
type: "turn-item.updated",
threadId,
runId,
nodeId,
driver,
occurredAt: now,
payload: {
id: TurnItemId.make("item:bench"),
threadId,
runId,
nodeId,
providerThreadId: null,
providerTurnId: null,
nativeItemRef: null,
parentItemId: null,
ordinal: 1,
status: "running",
title: "Run benchmarks",
startedAt: now,
completedAt: null,
updatedAt: now,
type: "command_execution",
input: "cargo bench",
output: "",
},
});
const running = {
taskId: "item:bench",
description: "Run benchmarks",
kind: "command" as const,
};
assert.deepEqual((yield* projections.getThreadShell(threadId))?.pendingBackgroundTasks, [
running,
]);

yield* hold("hold-bench", true);
assert.deepEqual(
(yield* projections.getThreadProjection(threadId)).thread.heldBackgroundTaskIds,
["item:bench"],
);
assert.deepEqual((yield* projections.getThreadShell(threadId))?.pendingBackgroundTasks, [
{ ...running, held: true },
]);

yield* hold("release-bench", false);
assert.isUndefined(
(yield* projections.getThreadProjection(threadId)).thread.heldBackgroundTaskIds,
);
assert.deepEqual((yield* projections.getThreadShell(threadId))?.pendingBackgroundTasks, [
running,
]);
}),
);
});
Loading
Loading