From b361b2725cd9f1195efd027013cf98037ceaace1 Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Sun, 27 Sep 2026 11:19:47 +0300 Subject: [PATCH 1/4] feat(composer): run one-off Bash commands as agent context Execute bang-prefixed submissions in the thread workspace and send bounded, redacted results through the existing provider turn flow. Reuse command activities, cancellation, and terminal authorization. Implemented by GPT-6-Astra through the Codex harness. --- .../Layers/ProviderCommandReactor.test.ts | 127 +++++++++++++++++- .../Layers/ProviderCommandReactor.ts | 93 ++++++++++++- .../src/orchestration/composerBash.test.ts | 120 +++++++++++++++++ apps/server/src/orchestration/composerBash.ts | 79 +++++++++++ apps/server/src/server.test.ts | 52 +++++++ apps/server/src/ws.ts | 10 +- docs/user/composer.md | 13 ++ 7 files changed, 486 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/orchestration/composerBash.test.ts create mode 100644 apps/server/src/orchestration/composerBash.ts diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c0db79fa42e3f..521e798183f40 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -171,6 +171,8 @@ describe("ProviderCommandReactor", () => { async function createHarness(input?: { readonly baseDir?: string; + readonly workspaceRoot?: string; + readonly onTurnSent?: () => Effect.Effect; readonly initialTitle?: string; readonly deferReactorStart?: boolean; readonly threadModelSelection?: ModelSelection; @@ -274,7 +276,7 @@ describe("ProviderCommandReactor", () => { Effect.succeed({ threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-1"), - }), + }).pipe(Effect.tap(() => input?.onTurnSent?.() ?? Effect.void)), ); const compactThread = vi.fn((_: ThreadId) => input?.compactThreadEffect?.() ?? Effect.void); const interruptTurn = vi.fn((_: unknown) => input?.interruptTurnEffect?.() ?? Effect.void); @@ -525,7 +527,7 @@ describe("ProviderCommandReactor", () => { commandId: CommandId.make("cmd-project-create"), projectId: asProjectId("project-1"), title: "Provider Project", - workspaceRoot: "/tmp/provider-project", + workspaceRoot: input?.workspaceRoot ?? "/tmp/provider-project", defaultModelSelection: modelSelection, createdAt: now, }), @@ -1023,6 +1025,126 @@ describe("ProviderCommandReactor", () => { }), ); + effectIt.effect.each(["project", "worktree"])( + "runs a composer command once in the %s and sends failure output as context", + (workspace) => + Effect.gen(function* () { + const sent = yield* Deferred.make(); + const workspaceRoot = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-bash-")); + createdBaseDirs.add(workspaceRoot); + const harness = yield* Effect.promise(() => + createHarness({ + workspaceRoot, + onTurnSent: () => Deferred.succeed(sent, undefined).pipe(Effect.asVoid), + }), + ); + const cwd = + workspace === "worktree" ? NodePath.join(workspaceRoot, "checkout") : workspaceRoot; + if (workspace === "worktree") { + NodeFS.mkdirSync(cwd); + yield* harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-bash-worktree"), + threadId: ThreadId.make("thread-1"), + worktreePath: cwd, + }); + } + const command = { + type: "thread.turn.start" as const, + commandId: CommandId.make("cmd-bash"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("bash-message"), + role: "user" as const, + text: "!printf x >> count; printf 'failed build' >&2; exit 2", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required" as const, + createdAt: "2026-01-01T00:00:00.000Z", + }; + yield* harness.engine.dispatch(command); + yield* Deferred.await(sent); + yield* harness.engine.dispatch(command); + yield* Effect.promise(() => harness.drain()); + expect(NodeFS.readFileSync(NodePath.join(cwd, "count"), "utf8")).toBe("x"); + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + input: expect.stringContaining("Exit code: 2"), + }); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + input: expect.stringContaining("failed build"), + }); + const snapshot = yield* Effect.promise(() => harness.readModel()); + const activities = snapshot.threads[0]?.activities ?? []; + expect(activities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "tool.updated", summary: "Running Bash command" }), + expect.objectContaining({ + kind: "tool.completed", + tone: "error", + payload: expect.objectContaining({ detail: expect.stringContaining("failed build") }), + }), + ]), + ); + }), + ); + + effectIt.effect("cancels a composer command without sending it to the provider", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ workspaceRoot: NodeOS.tmpdir() }), + ); + const running = yield* Deferred.make(); + const canceled = yield* Deferred.make(); + const events = yield* harness.engine.subscribeDomainEvents; + yield* Stream.runForEach(events, (event) => { + if (event.type !== "thread.activity-appended") return Effect.void; + const activity = event.payload.activity; + if (activity.summary === "Running Bash command") + return Deferred.succeed(running, undefined); + if (activity.kind === "provider.turn.start.failed") + return Deferred.succeed(canceled, undefined); + return Effect.void; + }).pipe(Effect.forkScoped); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bash-cancel-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("bash-cancel-message"), + role: "user", + text: "!sleep 60", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Deferred.await(running); + yield* harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.make("cmd-bash-cancel"), + threadId: ThreadId.make("thread-1"), + createdAt: "2026-01-01T00:00:01.000Z", + }); + yield* Deferred.await(canceled); + yield* Effect.promise(() => harness.drain()); + expect(harness.sendTurn).not.toHaveBeenCalled(); + expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([]); + const snapshot = yield* Effect.promise(() => harness.readModel()); + expect(snapshot.threads[0]?.session?.status).toBe("ready"); + expect(snapshot.threads[0]?.activities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "tool.completed", + payload: expect.objectContaining({ detail: "Bash command canceled." }), + }), + ]), + ); + }), + ); + it("reacts to thread.turn.start by ensuring session and sending provider turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -1046,6 +1168,7 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ input: "hello reactor" }); expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.make("thread-1")); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ cwd: "/tmp/provider-project", diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 35353c0662e39..b2fbf39b85ff3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -31,6 +31,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -73,6 +74,8 @@ import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; import * as TerminalManager from "../../terminal/Manager.ts"; +import * as ProcessRunner from "../../processRunner.ts"; +import { runComposerBashCommand, sanitizeComposerBashText } from "../composerBash.ts"; const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); @@ -269,6 +272,15 @@ const make = Effect.gen(function* () { const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; const terminalManager = yield* TerminalManager.TerminalManager; + const processRunner = yield* ProcessRunner.ProcessRunner; + const composerCommands = new Map }>(); + const interruptComposerCommands = Effect.fnUntraced(function* (threadId: ThreadId) { + const commands = [...composerCommands.values()].filter( + (command) => command.threadId === threadId, + ); + yield* Effect.forEach(commands, ({ fiber }) => Fiber.interrupt(fiber), { discard: true }); + return commands.length > 0; + }); /** Environment settings with the thread's project overrides applied. */ const projectSettingsForThread = Effect.fnUntraced(function* (threadId: ThreadId) { const settings = yield* serverSettingsService.getSettings; @@ -1610,15 +1622,75 @@ const make = Effect.gen(function* () { return; } - const send = providerService - .sendTurn(sendTurnRequest.value) - .pipe(Effect.asVoid, Effect.catchCause(recoverTurnStartFailure)); + const isBashCommand = message.text.startsWith("!"); + const appendBashActivity = Effect.fnUntraced(function* (detail?: string, failed = false) { + const createdAt = DateTime.formatIso(yield* DateTime.now); + yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: yield* serverCommandId("composer-bash"), + threadId: thread.id, + activity: { + id: yield* serverEventId(), + tone: failed ? "error" : "tool", + kind: detail === undefined ? "tool.updated" : "tool.completed", + summary: + detail === undefined + ? "Running Bash command" + : failed + ? "Bash command failed" + : "Bash command completed", + payload: { + itemType: "command_execution", + toolCallId: `composer-bash:${message.id}`, + status: detail === undefined ? "inProgress" : failed ? "failed" : "completed", + ...(detail !== undefined ? { detail } : {}), + data: { + command: sanitizeComposerBashText(message.text.slice(1)), + ...(detail !== undefined ? { rawOutput: detail } : {}), + }, + }, + turnId: null, + createdAt, + }, + createdAt, + }); + }); + const send = Effect.gen(function* () { + if (!isBashCommand) return yield* providerService.sendTurn(sendTurnRequest.value); + const result = yield* Effect.gen(function* () { + const project = yield* resolveProject(thread.projectId); + yield* appendBashActivity(); + return yield* runComposerBashCommand({ + text: message.text, + cwd: + resolveThreadWorkspaceCwd({ thread, projects: project ? [project] : [] }) ?? undefined, + }); + }).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + Effect.onInterrupt(() => + appendBashActivity("Bash command canceled.", true).pipe( + Effect.andThen( + appendTurnStartFailure( + "Bash command canceled", + "The command was canceled before its result could be sent.", + ), + ), + ), + ), + ); + composerCommands.delete(message.id); + if (result === null) return; + yield* appendBashActivity(result.detail, result.failed); + return yield* providerService.sendTurn({ ...sendTurnRequest.value, input: result.input }); + }).pipe(Effect.asVoid, Effect.catchCause(recoverTurnStartFailure)); // The forked send settles `sent` from here on, so drop the entry the post-processing hook uses. if (resumed && event.commandId !== null) resumedTurnStarts.delete(event.commandId); - yield* send.pipe( + const fiber = yield* send.pipe( Effect.ensuring(resumed ? Deferred.succeed(resumed.sent, undefined) : Effect.void), + Effect.ensuring(Effect.sync(() => composerCommands.delete(message.id))), Effect.forkScoped, ); + if (isBashCommand) composerCommands.set(message.id, { threadId: thread.id, fiber }); }); const resumeNextQueuedTurn = Effect.fn("resumeNextQueuedTurn")(function* (threadId: ThreadId) { @@ -1639,6 +1711,7 @@ const make = Effect.gen(function* () { const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( event: Extract, ) { + const canceledBash = yield* interruptComposerCommands(event.payload.threadId); yield* cancelTurnsAfterCompaction( event.payload.threadId, "Context compaction was interrupted. Send this message again to continue.", @@ -1648,6 +1721,13 @@ const make = Effect.gen(function* () { return; } const session = thread.session; + if (canceledBash && session && session.status !== "stopped" && session.activeTurnId === null) { + return yield* setThreadSession({ + threadId: thread.id, + session: { ...session, status: "ready", updatedAt: event.payload.createdAt }, + createdAt: event.payload.createdAt, + }); + } if (!session || session.status === "stopped") { return yield* appendProviderFailureActivity({ threadId: event.payload.threadId, @@ -1829,6 +1909,7 @@ const make = Effect.gen(function* () { const processSessionStopRequested = Effect.fn("processSessionStopRequested")(function* ( event: Extract, ) { + yield* interruptComposerCommands(event.payload.threadId); const thread = yield* resolveThreadShell(event.payload.threadId); if (!thread) { return; @@ -2079,4 +2160,6 @@ const make = Effect.gen(function* () { } satisfies ProviderCommandReactorShape; }); -export const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make); +export const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make).pipe( + Layer.provide(ProcessRunner.layer), +); diff --git a/apps/server/src/orchestration/composerBash.test.ts b/apps/server/src/orchestration/composerBash.test.ts new file mode 100644 index 0000000000000..3f1cd943a40e6 --- /dev/null +++ b/apps/server/src/orchestration/composerBash.test.ts @@ -0,0 +1,120 @@ +import * as NodeOS from "node:os"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ProcessRunner from "../processRunner.ts"; +import { runComposerBashCommand, sanitizeComposerBashText } from "./composerBash.ts"; + +const live = ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer)); +const run = (text: string) => + runComposerBashCommand({ text, cwd: NodeOS.tmpdir() }).pipe(Effect.provide(live)); + +describe("composer Bash commands", () => { + it.effect("leaves ordinary messages alone", () => + Effect.gen(function* () { + expect(yield* run("Explain !false")).toBeNull(); + expect(yield* run("hello")).toBeNull(); + }), + ); + + it.effect("captures stdout and stderr from one Bash execution", () => + Effect.gen(function* () { + const result = yield* run("!printf 'build output'; printf 'build warning' >&2; exit 2"); + expect(result?.failed).toBe(true); + expect(result?.detail).toContain("Exit code: 2"); + expect(result?.detail).toContain("build output"); + expect(result?.detail).toContain("build warning"); + expect(result?.input).toContain("already ran"); + }), + ); + + it.effect.each(["!true", "!printf '\\n'", "!printf '\\033[0m'"])( + "reports empty output explicitly for %s", + (command) => + Effect.gen(function* () { + const result = yield* run(command); + expect(result?.failed).toBe(false); + expect(result?.detail).toBe("Exit code: 0\n(no output)"); + }), + ); + + it("redacts secret environment values", () => { + vi.stubEnv("T3_TEST_SECRET", "example-private-value"); + try { + expect(sanitizeComposerBashText("value=example-private-value")).toBe("value=[redacted]"); + } finally { + vi.unstubAllEnvs(); + } + }); + + it.effect("does not execute without a thread workspace", () => + Effect.gen(function* () { + const result = yield* runComposerBashCommand({ text: "!pwd", cwd: undefined }).pipe( + Effect.provide(live), + ); + expect(result?.failed).toBe(true); + expect(result?.detail).toContain("no workspace"); + }), + ); + + it.effect("rejects an empty command", () => + Effect.gen(function* () { + const result = yield* run("! "); + expect(result?.failed).toBe(true); + expect(result?.detail).toContain("Enter a Bash command after !"); + }), + ); + + it.effect("bounds output and redacts recognizable credentials", () => + Effect.gen(function* () { + const result = yield* run("!printf 'Bearer example-secret\\n'; printf '%20000s' x"); + expect(result?.detail).toContain("Bearer [redacted]"); + expect(result?.detail).not.toContain("example-secret"); + expect(result?.detail).toContain("[output truncated]"); + expect(result!.detail.length).toBeLessThan(17000); + }), + ); + + it.effect("reports a timeout without claiming success", () => + Effect.gen(function* () { + const result = yield* runComposerBashCommand({ text: "!build", cwd: "/workspace" }).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: () => + Effect.succeed({ + stdout: "", + stderr: "", + code: null, + timedOut: true, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + }), + ); + expect(result?.failed).toBe(true); + expect(result?.detail).toContain("timed out after 60 seconds"); + }), + ); + + it.effect("does not expose process failure causes", () => + Effect.gen(function* () { + const result = yield* runComposerBashCommand({ text: "!build", cwd: "/workspace" }).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: () => + Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: "bash", + argumentCount: 2, + cause: "private credential", + }), + ), + }), + ); + expect(result?.detail).toContain("Bash could not be started"); + expect(result?.input).not.toContain("private credential"); + }), + ); +}); diff --git a/apps/server/src/orchestration/composerBash.ts b/apps/server/src/orchestration/composerBash.ts new file mode 100644 index 0000000000000..98ddfa67768fd --- /dev/null +++ b/apps/server/src/orchestration/composerBash.ts @@ -0,0 +1,79 @@ +import * as NodeUtil from "node:util"; +import * as Effect from "effect/Effect"; + +import { ProcessRunner } from "../processRunner.ts"; +import { sanitizeAcpStderrExcerpt } from "../provider/acp/AcpStderr.ts"; + +export function sanitizeComposerBashText(text: string): string { + let sanitized = NodeUtil.stripVTControlCharacters(text); + for (const [name, value] of Object.entries(process.env)) { + if ( + /(?:TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY|CREDENTIAL)/i.test(name) && + value && + value.length >= 4 + ) { + sanitized = sanitized.replaceAll(value, "[redacted]"); + } + } + return sanitizeAcpStderrExcerpt(sanitized); +} + +export const runComposerBashCommand = Effect.fnUntraced(function* (input: { + readonly text: string; + readonly cwd: string | undefined; +}) { + if (!input.text.startsWith("!")) return null; + const command = input.text.slice(1).trim(); + const runner = yield* ProcessRunner; + const result = !command + ? { failed: true, detail: "Enter a Bash command after !." } + : !input.cwd + ? { failed: true, detail: "The thread has no workspace to run this command in." } + : yield* runner + .run({ + command: "bash", + args: ["--noprofile", "--norc", "-c", command], + cwd: input.cwd, + stdin: "", + timeout: "60 seconds", + timeoutBehavior: "timedOutResult", + maxOutputBytes: 16 * 1024, + outputMode: "truncate", + truncatedMarker: "\n[output truncated]", + env: { BASH_ENV: "", ENV: "" }, + }) + .pipe( + Effect.map((output) => { + const stdout = sanitizeComposerBashText(output.stdout); + const stderr = sanitizeComposerBashText(output.stderr); + return { + failed: output.timedOut || output.code !== 0, + detail: output.timedOut + ? "Bash command timed out after 60 seconds. Partial output is unavailable." + : [ + `Exit code: ${output.code ?? "unknown"}`, + ...(stdout ? [`stdout:\n${stdout}`] : []), + ...(stderr ? [`stderr:\n${stderr}`] : []), + ...(!stdout && !stderr ? ["(no output)"] : []), + ].join("\n"), + }; + }), + Effect.catch((error) => + Effect.succeed({ + failed: true, + detail: + error._tag === "ProcessSpawnError" + ? "Bash could not be started. Check that Bash is installed and the workspace exists." + : "Bash command failed while collecting its output.", + }), + ), + ); + const safeCommand = sanitizeComposerBashText(command); + const detail = sanitizeComposerBashText(result.detail); + return { + command: safeCommand, + failed: result.failed, + detail, + input: `The user already ran this one-off Bash command. Use its result as context; do not rerun it automatically.\n\nCommand: ${safeCommand}\n\n${detail}`, + }; +}); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b20458cd200bd..d65c08962c788 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4559,6 +4559,58 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("requires terminal permission for Bash composer submissions", () => + Effect.gen(function* () { + const dispatched: string[] = []; + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command.commandId); + return { sequence: 1 }; + }), + }, + }, + }); + const { body: token } = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { + scope: "orchestration:operate", + }); + const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { authorization: `Bearer ${token.access_token ?? ""}` }, + }); + const ticket = (yield* ticketResponse.json) as { readonly ticket: string }; + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket.ticket)}`; + yield* withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const command = (text: string) => ({ + type: "thread.turn.start" as const, + commandId: CommandId.make(text === "hello" ? "normal-message" : "bash-message"), + threadId: ThreadId.make("thread-bash-scope"), + message: { + messageId: MessageId.make("scope-message"), + role: "user" as const, + text, + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand](command("hello")); + const error = yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]( + command("!printf hello"), + ).pipe(Effect.flip); + assert.equal(error._tag, "EnvironmentAuthorizationError"); + if (error._tag === "EnvironmentAuthorizationError") + assert.equal(error.requiredScope, "terminal:operate"); + }), + ); + assert.deepEqual(dispatched, ["normal-message"]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("does not allow management-only access tokens to operate the environment", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 3728fcca69c31..a923ceb1a60ce 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -20,6 +20,7 @@ import * as Stream from "effect/Stream"; import { DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, AuthAccessStreamError, + AuthTerminalOperateScope, AutomationOperationError, type AuthAccessStreamEvent, type AuthEnvironmentScope, @@ -195,6 +196,7 @@ import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); +const isEnvironmentAuthorizationError = Schema.is(EnvironmentAuthorizationError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const CONFIG_DISCOVERY_TIMEOUT = Duration.seconds(5); @@ -1994,6 +1996,12 @@ const makeWsRpcLayer = ( Effect.gen(function* () { yield* ProjectCloneTracker.rejectCommandsDuringClone(projectCloneTracker, command); const normalizedCommand = yield* normalizeDispatchCommand(command); + if ( + normalizedCommand.type === "thread.turn.start" && + normalizedCommand.message.text.startsWith("!") + ) { + yield* authorizeEffect(AuthTerminalOperateScope, Effect.void); + } if (normalizedCommand.type === "project.delete") { yield* worktreeRuns.stopProject(normalizedCommand.projectId); } @@ -2068,7 +2076,7 @@ const makeWsRpcLayer = ( return result; }).pipe( Effect.mapError((cause) => - isOrchestrationDispatchCommandError(cause) + isEnvironmentAuthorizationError(cause) || isOrchestrationDispatchCommandError(cause) ? cause : new OrchestrationDispatchCommandError({ message: "Failed to dispatch orchestration command", diff --git a/docs/user/composer.md b/docs/user/composer.md index 85d2b6f099e45..8fec9747ea446 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -153,6 +153,19 @@ Provider commands must start the message to run. T3 Code commands such as Send `/compact` in an existing conversation to reduce context usage when the provider supports it. Web and desktop also offer compaction from the context meter. +## One-off Bash commands + +Send `!` followed by a command, such as `!npm run build`, to run it in the thread's +workspace on the connected environment. T3 Code shows its result in the conversation +and sends the command, exit status, and output to the selected agent as context. +Failed commands include their output so the agent can help diagnose the failure. + +Commands require Bash and run without an interactive terminal or shell startup files. +Each command has a 60-second limit and captures up to 16 KiB each of stdout and stderr. +Truncated output and empty output are labeled. Stop cancels a running command; a +timeout ends it without preserving partial output. Known credentials are redacted, +but avoid commands that print secrets. + ## Context in your message Context you attach lands where your cursor is, as a chip inside your text: a terminal excerpt, From de324860daf9f6cf6dcae5773df939deb3a5c90b Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Sun, 27 Sep 2026 11:33:19 +0300 Subject: [PATCH 2/4] feat(chat): show expandable Bash results on user messages --- .../threads/BashMessageDisclosure.tsx | 61 ++++++++++++ .../src/features/threads/ThreadFeed.tsx | 43 +++++---- apps/mobile/src/lib/threadActivity.test.ts | 87 +++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 26 ++++- .../chat/BashMessageDisclosure.test.tsx | 76 +++++++++++++++ .../components/chat/BashMessageDisclosure.tsx | 52 ++++++++++ .../chat/MessagesTimeline.logic.test.ts | 96 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 14 +++ .../components/chat/MessagesTimeline.test.tsx | 83 ++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 26 +++-- packages/client-runtime/package.json | 4 + .../client-runtime/src/composerBash.test.ts | 61 ++++++++++++ packages/client-runtime/src/composerBash.ts | 42 ++++++++ 13 files changed, 643 insertions(+), 28 deletions(-) create mode 100644 apps/mobile/src/features/threads/BashMessageDisclosure.tsx create mode 100644 apps/web/src/components/chat/BashMessageDisclosure.test.tsx create mode 100644 apps/web/src/components/chat/BashMessageDisclosure.tsx create mode 100644 packages/client-runtime/src/composerBash.test.ts create mode 100644 packages/client-runtime/src/composerBash.ts diff --git a/apps/mobile/src/features/threads/BashMessageDisclosure.tsx b/apps/mobile/src/features/threads/BashMessageDisclosure.tsx new file mode 100644 index 0000000000000..9b9d079476bc9 --- /dev/null +++ b/apps/mobile/src/features/threads/BashMessageDisclosure.tsx @@ -0,0 +1,61 @@ +import { + composerBashStatusLabel, + type ComposerBashCommand, +} from "@t3tools/client-runtime/composer-bash"; +import type { ReactNode } from "react"; +import { Pressable, ScrollView, View, type ColorValue } from "react-native"; +import { AppText as Text } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import { ThreadDisclosureChevron } from "./thread-work-log"; + +export function BashMessageDisclosure({ + command, + expanded, + onToggle, + children, + chevronColor, +}: { + command: ComposerBashCommand | undefined; + expanded: boolean; + onToggle: () => void; + children: ReactNode; + chevronColor: ColorValue; +}) { + if (!command) return children; + + return ( + + + + {composerBashStatusLabel(command)} + + + + {children} + {expanded && ( + + + {command.output || + (command.status === "running" ? "Running command…" : "No command output.")} + + + )} + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 83b0bef9c0228..f951f582b72eb 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,3 +1,4 @@ +import { BashMessageDisclosure } from "./BashMessageDisclosure"; import { WorktreeWorkingHeader, WorktreeSetupCard, @@ -1559,7 +1560,8 @@ function renderFeedEntry( maxWidth: props.userBubbleMaxWidth, ...(hasReviewCommentContext ? { width: props.reviewCommentBubbleWidth } - : hasWideBlock + : hasWideBlock || + (entry.bashCommand && props.expandedWorkRows[`bash:${message.id}`]) ? { width: props.userBubbleMaxWidth } : null), }} @@ -1618,22 +1620,29 @@ function renderFeedEntry( })} ) : null} - {message.text.trim().length > 0 ? ( - - - - ) : null} + props.onToggleWorkRow(`bash:${message.id}`, entry.id)} + > + {message.text.trim().length > 0 ? ( + + + + ) : null} + diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 5d256f7c6206b..03fcdbe9cf3a4 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -3748,3 +3748,90 @@ it("keeps attachment-only question answers expandable outside mobile work groups expect(running[1]).toBe(group); expect(running[2]?.type).toBe("work-toggle"); }); + +describe("composer Bash message results", () => { + it("refreshes a cached message when its command finishes, preserving ordinary rows", () => { + const createdAt = "2026-09-01T00:00:00.000Z"; + const thread = makeThread({ + id: ThreadId.make("bash-thread"), + projectId: ProjectId.make("project"), + title: "Bash", + messages: [ + { + id: MessageId.make("bash-user"), + role: "user", + text: "!ls", + turnId: null, + createdAt, + updatedAt: createdAt, + streaming: false, + }, + { + id: MessageId.make("ordinary-user"), + role: "user", + text: "Explain this", + turnId: null, + createdAt, + updatedAt: createdAt, + streaming: false, + }, + { + id: MessageId.make("other-bash-user"), + role: "user", + text: "!pwd", + turnId: null, + createdAt, + updatedAt: createdAt, + streaming: false, + }, + ], + }); + const activity = makeActivity({ + id: EventId.make("bash-activity"), + kind: "tool.updated", + summary: "Running Bash command", + tone: "tool", + createdAt, + payload: { + toolCallId: "composer-bash:bash-user", + itemType: "command_execution", + status: "inProgress", + data: { command: "ls" }, + }, + }); + const messageRows = (feed: ThreadFeedEntry[]) => + feed.filter((entry) => entry.type === "message"); + const initial = messageRows(buildThreadFeed(thread)); + expect(initial.every((entry) => entry.bashCommand === undefined)).toBe(true); + const running = messageRows(buildThreadFeed({ ...thread, activities: [activity] })); + expect(running[0]?.bashCommand).toEqual({ status: "running", output: null }); + for (const status of ["completed", "failed"] as const) { + const detail = + status === "completed" ? "Exit code: 0\nfile.txt" : "Exit code: 1\nBuild failed"; + const finishedThread = { + ...thread, + activities: [ + { + ...activity, + kind: "tool.completed" as const, + payload: { + toolCallId: "composer-bash:bash-user", + itemType: "command_execution", + status, + detail, + data: { command: "ls", rawOutput: detail }, + }, + }, + ], + }; + const finished = messageRows(buildThreadFeed(finishedThread)); + expect(finished[0]?.bashCommand).toEqual({ status, output: detail }); + expect(finished[0]).not.toBe(running[0]); + expect(messageRows(buildThreadFeed(finishedThread))[0]).toBe(finished[0]); + for (const index of [1, 2]) { + expect(finished[index]).toBe(initial[index]); + expect(finished[index]?.bashCommand).toBeUndefined(); + } + } + }); +}); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 8f000b1b55fc5..fc53c14cf1f5a 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,3 +1,7 @@ +import { + indexComposerBashCommands, + type ComposerBashCommand, +} from "@t3tools/client-runtime/composer-bash"; import * as Option from "effect/Option"; import { foldUserInputActivities } from "@t3tools/client-runtime/work-log/user-input"; import * as Schema from "effect/Schema"; @@ -139,6 +143,7 @@ type RawThreadFeedEntry = readonly id: string; readonly createdAt: string; readonly message: OrchestrationThread["messages"][number]; + readonly bashCommand?: ComposerBashCommand | undefined; } | { readonly type: "activity"; @@ -2420,6 +2425,9 @@ export function buildThreadFeed( (entry) => oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt, ); + const bashCommands = indexComposerBashCommands( + activityEntries.map((entry) => entry.activity.workEntry), + ); const foldedAnswerMessageIds = new Set( activityEntries.flatMap((entry) => entry.activity.workEntry.questionAnswer @@ -2432,9 +2440,23 @@ export function buildThreadFeed( ...messages .filter((message) => message.role !== "user" || !foldedAnswerMessageIds.has(message.id)) .map((message) => { + const bashCommand = + message.role === "user" && message.text.startsWith("!") + ? bashCommands.get(message.id) + : undefined; let entry = messageEntriesCache.get(message); - if (!entry) { - entry = { type: "message", id: message.id, createdAt: message.createdAt, message }; + if ( + !entry || + entry.bashCommand?.status !== bashCommand?.status || + entry.bashCommand?.output !== bashCommand?.output + ) { + entry = { + type: "message", + id: message.id, + createdAt: message.createdAt, + message, + bashCommand, + }; messageEntriesCache.set(message, entry); } return entry; diff --git a/apps/web/src/components/chat/BashMessageDisclosure.test.tsx b/apps/web/src/components/chat/BashMessageDisclosure.test.tsx new file mode 100644 index 0000000000000..29deba13080e2 --- /dev/null +++ b/apps/web/src/components/chat/BashMessageDisclosure.test.tsx @@ -0,0 +1,76 @@ +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { BashMessageDisclosure } from "./BashMessageDisclosure"; + +describe("Bash message disclosure", () => { + let renderer: ReactTestRenderer; + beforeEach(() => vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true)); + afterEach(async () => { + if (renderer) await act(() => renderer.unmount()); + vi.unstubAllGlobals(); + }); + + it("leaves ordinary and unconfirmed messages unchanged", async () => { + await act(() => { + renderer = create( + +

!ls

+
, + ); + }); + expect(renderer.root.findAllByType("button")).toHaveLength(0); + expect(renderer.root.findByType("p").children).toEqual(["!ls"]); + }); + + it.each(["completed", "failed"] as const)( + "expands and collapses a %s command result", + async (status) => { + const output = + status === "completed" ? "Exit code: 0\nfile.txt" : "Exit code: 1\nBuild failed"; + await act(() => { + renderer = create( + +

!build

+
, + ); + }); + const button = renderer.root.findByType("button"); + expect(button.props["aria-expanded"]).toBe(false); + expect(renderer.root.findAllByType("pre")).toHaveLength(0); + await act(() => button.props.onClick()); + expect(button.props["aria-expanded"]).toBe(true); + expect(renderer.root.findByType("pre").children).toEqual([output]); + expect(renderer.root.findByProps({ id: button.props["aria-controls"] }).props.hidden).toBe( + false, + ); + await act(() => button.props.onClick()); + expect(button.props["aria-expanded"]).toBe(false); + expect(renderer.root.findAllByType("pre")).toHaveLength(0); + }, + ); + + it("updates an open running command with the server result", async () => { + await act(() => { + renderer = create( + + !true + , + ); + }); + await act(() => renderer.root.findByType("button").props.onClick()); + expect(renderer.root.findByType("pre").children).toEqual(["Running command…"]); + await act(() => + renderer.update( + + !true + , + ), + ); + expect(renderer.root.findByType("button").props["aria-expanded"]).toBe(true); + expect(renderer.root.findByType("pre").children).toEqual(["Exit code: 0\n(no output)"]); + expect(renderer.root.findByProps({ role: "status" }).children).toEqual(["Bash · Completed"]); + }); +}); diff --git a/apps/web/src/components/chat/BashMessageDisclosure.tsx b/apps/web/src/components/chat/BashMessageDisclosure.tsx new file mode 100644 index 0000000000000..4f13f3f544865 --- /dev/null +++ b/apps/web/src/components/chat/BashMessageDisclosure.tsx @@ -0,0 +1,52 @@ +import { + composerBashStatusLabel, + type ComposerBashCommand, +} from "@t3tools/client-runtime/composer-bash"; +import { ChevronDownIcon, TerminalIcon } from "lucide-react"; +import { useId, useState, type ReactNode } from "react"; + +export function BashMessageDisclosure({ + command, + children, +}: { + command: ComposerBashCommand | undefined; + children: ReactNode; +}) { + const [expanded, setExpanded] = useState(false); + const outputId = useId(); + if (!command) return children; + + return ( +
+ + {children} + +
+ ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index d95e225176302..e90ff90a1a6be 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3895,3 +3895,99 @@ describe("computeStableMessagesTimelineRows", () => { expect(reordered.result).toEqual([initial.result[1], initial.result[0]]); }); }); + +describe("composer Bash message projection", () => { + const createdAt = "2026-09-01T00:00:00.000Z"; + const messages: ChatMessage[] = [ + { + id: MessageId.make("bash-user"), + role: "user", + text: "!ls", + turnId: null, + createdAt, + updatedAt: createdAt, + streaming: false, + }, + { + id: MessageId.make("ordinary-user"), + role: "user", + text: "Explain this", + turnId: null, + createdAt, + updatedAt: createdAt, + streaming: false, + }, + { + id: MessageId.make("other-bash-user"), + role: "user", + text: "!pwd", + turnId: null, + createdAt, + updatedAt: createdAt, + streaming: false, + }, + ]; + const project = (work: WorkLogEntry[]) => + deriveMessagesTimelineRows({ + timelineEntries: deriveTimelineEntries(messages, [], work), + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + const work: WorkLogEntry = { + id: "bash-activity", + createdAt, + label: "Bash command", + tone: "tool", + toolCallId: "composer-bash:bash-user", + itemType: "command_execution", + sourceActivityKind: "tool.updated", + toolLifecycleStatus: "inProgress", + }; + + it("requires a matching server command and refreshes the message when its result arrives", () => { + const initial = computeStableMessagesTimelineRows(project([]), { byId: new Map(), result: [] }); + const running = computeStableMessagesTimelineRows(project([work]), initial); + const completedWork: WorkLogEntry = { + ...work, + sourceActivityKind: "tool.completed", + toolLifecycleStatus: "completed", + detail: "Exit code: 0\nfile.txt", + }; + const completed = computeStableMessagesTimelineRows(project([completedWork]), running); + const messageRows = (rows: ReadonlyArray) => + rows.filter((row) => row.kind === "message"); + expect(messageRows(initial.result).every((row) => row.bashCommand === undefined)).toBe(true); + expect(messageRows(running.result)[0]?.bashCommand).toEqual({ + status: "running", + output: null, + }); + expect(messageRows(completed.result)[0]?.bashCommand).toEqual({ + status: "completed", + output: "Exit code: 0\nfile.txt", + }); + expect(messageRows(completed.result)[0]).not.toBe(messageRows(running.result)[0]); + for (const index of [1, 2]) { + expect(messageRows(completed.result)[index]).toBe(messageRows(initial.result)[index]); + expect(messageRows(completed.result)[index]?.bashCommand).toBeUndefined(); + } + expect(computeStableMessagesTimelineRows(project([completedWork]), completed).result).toBe( + completed.result, + ); + }); + + it("preserves failed output without a completed state", () => { + const rows = project([ + { + ...work, + sourceActivityKind: "tool.completed", + toolLifecycleStatus: "failed", + detail: "Exit code: 1\nBuild failed", + }, + ]); + expect( + rows.find((row) => row.kind === "message" && row.message.id === "bash-user"), + ).toMatchObject({ bashCommand: { status: "failed", output: "Exit code: 1\nBuild failed" } }); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 21508a9663aef..ff476fa26010c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,3 +1,7 @@ +import { + indexComposerBashCommands, + type ComposerBashCommand, +} from "@t3tools/client-runtime/composer-bash"; import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import * as Equal from "effect/Equal"; @@ -410,6 +414,7 @@ export type MessagesTimelineRow = id: string; createdAt: string; message: ChatMessage; + bashCommand?: ComposerBashCommand | undefined; durationStart: string; showAssistantMeta: boolean; showAssistantCopyButton: boolean; @@ -976,6 +981,9 @@ export function deriveMessagesTimelineRows(input: { /** Messages sent during the running turn, rendered after the live rows. */ queuedMessages?: ReadonlyArray; }): MessagesTimelineRow[] { + const bashCommands = indexComposerBashCommands( + input.timelineEntries.flatMap((entry) => (entry.kind === "work" ? [entry.entry] : [])), + ); const turnDiffSummaryByAssistantMessageId = new Map(); for (const summary of input.turnDiffSummaries) { if (summary.assistantMessageId) { @@ -1396,6 +1404,10 @@ export function deriveMessagesTimelineRows(input: { id: timelineEntry.id, createdAt: timelineEntry.createdAt, message: timelineEntry.message, + bashCommand: + timelineEntry.message.role === "user" && timelineEntry.message.text.startsWith("!") + ? bashCommands.get(timelineEntry.message.id) + : undefined, durationStart, showAssistantMeta, showAssistantCopyButton: showAssistantMeta, @@ -1684,6 +1696,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean const bm = b as typeof a; return ( a.message === bm.message && + a.bashCommand?.status === bm.bashCommand?.status && + a.bashCommand?.output === bm.bashCommand?.output && a.durationStart === bm.durationStart && a.showAssistantMeta === bm.showAssistantMeta && a.showAssistantCopyButton === bm.showAssistantCopyButton && diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index a87dc2c2a444b..d521c0806d46f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -285,6 +285,89 @@ function buildSnapShotTimelineEntry(previewUrl?: string) { } describe("MessagesTimeline", () => { + it("opens the result from its Bash message and marks only successful commands", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("requestAnimationFrame", () => 0); + vi.stubGlobal("cancelAnimationFrame", () => {}); + const message = buildUserTimelineEntry("!ls"); + const ordinary = { + ...buildUserTimelineEntry("Explain this"), + id: "ordinary-entry", + message: { + ...buildUserTimelineEntry("Explain this").message, + id: MessageId.make("ordinary-message"), + }, + }; + const props = buildProps(); + let renderer: ReactTestRenderer | undefined; + try { + await act(() => { + renderer = create(); + }); + const bashStatuses = () => + renderer!.root.findAll( + (node) => node.props.role === "status" && String(node.children[0]).startsWith("Bash ·"), + ); + const glows = () => + renderer!.root.findAll( + (node) => + node.type === "div" && String(node.props.className).includes("shadow-destructive/20"), + ); + expect(bashStatuses()).toHaveLength(0); + expect(glows()).toHaveLength(0); + for (const status of ["inProgress", "completed", "failed"] as const) { + const detail = + status === "inProgress" + ? undefined + : status === "completed" + ? "Exit code: 0\nfile.txt" + : "Exit code: 1\nBuild failed"; + await act(() => + renderer!.update( + , + ), + ); + expect(bashStatuses()).toHaveLength(1); + expect(glows()).toHaveLength(status === "completed" ? 1 : 0); + const disclosure = bashStatuses()[0]!.parent!; + if (status === "inProgress") await act(() => disclosure.props.onClick()); + expect(disclosure.props["aria-expanded"]).toBe(true); + expect( + renderer!.root.findByProps({ "aria-label": "Bash command result" }).children, + ).toEqual([detail ?? "Running command…"]); + } + await act(() => bashStatuses()[0]!.parent!.props.onClick()); + expect(renderer!.root.findAllByProps({ "aria-label": "Bash command result" })).toHaveLength( + 0, + ); + } finally { + await act(() => renderer?.unmount()); + vi.unstubAllGlobals(); + } + }); + it("renders previous and next controls with the minimap", () => { const first = buildUserTimelineEntry("First turn"); const secondBase = buildUserTimelineEntry("Second turn"); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a1d8176207ae0..8d824835473a6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,4 +1,5 @@ import { ArrowUpIcon, ClockIcon } from "lucide-react"; +import { BashMessageDisclosure } from "./BashMessageDisclosure"; import { ReadOnlySourcePreview } from "../files/AttachmentFilePreview"; import { useRightPanelStore } from "~/rightPanelStore"; import { @@ -2109,7 +2110,12 @@ function UserTimelineRow({ row }: { row: Extract -
+
You {(regularImages.length > 0 || userVideos.length > 0) && (
@@ -2213,14 +2219,16 @@ function UserTimelineRow({ row }: { row: Extract ) : null} -
- -
+ +
+ +
+
diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index dc8cc4ae76ea8..04f27192ded15 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./composer-bash": { + "types": "./src/composerBash.ts", + "default": "./src/composerBash.ts" + }, "./device/stream": { "types": "./src/device/stream.ts", "default": "./src/device/stream.ts" diff --git a/packages/client-runtime/src/composerBash.test.ts b/packages/client-runtime/src/composerBash.test.ts new file mode 100644 index 0000000000000..14cc9fdf23443 --- /dev/null +++ b/packages/client-runtime/src/composerBash.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vite-plus/test"; +import { indexComposerBashCommands } from "./composerBash.ts"; + +const activity = { + toolCallId: "composer-bash:message-1", + itemType: "command_execution", + sourceActivityKind: "tool.updated", + toolLifecycleStatus: "inProgress", +}; + +describe("composer Bash message results", () => { + it("links server-confirmed commands to their exact message id", () => { + const commands = indexComposerBashCommands([activity]); + expect(commands.get("message-1")).toEqual({ status: "running", output: null }); + expect(commands.has("message-2")).toBe(false); + }); + + it("replaces running state with the corresponding result", () => { + const commands = indexComposerBashCommands([ + activity, + { + ...activity, + sourceActivityKind: "tool.completed", + toolLifecycleStatus: "completed", + detail: "Exit code: 0\nstdout:\nfile.txt", + }, + ]); + expect(commands.get("message-1")).toEqual({ + status: "completed", + output: "Exit code: 0\nstdout:\nfile.txt", + }); + }); + + it("keeps failed output distinct from successful entry", () => { + expect( + indexComposerBashCommands([ + { + ...activity, + sourceActivityKind: "tool.completed", + toolLifecycleStatus: "failed", + detail: "Bash could not be started.", + }, + ]).get("message-1"), + ).toEqual({ status: "failed", output: "Bash could not be started." }); + }); + + it("ignores ordinary tools and unconfirmed lifecycle states", () => { + expect( + indexComposerBashCommands([ + { ...activity, toolCallId: "provider-tool-1" }, + { ...activity, sourceActivityKind: "tool.started" }, + { ...activity, itemType: "file_change" }, + { + toolCallId: activity.toolCallId, + itemType: activity.itemType, + sourceActivityKind: activity.sourceActivityKind, + }, + ]).size, + ).toBe(0); + }); +}); diff --git a/packages/client-runtime/src/composerBash.ts b/packages/client-runtime/src/composerBash.ts new file mode 100644 index 0000000000000..30dae584bb7d7 --- /dev/null +++ b/packages/client-runtime/src/composerBash.ts @@ -0,0 +1,42 @@ +export interface ComposerBashCommand { + readonly status: "running" | "completed" | "failed"; + readonly output: string | null; +} + +export const composerBashStatusLabel = (command: ComposerBashCommand): string => + command.status === "running" + ? "Bash · Running" + : command.status === "completed" + ? "Bash · Completed" + : "Bash · Failed"; + +/** Only server activities with the composer message identity confirm Bash execution. */ +export function indexComposerBashCommands( + entries: ReadonlyArray<{ + readonly toolCallId?: string; + readonly itemType?: string; + readonly sourceActivityKind?: string; + readonly toolLifecycleStatus?: string; + readonly detail?: string; + }>, +): ReadonlyMap { + const commands = new Map(); + for (const entry of entries) { + if (!entry.toolCallId?.startsWith("composer-bash:") || entry.itemType !== "command_execution") + continue; + const status = + entry.sourceActivityKind === "tool.completed" + ? entry.toolLifecycleStatus === "completed" + ? "completed" + : "failed" + : entry.sourceActivityKind === "tool.updated" && entry.toolLifecycleStatus === "inProgress" + ? "running" + : null; + if (status) + commands.set(entry.toolCallId.slice("composer-bash:".length), { + status, + output: entry.detail ?? null, + }); + } + return commands; +} From 0ee5cf0ef7331fd4b2b42ebc64014a66c14d0cb6 Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Sun, 27 Sep 2026 11:38:28 +0300 Subject: [PATCH 3/4] fix(composer): center Bash glow around the input --- apps/mobile/global.css | 1 + .../features/threads/BashMessageDisclosure.tsx | 8 +------- .../src/features/threads/NewTaskDraftScreen.tsx | 1 + .../src/features/threads/ThreadComposer.tsx | 16 +++++++++++++++- apps/web/src/components/chat/ChatComposer.tsx | 14 +++++++++++++- .../components/chat/MessagesTimeline.test.tsx | 4 ++-- .../web/src/components/chat/MessagesTimeline.tsx | 7 +------ apps/web/src/index.css | 1 + 8 files changed, 35 insertions(+), 17 deletions(-) diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 730b9e1a97f34..ad4faf3fdbc8d 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -13,6 +13,7 @@ /* ─── Typography ────────────────────────────────────────────────────── */ @theme { + --shadow-composer-bash: 0 0 32px 4px rgb(0 0 0 / 25%); /* Keep these native family names aligned with app.config.ts. */ --font-sans: "DMSans-Regular"; --font-medium: "DMSans-Medium"; diff --git a/apps/mobile/src/features/threads/BashMessageDisclosure.tsx b/apps/mobile/src/features/threads/BashMessageDisclosure.tsx index 9b9d079476bc9..0ceb1407f38e8 100644 --- a/apps/mobile/src/features/threads/BashMessageDisclosure.tsx +++ b/apps/mobile/src/features/threads/BashMessageDisclosure.tsx @@ -5,7 +5,6 @@ import { import type { ReactNode } from "react"; import { Pressable, ScrollView, View, type ColorValue } from "react-native"; import { AppText as Text } from "../../components/AppText"; -import { cn } from "../../lib/cn"; import { ThreadDisclosureChevron } from "./thread-work-log"; export function BashMessageDisclosure({ @@ -24,12 +23,7 @@ export function BashMessageDisclosure({ if (!command) return children; return ( - + + {props.bashMode && ( + + Bash + + )} {props.children} @@ -675,6 +688,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} ) : null} -
+