From 2605816ca37ec78b7d6138448a9fb98bf184f4e1 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:33:00 -0400 Subject: [PATCH] feat(agents): message a Codex subagent straight from its transcript A spawned Codex agent could only be read. Steering it meant asking the parent to relay, and the parent does not always listen. The agent drill-in in the Agents tab now ends in a one-line composer while the agent is live. Enter sends; the reply lands in the transcript above like any other turn. A new server.sendSubagentInput RPC routes to the provider adapter; the Codex adapter authorizes the thread against the session's root (the same ancestry walk the transcript read uses, now shared), refuses threads the app-server marks as not accepting direct input, and starts a turn on the child thread. Claude has no host-side way to reach a subagent, so the composer stays hidden there. --- .../Layers/CheckpointReactor.test.ts | 1 + .../Layers/ProviderCommandReactor.test.ts | 1 + .../Layers/ProviderRuntimeIngestion.test.ts | 1 + .../src/provider/Layers/CodexAdapter.test.ts | 52 ++++++++ .../src/provider/Layers/CodexAdapter.ts | 111 ++++++++++++------ .../provider/Layers/CodexSessionRuntime.ts | 12 ++ .../src/provider/Layers/ProviderService.ts | 30 +++++ .../Layers/ProviderSessionReaper.test.ts | 1 + .../src/provider/Services/ProviderAdapter.ts | 12 ++ .../src/provider/Services/ProviderService.ts | 8 ++ apps/server/src/ws.ts | 17 +++ .../components/chat/AgentsPanel.browser.tsx | 45 +++++++ apps/web/src/components/chat/AgentsPanel.tsx | 7 ++ .../src/components/chat/SubagentInspector.tsx | 94 +++++++++++++++ .../chat/subagentTranscriptClient.ts | 18 ++- apps/web/src/rpc/wsRpcClient.ts | 7 ++ packages/contracts/src/provider.ts | 23 ++++ packages/contracts/src/rpc.ts | 11 ++ 18 files changed, 414 insertions(+), 37 deletions(-) diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 99612b5c0..2e7903a78 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -152,6 +152,7 @@ function createProviderServiceHarness( }), rollbackConversation, readSubagentTranscript: () => unsupported(), + sendSubagentInput: () => unsupported(), resolveSubagentWorktree: () => Effect.succeed(null), deleteThread: () => unsupported(), get streamEvents() { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index eec12fe32..c7d8e6909 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -444,6 +444,7 @@ describe("ProviderCommandReactor", () => { }, rollbackConversation: () => unsupported(), readSubagentTranscript: () => unsupported(), + sendSubagentInput: () => unsupported(), resolveSubagentWorktree: () => Effect.succeed(null), deleteThread: () => unsupported(), get streamEvents() { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index fc7d3609b..dfe3ecc87 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -141,6 +141,7 @@ function createProviderServiceHarness() { }, rollbackConversation: () => unsupported(), readSubagentTranscript: () => unsupported(), + sendSubagentInput: () => unsupported(), resolveSubagentWorktree: ({ toolUseId }) => Effect.succeed(subagentWorktreesByToolUseId.get(toolUseId) ?? null), deleteThread: () => unsupported(), diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index ad6421a13..74e428d32 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -346,6 +346,11 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { ): Promise => Promise.resolve({ data: [] }), ); + public readonly startStoredThreadTurnImpl = vi.fn( + (_providerThreadId: string, _text: string): Promise => + Promise.resolve({ turn: { id: "turn-direct-1", items: [], status: "inProgress" } }), + ); + public readonly rollbackThreadImpl = vi.fn((_numTurns: number): Promise => Promise.resolve({ threadId: "provider-thread-1", @@ -431,6 +436,10 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { return Effect.promise(() => this.readStoredThreadItemsImpl(input)); } + startStoredThreadTurn(providerThreadId: string, text: string) { + return Effect.promise(() => this.startStoredThreadTurnImpl(providerThreadId, text)); + } + rollbackThread(numTurns: number) { return Effect.promise(() => this.rollbackThreadImpl(numTurns)); } @@ -875,6 +884,49 @@ transcriptLayer("CodexAdapterLive subagent transcripts", (it) => { assert.match(result.failure.detail, /not a subagent of this conversation/); }), ); + + it.effect("starts a turn on an authorized subagent thread for direct input", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const threadId = asThreadId("thread-direct-input"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId, + runtimeMode: "full-access", + }); + const runtime = transcriptRuntimeFactory.lastRuntime; + assert.ok(runtime); + + const sendSubagentInput = adapter.sendSubagentInput; + assert.ok(sendSubagentInput); + const result = yield* sendSubagentInput(threadId, { + threadId, + agentId: "child-provider-thread", + text: "Focus on the router first.", + }); + assert.deepStrictEqual(result, { turnId: "turn-direct-1" }); + assert.deepStrictEqual(runtime.startStoredThreadTurnImpl.mock.calls, [ + ["child-provider-thread", "Focus on the router first."], + ]); + + // A child the app-server says cannot take input is refused before any turn starts. + runtime.readStoredThreadMetadataImpl.mockImplementation((providerThreadId: string) => + Promise.resolve({ + ...makeStoredThread({ id: providerThreadId, parentThreadId: "provider-thread-1" }), + canAcceptDirectInput: false, + }), + ); + const refused = yield* sendSubagentInput(threadId, { + threadId, + agentId: "child-provider-thread", + text: "Anyone there?", + }).pipe(Effect.result); + assert.ok(refused._tag === "Failure"); + assert.ok(refused.failure instanceof ProviderAdapterRequestError); + assert.match(refused.failure.detail, /does not accept direct input/); + assert.equal(runtime.startStoredThreadTurnImpl.mock.calls.length, 1); + }), + ); }); const sessionRuntimeFactory = makeRuntimeFactory(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index e521e739e..c8a4f8447 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -3228,6 +3228,76 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( })), ); + /** Resolves a stored provider thread the session may act on: one whose + * ancestry reaches this session's root within the supported nesting depth. + * Returns the thread's metadata; anything else is a request error. */ + const authorizeSubagentThread = Effect.fn("authorizeSubagentThread")(function* ( + context: CodexAdapterSessionContext, + threadId: ThreadId, + agentId: string, + options: { + readonly requestError: (detail: string) => ProviderAdapterRequestError; + readonly parentThreadDetail: string; + }, + ) { + const { requestError } = options; + const rootThreadId = yield* context.runtime.readProviderThreadId.pipe( + Effect.mapError((cause) => mapCodexRuntimeError(threadId, "thread/read", cause)), + ); + if (agentId === rootThreadId) { + return yield* requestError(options.parentThreadDetail); + } + + const readStoredThreadMetadata = (providerThreadId: string) => + context.runtime + .readStoredThreadMetadata(providerThreadId) + .pipe(Effect.mapError((cause) => mapCodexRuntimeError(threadId, "thread/read", cause))); + const candidate = yield* readStoredThreadMetadata(agentId); + const visited = new Set([candidate.id]); + let current = candidate; + + for (let depth = 0; depth < CODEX_SUBAGENT_MAX_ANCESTRY_DEPTH; depth += 1) { + const parentThreadId = readCodexSubagentParentThreadId(current); + if (parentThreadId === rootThreadId) { + return candidate; + } + if (!parentThreadId || visited.has(parentThreadId)) { + return yield* requestError( + `Codex thread '${agentId}' is not a subagent of this conversation.`, + ); + } + visited.add(parentThreadId); + current = yield* readStoredThreadMetadata(parentThreadId); + } + + return yield* requestError( + `Codex thread '${agentId}' exceeded the supported subagent nesting depth.`, + ); + }); + + const sendSubagentInput: NonNullable = Effect.fn( + "sendSubagentInput", + )(function* (threadId, input) { + const requestError = (detail: string) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sendSubagentInput", + detail, + }); + const context = yield* requireSession(threadId); + const candidate = yield* authorizeSubagentThread(context, threadId, input.agentId, { + requestError, + parentThreadDetail: "Send to the parent thread through the composer instead.", + }); + if (candidate.canAcceptDirectInput === false) { + return yield* requestError("This agent does not accept direct input right now."); + } + const response = yield* context.runtime + .startStoredThreadTurn(candidate.id, input.text) + .pipe(Effect.mapError((cause) => mapCodexRuntimeError(threadId, "turn/start", cause))); + return { turnId: response.turn.id }; + }); + const readSubagentTranscript: NonNullable = Effect.fn("readSubagentTranscript")(function* (threadId, input) { const requestError = (detail: string) => @@ -3237,42 +3307,10 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( detail, }); const context = yield* requireSession(threadId); - const rootThreadId = yield* context.runtime.readProviderThreadId.pipe( - Effect.mapError((cause) => mapCodexRuntimeError(threadId, "thread/read", cause)), - ); - if (input.agentId === rootThreadId) { - return yield* requestError("The requested transcript belongs to the parent thread."); - } - - const readStoredThreadMetadata = (providerThreadId: string) => - context.runtime - .readStoredThreadMetadata(providerThreadId) - .pipe(Effect.mapError((cause) => mapCodexRuntimeError(threadId, "thread/read", cause))); - const candidate = yield* readStoredThreadMetadata(input.agentId); - const visited = new Set([candidate.id]); - let current = candidate; - let authorized = false; - - for (let depth = 0; depth < CODEX_SUBAGENT_MAX_ANCESTRY_DEPTH; depth += 1) { - const parentThreadId = readCodexSubagentParentThreadId(current); - if (parentThreadId === rootThreadId) { - authorized = true; - break; - } - if (!parentThreadId || visited.has(parentThreadId)) { - return yield* requestError( - `Codex thread '${input.agentId}' is not a subagent of this conversation.`, - ); - } - visited.add(parentThreadId); - current = yield* readStoredThreadMetadata(parentThreadId); - } - - if (!authorized) { - return yield* requestError( - `Codex thread '${input.agentId}' exceeded the supported subagent nesting depth.`, - ); - } + const candidate = yield* authorizeSubagentThread(context, threadId, input.agentId, { + requestError, + parentThreadDetail: "The requested transcript belongs to the parent thread.", + }); // Legacy threads expose their stored turns through `thread/read`. The // cursor API only exists for Codex's explicit paginated history mode; @@ -3638,6 +3676,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( clearThreadGoal, readThread, readSubagentTranscript, + sendSubagentInput, rollbackThread, deleteThread, respondToRequest, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index b5b469d2d..310b6372f 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -284,6 +284,13 @@ export interface CodexSessionRuntimeShape { readonly readStoredThreadItems: ( input: EffectCodexSchema.V2ThreadItemsListParams, ) => Effect.Effect; + /** Start a turn on another loaded provider thread (a spawned subagent) with + * a plain text message. Callers must authorize the thread first; the + * app-server rejects threads that do not accept direct input. */ + readonly startStoredThreadTurn: ( + providerThreadId: string, + text: string, + ) => Effect.Effect; readonly rollbackThread: ( numTurns: number, ) => Effect.Effect; @@ -2428,6 +2435,11 @@ export const makeCodexSessionRuntime = ( }) .pipe(Effect.map((response) => response.thread)), readStoredThreadItems: (input) => client.request("thread/items/list", input), + startStoredThreadTurn: (providerThreadId, text) => + client.request("turn/start", { + threadId: providerThreadId, + input: [{ type: "text", text }], + }), rollbackThread: (numTurns) => Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 7920f5abc..0926a033e 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -26,6 +26,7 @@ import { ProviderSendTurnInput, ProviderSessionStartInput, ProviderStartReviewInput, + ProviderSubagentInputRequest, ProviderSubagentTranscriptInput, ProviderSteerTurnInput, ProviderStopSessionInput, @@ -1876,6 +1877,34 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( return yield* readTranscript(routed.threadId, input); }); + const sendSubagentInput: ProviderServiceShape["sendSubagentInput"] = Effect.fn( + "sendSubagentInput", + )(function* (rawInput) { + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.sendSubagentInput", + schema: ProviderSubagentInputRequest, + payload: rawInput, + }); + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.sendSubagentInput", + allowRecovery: false, + }); + yield* Effect.annotateCurrentSpan({ + "provider.operation": "send-subagent-input", + "provider.kind": routed.adapter.provider, + "provider.thread_id": input.threadId, + }); + const send = routed.adapter.sendSubagentInput; + if (send === undefined) { + return yield* toValidationError( + "ProviderService.sendSubagentInput", + `Provider '${routed.adapter.provider}' does not accept direct input to subagents.`, + ); + } + return yield* send(routed.threadId, input); + }); + const resolveSubagentWorktree: ProviderServiceShape["resolveSubagentWorktree"] = Effect.fn( "resolveSubagentWorktree", )(function* (input) { @@ -2019,6 +2048,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( getInstanceInfo, rollbackConversation, readSubagentTranscript, + sendSubagentInput, resolveSubagentWorktree, deleteThread, // Each access creates a fresh PubSub subscription so that multiple diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index dac0a7361..decc9ef5e 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -233,6 +233,7 @@ describe("ProviderSessionReaper", () => { }, rollbackConversation: () => unsupported(), readSubagentTranscript: () => unsupported(), + sendSubagentInput: () => unsupported(), resolveSubagentWorktree: () => Effect.succeed(null), deleteThread: () => unsupported(), streamEvents: Stream.empty, diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 24669cec0..04f3f344d 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -24,6 +24,8 @@ import type { ProviderRealtimeAppendAudioInput, ProviderRealtimeListVoicesResult, ProviderRealtimeOutputModality, + ProviderSubagentInputRequest, + ProviderSubagentInputResult, ProviderSubagentTranscriptInput, ProviderSubagentTranscriptResult, ProviderSteerTurnInput, @@ -283,6 +285,16 @@ export interface ProviderAdapterShape { input: ProviderSubagentTranscriptInput, ) => Effect.Effect; + /** + * Send a user message straight to a spawned subagent, starting a turn on + * its own thread. Optional: only providers whose runtime accepts direct + * input to a child (Codex) implement it. + */ + readonly sendSubagentInput?: ( + threadId: ThreadId, + input: ProviderSubagentInputRequest, + ) => Effect.Effect; + /** * Where a spawned subagent is working, when it was given its own checkout. * diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 3752c392f..1696bff2f 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -36,6 +36,8 @@ import type { RuntimeThreadGoalSnapshot, ThreadGoalStatus, ThreadId, + ProviderSubagentInputRequest, + ProviderSubagentInputResult, ProviderSubagentTranscriptInput, ProviderSubagentTranscriptResult, ProviderTurnStartResult, @@ -202,6 +204,12 @@ export interface ProviderServiceShape { input: ProviderSubagentTranscriptInput, ) => Effect.Effect; + /** Send a user message straight to a spawned subagent (see the adapter's + * `sendSubagentInput`). */ + readonly sendSubagentInput: ( + input: ProviderSubagentInputRequest, + ) => Effect.Effect; + /** * Where an isolated subagent is working, or null when that is unknown — * because the provider does not record it, the session is not routable, or diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 41f85d095..d4227a30f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -45,6 +45,7 @@ import { ProviderExtensionsError, ProviderExternalThreadError, ProviderRealtimeError, + ProviderSubagentInputError, ProviderSubagentTranscriptError, ThreadId, type TerminalEvent, @@ -1112,6 +1113,22 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ), { "rpc.aggregate": "server" }, ), + [WS_METHODS.serverSendSubagentInput]: (input) => + observeRpcEffect( + WS_METHODS.serverSendSubagentInput, + providerService.sendSubagentInput(input).pipe( + Effect.mapError( + (error) => + new ProviderSubagentInputError({ + message: + error.message.trim().length > 0 + ? error.message + : "Failed to send the message to the agent.", + }), + ), + ), + { "rpc.aggregate": "server" }, + ), [WS_METHODS.serverListExternalProviderThreads]: (input) => observeRpcEffect( WS_METHODS.serverListExternalProviderThreads, diff --git a/apps/web/src/components/chat/AgentsPanel.browser.tsx b/apps/web/src/components/chat/AgentsPanel.browser.tsx index c382b954c..f87ae9154 100644 --- a/apps/web/src/components/chat/AgentsPanel.browser.tsx +++ b/apps/web/src/components/chat/AgentsPanel.browser.tsx @@ -17,9 +17,11 @@ import { buildRightPanelLauncherStates } from "./rightPanelLauncherState"; import type { ThreadBackgroundRunItem } from "./threadActivity"; const transcriptRpcMock = vi.hoisted(() => vi.fn()); +const sendInputRpcMock = vi.hoisted(() => vi.fn()); vi.mock("./subagentTranscriptClient", () => ({ readSubagentTranscriptPage: transcriptRpcMock, + sendSubagentInput: sendInputRpcMock, })); const ENVIRONMENT_ID = EnvironmentId.make("environment-local"); @@ -225,6 +227,49 @@ describe("AgentsPanel", () => { } }); + it("sends a message straight to a live Codex agent from its transcript", async () => { + sendInputRpcMock.mockResolvedValue({ turnId: "turn-direct" }); + const mounted = await renderPanel({ + subagents: [buildSubagent({ label: "Router sweep" })], + }); + + try { + await page.getByRole("button", { name: "Open Router sweep transcript" }).click(); + const box = page.getByRole("textbox", { name: "Message this agent" }); + await expect.element(box).toBeVisible(); + await box.fill("Focus on the router first."); + await userEvent.keyboard("{Enter}"); + + await vi.waitFor(() => { + expect(sendInputRpcMock).toHaveBeenCalledWith({ + environmentId: ENVIRONMENT_ID, + threadId: THREAD_ID, + agentId: "agent-1", + text: "Focus on the router first.", + }); + }); + // Sent: the line clears for the next message; the reply lands in the transcript. + await expect.element(box).toHaveValue(""); + } finally { + await mounted.unmount(); + } + }); + + it("offers no direct line to a Claude agent, which only the model can reach", async () => { + const mounted = await renderPanel({ + providerLabel: "claude", + subagents: [buildSubagent({ label: "Router sweep" })], + }); + + try { + await page.getByRole("button", { name: "Open Router sweep transcript" }).click(); + await expect.element(page.getByText("Walked the route files.")).toBeVisible(); + expect(document.querySelector("[data-subagent-input-composer='true']")).toBeNull(); + } finally { + await mounted.unmount(); + } + }); + /** The instruction block above the drilled-in thread, if there is one. */ function drilledInInstructionText(): string | null { return ( diff --git a/apps/web/src/components/chat/AgentsPanel.tsx b/apps/web/src/components/chat/AgentsPanel.tsx index 326c87197..5f1940a61 100644 --- a/apps/web/src/components/chat/AgentsPanel.tsx +++ b/apps/web/src/components/chat/AgentsPanel.tsx @@ -56,6 +56,12 @@ export interface AgentsPanelProps { onClose?: (() => void) | undefined; } +/** Only Codex lets the host start a turn on a spawned agent's own thread; + * Claude agents can only be reached by the model. */ +function providerAcceptsSubagentInput(providerLabel: string | null | undefined): boolean { + return providerLabel?.trim().toLowerCase().includes("codex") ?? false; +} + /** The trunk takes the provider's own hue so the panel reads as that * provider's work; anything else falls back to the hairline colour. */ function trunkColor(providerLabel: string | null | undefined): string { @@ -362,6 +368,7 @@ export const AgentsPanel = memo(function AgentsPanel({ details={deriveSubagentDisplayDetails(selectedSubagent)} cwd={threadCwd ?? undefined} dismissVariant="back" + canSendInput={providerAcceptsSubagentInput(providerLabel)} onClose={handleBack} /> ) : null; diff --git a/apps/web/src/components/chat/SubagentInspector.tsx b/apps/web/src/components/chat/SubagentInspector.tsx index 3304e66ce..1f67381d8 100644 --- a/apps/web/src/components/chat/SubagentInspector.tsx +++ b/apps/web/src/components/chat/SubagentInspector.tsx @@ -24,6 +24,7 @@ import { SubagentModelMeta, } from "./subagentMeta"; import { SubagentTranscript } from "./SubagentTranscript"; +import { sendSubagentInput } from "./subagentTranscriptClient"; interface SubagentInspectorProps { environmentId: EnvironmentId; @@ -35,6 +36,9 @@ interface SubagentInspectorProps { /** `back` returns to a list the inspector was drilled into (the agents * panel); `close` dismisses the surface entirely (the dialog). */ dismissVariant?: "close" | "back"; + /** The provider can take a message straight to this agent (Codex). The + * composer only shows while the agent is live. */ + canSendInput?: boolean | undefined; onClose: () => void; } @@ -67,6 +71,7 @@ export function SubagentInspector({ details, cwd, dismissVariant = "close", + canSendInput = false, onClose, }: SubagentInspectorProps) { const [providerAgent, setProviderAgent] = useState(); @@ -215,6 +220,95 @@ export function SubagentInspector({ onInstructionResolved={handleInstructionResolved} scrollable /> + {canSendInput && active && transcriptAgentId !== null ? ( + + ) : null} ); } + +/** + * One line to the agent itself, under its transcript. Enter sends, Shift+Enter + * breaks a line. The reply lands in the transcript above like any other turn, + * so there is nothing else to show here but a failure. + */ +function SubagentInputComposer({ + environmentId, + threadId, + agentId, + agentName, +}: { + environmentId: EnvironmentId; + threadId: ThreadId; + agentId: string; + agentName: string; +}) { + const [text, setText] = useState(""); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + const trimmed = text.trim(); + + const submit = useCallback(async () => { + if (trimmed.length === 0 || sending) { + return; + } + setSending(true); + setError(null); + try { + await sendSubagentInput({ environmentId, threadId, agentId, text: trimmed }); + setText(""); + } catch (cause) { + setError(cause instanceof Error && cause.message ? cause.message : "Could not send."); + } finally { + setSending(false); + } + }, [agentId, environmentId, sending, threadId, trimmed]); + + return ( +
{ + event.preventDefault(); + void submit(); + }} + > +
+