diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 6c27e1010c..22e33d0871 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -328,6 +328,32 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("turn failed"); }); + it("does not project command output deltas into persisted tool activities", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-command-output"), + provider: "codex", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("item-command-1"), + createdAt: now, + payload: { + streamKind: "command_output", + delta: "hello from stdout\n", + }, + }); + + await harness.drain(); + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1")); + expect( + thread?.activities.some((entry) => entry.id === asEventId("evt-command-output")) ?? false, + ).toBe(false); + }); + it("applies provider session.state.changed transitions directly", async () => { const harness = await createHarness(); const waitingAt = new Date().toISOString(); @@ -1834,6 +1860,11 @@ describe("ProviderRuntimeIngestion", () => { status: "in_progress", title: "Read file", detail: "/tmp/file.ts", + data: { + item: { + command: ["sed", "-n", "1,40p", "/tmp/file.ts"], + }, + }, }, }); @@ -1853,6 +1884,18 @@ describe("ProviderRuntimeIngestion", () => { (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started", ), ).toBe(true); + const toolStarted = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-tool-started", + ); + const toolStartedPayload = + toolStarted?.payload && typeof toolStarted.payload === "object" + ? (toolStarted.payload as Record) + : undefined; + expect(toolStartedPayload?.data).toEqual({ + item: { + command: ["sed", "-n", "1,40p", "/tmp/file.ts"], + }, + }); }); it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index b479d1c28a..fab37141c2 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -441,6 +441,7 @@ function runtimeEventToActivities( summary: event.payload.title ?? "Tool updated", payload: { itemType: event.payload.itemType, + ...(event.itemId ? { itemId: event.itemId } : {}), ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), @@ -464,6 +465,7 @@ function runtimeEventToActivities( summary: event.payload.title ?? "Tool", payload: { itemType: event.payload.itemType, + ...(event.itemId ? { itemId: event.itemId } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), }, turnId: toTurnId(event.turnId) ?? null, @@ -485,7 +487,9 @@ function runtimeEventToActivities( summary: `${event.payload.title ?? "Tool"} started`, payload: { itemType: event.payload.itemType, + ...(event.itemId ? { itemId: event.itemId } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index cff7e26efa..93ed641d07 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -29,6 +29,7 @@ import { normalizeDispatchCommand } from "./orchestration/Normalizer"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry"; +import { ProviderService } from "./provider/Services/ProviderService"; import { ServerLifecycleEvents } from "./serverLifecycleEvents"; import { ServerRuntimeStartup } from "./serverRuntimeStartup"; import { ServerSettingsService } from "./serverSettings"; @@ -188,6 +189,18 @@ const WsRpcLayer = WsRpcGroup.toLayer( ); }), ), + [WS_METHODS.subscribeProviderRuntimeToolOutputEvents]: (_input) => + Stream.unwrap( + Effect.gen(function* () { + const providerService = yield* ProviderService; + return providerService.streamEvents.pipe( + Stream.filter( + (event) => + event.type === "content.delta" && event.payload.streamKind === "command_output", + ), + ); + }), + ), [WS_METHODS.serverGetConfig]: (_input) => loadServerConfig, [WS_METHODS.serverRefreshProviders]: (_input) => providerRegistry.refresh().pipe(Effect.map((providers) => ({ providers }))), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 76133712d4..a8f4c33807 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -47,13 +47,14 @@ import { deriveTimelineEntries, deriveActiveWorkStartedAt, deriveActivePlanState, + deriveBaseWorkLogEntries, findSidebarProposedPlan, findLatestProposedPlan, - deriveWorkLogEntries, hasActionableProposedPlan, hasToolActivityForTurn, isLatestTurnSettled, formatElapsed, + mergeRuntimeOutputIntoWorkLogEntries, } from "../session-logic"; import { isScrollContainerNearBottom } from "../chat-scroll"; import { @@ -65,6 +66,7 @@ import { import { useStore } from "../store"; import { useProjectById, useThreadById } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; +import { useRuntimeToolOutputStore } from "../runtimeToolOutputStore"; import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, @@ -834,10 +836,21 @@ export default function ChatView({ threadId }: ChatViewProps) { const selectedModelForPicker = selectedModel; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; - const workLogEntries = useMemo( - () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), + const runtimeToolOutputByItemIdRaw = useRuntimeToolOutputStore((state) => + activeThread ? (state.outputsByThreadId[activeThread.id] ?? null) : null, + ); + const runtimeToolOutputByItemId = useMemo( + () => new Map(Object.entries(runtimeToolOutputByItemIdRaw ?? {})), + [runtimeToolOutputByItemIdRaw], + ); + const baseWorkLogEntries = useMemo( + () => deriveBaseWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); + const workLogEntries = useMemo( + () => mergeRuntimeOutputIntoWorkLogEntries(baseWorkLogEntries, runtimeToolOutputByItemId), + [baseWorkLogEntries, runtimeToolOutputByItemId], + ); const latestTurnHasToolActivity = useMemo( () => hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId), [activeLatestTurn?.turnId, threadActivities], diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 692438c74a..f18b9494d7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -140,4 +140,57 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Context compacted"); expect(markup).toContain("Work log"); }); + + it("renders command output collapsed by default for tool work entries", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); + + expect(markup.match(/bun fmt/g)?.length).toBe(1); + expect(markup).toContain("Show output"); + expect(markup).not.toContain("apps/web/src/session-logic.ts"); + expect(markup).not.toContain( + "apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts", + ); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 9e0b895912..56c003a367 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -863,6 +863,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; }) { const { workEntry } = props; + const [outputExpanded, setOutputExpanded] = useState(false); const iconConfig = workToneIcon(workEntry.tone); const EntryIcon = workEntryIcon(workEntry); const heading = toolWorkEntryHeading(workEntry); @@ -870,6 +871,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { const displayText = preview ? `${heading} - ${preview}` : heading; const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0; const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail; + const hasOutput = typeof workEntry.output === "string" && workEntry.output.length > 0; return (
@@ -895,6 +897,22 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {

+ {hasOutput && ( +
+ + {outputExpanded && ( +
+              {workEntry.output}
+            
+ )} +
+ )} {hasChangedFiles && !previewIsChangedFiles && (
{workEntry.changedFiles?.slice(0, 4).map((filePath) => ( diff --git a/apps/web/src/orchestrationEventEffects.test.ts b/apps/web/src/orchestrationEventEffects.test.ts index 263610bb95..abfba8fa29 100644 --- a/apps/web/src/orchestrationEventEffects.test.ts +++ b/apps/web/src/orchestrationEventEffects.test.ts @@ -42,6 +42,7 @@ describe("deriveOrchestrationBatchEffects", () => { it("targets draft promotion and terminal cleanup from thread lifecycle events", () => { const createdThreadId = ThreadId.makeUnsafe("thread-created"); const deletedThreadId = ThreadId.makeUnsafe("thread-deleted"); + const startedThreadId = ThreadId.makeUnsafe("thread-started"); const effects = deriveOrchestrationBatchEffects([ makeEvent("thread.created", { @@ -60,10 +61,19 @@ describe("deriveOrchestrationBatchEffects", () => { threadId: deletedThreadId, deletedAt: "2026-02-27T00:00:01.000Z", }), + makeEvent("thread.turn-start-requested", { + threadId: startedThreadId, + messageId: MessageId.makeUnsafe("message-started"), + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-02-27T00:00:02.000Z", + }), ]); expect(effects.clearPromotedDraftThreadIds).toEqual([createdThreadId]); expect(effects.clearDeletedThreadIds).toEqual([deletedThreadId]); + expect(effects.clearRuntimeToolOutputThreadIds).toEqual([deletedThreadId, startedThreadId]); expect(effects.removeTerminalStateThreadIds).toEqual([deletedThreadId]); expect(effects.needsProviderInvalidation).toBe(false); }); @@ -102,6 +112,7 @@ describe("deriveOrchestrationBatchEffects", () => { expect(effects.clearPromotedDraftThreadIds).toEqual([threadId]); expect(effects.clearDeletedThreadIds).toEqual([]); + expect(effects.clearRuntimeToolOutputThreadIds).toEqual([]); expect(effects.removeTerminalStateThreadIds).toEqual([]); expect(effects.needsProviderInvalidation).toBe(true); }); diff --git a/apps/web/src/orchestrationEventEffects.ts b/apps/web/src/orchestrationEventEffects.ts index d4dda76d9e..d4052dec53 100644 --- a/apps/web/src/orchestrationEventEffects.ts +++ b/apps/web/src/orchestrationEventEffects.ts @@ -3,6 +3,7 @@ import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts"; export interface OrchestrationBatchEffects { clearPromotedDraftThreadIds: ThreadId[]; clearDeletedThreadIds: ThreadId[]; + clearRuntimeToolOutputThreadIds: ThreadId[]; removeTerminalStateThreadIds: ThreadId[]; needsProviderInvalidation: boolean; } @@ -15,6 +16,7 @@ export function deriveOrchestrationBatchEffects( { clearPromotedDraft: boolean; clearDeletedThread: boolean; + clearRuntimeToolOutput: boolean; removeTerminalState: boolean; } >(); @@ -32,15 +34,28 @@ export function deriveOrchestrationBatchEffects( threadLifecycleEffects.set(event.payload.threadId, { clearPromotedDraft: true, clearDeletedThread: false, + clearRuntimeToolOutput: false, removeTerminalState: false, }); break; } + case "thread.turn-start-requested": { + const previous = threadLifecycleEffects.get(event.payload.threadId); + threadLifecycleEffects.set(event.payload.threadId, { + clearPromotedDraft: previous?.clearPromotedDraft ?? false, + clearDeletedThread: previous?.clearDeletedThread ?? false, + clearRuntimeToolOutput: true, + removeTerminalState: previous?.removeTerminalState ?? false, + }); + break; + } + case "thread.deleted": { threadLifecycleEffects.set(event.payload.threadId, { clearPromotedDraft: false, clearDeletedThread: true, + clearRuntimeToolOutput: true, removeTerminalState: true, }); break; @@ -54,6 +69,7 @@ export function deriveOrchestrationBatchEffects( const clearPromotedDraftThreadIds: ThreadId[] = []; const clearDeletedThreadIds: ThreadId[] = []; + const clearRuntimeToolOutputThreadIds: ThreadId[] = []; const removeTerminalStateThreadIds: ThreadId[] = []; for (const [threadId, effect] of threadLifecycleEffects) { if (effect.clearPromotedDraft) { @@ -62,6 +78,9 @@ export function deriveOrchestrationBatchEffects( if (effect.clearDeletedThread) { clearDeletedThreadIds.push(threadId); } + if (effect.clearRuntimeToolOutput) { + clearRuntimeToolOutputThreadIds.push(threadId); + } if (effect.removeTerminalState) { removeTerminalStateThreadIds.push(threadId); } @@ -70,6 +89,7 @@ export function deriveOrchestrationBatchEffects( return { clearPromotedDraftThreadIds, clearDeletedThreadIds, + clearRuntimeToolOutputThreadIds, removeTerminalStateThreadIds, needsProviderInvalidation, }; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index d186a7c9c1..9bde526545 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -42,6 +42,7 @@ import { projectQueryKeys } from "../lib/projectReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects"; import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery"; +import { useRuntimeToolOutputStore } from "../runtimeToolOutputStore"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -201,6 +202,7 @@ function EventRouter() { const removeOrphanedTerminalStates = useTerminalStateStore( (store) => store.removeOrphanedTerminalStates, ); + const clearThreadRuntimeToolOutput = useRuntimeToolOutputStore((store) => store.clearThread); const queryClient = useQueryClient(); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); @@ -393,6 +395,9 @@ function EventRouter() { draftStore.clearDraftThread(threadId); clearThreadUi(threadId); } + for (const threadId of batchEffects.clearRuntimeToolOutputThreadIds) { + clearThreadRuntimeToolOutput(threadId); + } for (const threadId of batchEffects.removeTerminalStateThreadIds) { removeTerminalState(threadId); } @@ -489,6 +494,18 @@ function EventRouter() { hasRunningSubprocess, ); }); + const runtimeToolOutputStore = useRuntimeToolOutputStore.getState(); + runtimeToolOutputStore.clearAll(); + const unsubRuntimeToolOutputEvent = api.orchestration.onRuntimeToolOutputEvent((event) => { + if ( + event.type !== "content.delta" || + event.payload.streamKind !== "command_output" || + !event.itemId + ) { + return; + } + runtimeToolOutputStore.appendOutput(event.threadId, event.itemId, event.payload.delta); + }); return () => { disposed = true; disposedRef.current = true; @@ -498,6 +515,7 @@ function EventRouter() { queryInvalidationThrottler.cancel(); unsubDomainEvent(); unsubTerminalEvent(); + unsubRuntimeToolOutputEvent(); }; }, [ applyOrchestrationEvents, @@ -506,6 +524,7 @@ function EventRouter() { removeTerminalState, removeOrphanedTerminalStates, clearThreadUi, + clearThreadRuntimeToolOutput, setProjectExpanded, syncProjects, syncServerReadModel, diff --git a/apps/web/src/runtimeToolOutputStore.ts b/apps/web/src/runtimeToolOutputStore.ts new file mode 100644 index 0000000000..4de69a0455 --- /dev/null +++ b/apps/web/src/runtimeToolOutputStore.ts @@ -0,0 +1,43 @@ +import { ThreadId } from "@t3tools/contracts"; +import { create } from "zustand"; + +interface RuntimeToolOutputState { + outputsByThreadId: Record>; + appendOutput: (threadId: ThreadId, itemId: string, delta: string) => void; + clearThread: (threadId: ThreadId) => void; + clearAll: () => void; +} + +const MAX_OUTPUT_CHARS_PER_ITEM = 24_000; + +export const useRuntimeToolOutputStore = create((set) => ({ + outputsByThreadId: {}, + appendOutput: (threadId, itemId, delta) => + set((state) => { + const threadOutputs = state.outputsByThreadId[threadId] ?? {}; + const previous = threadOutputs[itemId] ?? ""; + const next = `${previous}${delta}`; + return { + outputsByThreadId: { + ...state.outputsByThreadId, + [threadId]: { + ...threadOutputs, + [itemId]: + next.length > MAX_OUTPUT_CHARS_PER_ITEM + ? next.slice(next.length - MAX_OUTPUT_CHARS_PER_ITEM) + : next, + }, + }, + }; + }), + clearThread: (threadId) => + set((state) => { + if (!(threadId in state.outputsByThreadId)) { + return state; + } + const next = { ...state.outputsByThreadId }; + delete next[threadId]; + return { outputsByThreadId: next }; + }), + clearAll: () => set({ outputsByThreadId: {} }), +})); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index e05c3b5e93..bc9e39ce30 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -825,6 +825,558 @@ describe("deriveWorkLogEntries", () => { }); }); + it("merges ephemeral command output into the matching command entry", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-start", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.started", + summary: "Ran command started", + payload: { + itemType: "command_execution", + itemId: "item-command-1", + title: "Ran command", + data: { + item: { + command: ["bun", "fmt"], + }, + }, + }, + }), + makeActivity({ + id: "command-progress-1", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + itemId: "item-command-1", + title: "Ran command", + }, + }), + makeActivity({ + id: "command-progress-2", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + itemId: "item-command-1", + title: "Ran command", + }, + }), + makeActivity({ + id: "command-complete", + createdAt: "2026-02-23T00:00:04.000Z", + kind: "tool.completed", + summary: "Ran command completed", + payload: { + itemType: "command_execution", + itemId: "item-command-1", + title: "Ran command", + }, + }), + ]; + + const [entry] = deriveWorkLogEntries( + activities, + undefined, + new Map([ + [ + "item-command-1", + "apps/web/src/session-logic.ts\napps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts\n", + ], + ]), + ); + + expect(entry).toMatchObject({ + id: "command-complete", + command: "bun fmt", + output: + "apps/web/src/session-logic.ts\napps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts\n", + itemType: "command_execution", + toolTitle: "Ran command", + }); + }); + + it("keeps command tool.started entries so output can stream before completion", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-start-only", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.started", + summary: "Ran command started", + payload: { + itemType: "command_execution", + itemId: "item-command-live", + title: "Ran command", + data: { + item: { + command: ["bun", "run", "build"], + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries( + activities, + undefined, + new Map([["item-command-live", "building...\n"]]), + ); + + expect(entry).toMatchObject({ + id: "command-start-only", + command: "bun run build", + output: "building...\n", + itemType: "command_execution", + toolTitle: "Ran command", + }); + }); + + it("collapses command started and completed rows even when their details differ", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-start", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.started", + summary: "Ran command started", + payload: { + itemType: "command_execution", + itemId: "item-command-build", + title: "Ran command", + detail: "npm run build", + data: { + item: { + command: ["npm", "run", "build"], + }, + }, + }, + }), + makeActivity({ + id: "command-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Ran command completed", + payload: { + itemType: "command_execution", + itemId: "item-command-build", + title: "Ran command", + detail: "", + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + id: "command-complete", + command: "npm run build", + itemType: "command_execution", + toolTitle: "Ran command", + }); + }); + + it("collapses a single replayed command completion by group when itemId is absent", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-start-no-item-id", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.started", + summary: "Ran command started", + payload: { + itemType: "command_execution", + title: "Ran command", + detail: "npm run build", + data: { + item: { + command: ["npm", "run", "build"], + }, + }, + }, + }), + makeActivity({ + id: "command-complete-no-item-id", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Ran command completed", + payload: { + itemType: "command_execution", + title: "Ran command", + detail: "", + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + id: "command-complete-no-item-id", + command: "npm run build", + itemType: "command_execution", + toolTitle: "Ran command", + }); + }); + + it("collapses replayed command started and updated rows when only the summary differs by lifecycle suffix", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-start", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.started", + summary: "Ran command started", + payload: { + itemType: "command_execution", + detail: "npm run build", + data: { + item: { + command: ["npm", "run", "build"], + }, + }, + }, + }), + makeActivity({ + id: "command-update", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + detail: "npm run build", + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + id: "command-update", + command: "npm run build", + itemType: "command_execution", + label: "Ran command", + }); + }); + + it("collapses replayed multi-command rows by itemId when all started events sort before later lifecycle events", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-1-start", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.started", + summary: "Ran command started", + payload: { + itemType: "command_execution", + itemId: "item-command-1", + detail: "npm run build", + data: { + item: { + command: ["npm", "run", "build"], + }, + }, + }, + }), + makeActivity({ + id: "command-2-start", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.started", + summary: "Ran command started", + payload: { + itemType: "command_execution", + itemId: "item-command-2", + detail: "npm run lint", + data: { + item: { + command: ["npm", "run", "lint"], + }, + }, + }, + }), + makeActivity({ + id: "command-1-update", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + itemId: "item-command-1", + detail: "npm run build", + }, + }), + makeActivity({ + id: "command-1-complete", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.completed", + summary: "Ran command completed", + payload: { + itemType: "command_execution", + itemId: "item-command-1", + detail: "", + }, + }), + makeActivity({ + id: "command-2-update", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + itemId: "item-command-2", + detail: "npm run lint", + }, + }), + makeActivity({ + id: "command-2-complete", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.completed", + summary: "Ran command completed", + payload: { + itemType: "command_execution", + itemId: "item-command-2", + detail: "", + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + + expect(entries).toHaveLength(2); + expect(entries[0]).toMatchObject({ + id: "command-1-complete", + command: "npm run build", + itemType: "command_execution", + }); + expect(entries[1]).toMatchObject({ + id: "command-2-complete", + command: "npm run lint", + itemType: "command_execution", + }); + }); + + it("does not collapse ambiguous replayed command completions when multiple open commands share a group and itemId is absent", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-1-start-no-item-id", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.started", + summary: "Ran command started", + payload: { + itemType: "command_execution", + title: "Ran command", + detail: "npm run build", + data: { + item: { + command: ["npm", "run", "build"], + }, + }, + }, + }), + makeActivity({ + id: "command-2-start-no-item-id", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.started", + summary: "Ran command started", + payload: { + itemType: "command_execution", + title: "Ran command", + detail: "npm run lint", + data: { + item: { + command: ["npm", "run", "lint"], + }, + }, + }, + }), + makeActivity({ + id: "command-complete-ambiguous", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "tool.completed", + summary: "Ran command completed", + payload: { + itemType: "command_execution", + title: "Ran command", + detail: "", + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + + expect(entries).toHaveLength(3); + expect(entries.map((entry) => entry.id)).toEqual([ + "command-1-start-no-item-id", + "command-2-start-no-item-id", + "command-complete-ambiguous", + ]); + }); + + it("preserves output on the first command when multiple replayed commands are present", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-1-start", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.started", + summary: "Ran command started", + payload: { + itemType: "command_execution", + itemId: "item-command-1", + detail: "npm run build", + data: { + item: { + command: ["npm", "run", "build"], + }, + }, + }, + }), + makeActivity({ + id: "command-2-start", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.started", + summary: "Ran command started", + payload: { + itemType: "command_execution", + itemId: "item-command-2", + detail: "npm run lint", + data: { + item: { + command: ["npm", "run", "lint"], + }, + }, + }, + }), + makeActivity({ + id: "command-1-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Ran command completed", + payload: { + itemType: "command_execution", + itemId: "item-command-1", + detail: "", + }, + }), + makeActivity({ + id: "command-2-complete", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "tool.completed", + summary: "Ran command completed", + payload: { + itemType: "command_execution", + itemId: "item-command-2", + detail: "", + }, + }), + ]; + + const entries = deriveWorkLogEntries( + activities, + undefined, + new Map([ + ["item-command-1", "build output\n"], + ["item-command-2", "lint output\n"], + ]), + ); + + expect(entries).toHaveLength(2); + expect(entries[0]).toMatchObject({ + id: "command-1-complete", + command: "npm run build", + output: "build output\n", + itemType: "command_execution", + }); + expect(entries[1]).toMatchObject({ + id: "command-2-complete", + command: "npm run lint", + output: "lint output\n", + itemType: "command_execution", + }); + }); + + it("preserves output for both commands while a second command is still in progress", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-1-start", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.started", + summary: "Ran command started", + payload: { + itemType: "command_execution", + itemId: "item-command-1", + title: "Ran command", + data: { + item: { + command: ["npm", "run", "build"], + }, + }, + }, + }), + makeActivity({ + id: "command-1-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Ran command completed", + payload: { + itemType: "command_execution", + itemId: "item-command-1", + title: "Ran command", + detail: "", + }, + }), + makeActivity({ + id: "command-2-start", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "tool.started", + summary: "Ran command started", + payload: { + itemType: "command_execution", + itemId: "item-command-2", + title: "Ran command", + data: { + item: { + command: ["npm", "run", "lint"], + }, + }, + }, + }), + makeActivity({ + id: "command-2-update", + createdAt: "2026-02-23T00:00:04.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + itemId: "item-command-2", + title: "Ran command", + }, + }), + ]; + + const entries = deriveWorkLogEntries( + activities, + undefined, + new Map([ + ["item-command-1", "build output\n"], + ["item-command-2", "lint output\n"], + ]), + ); + + expect(entries).toHaveLength(2); + expect(entries[0]).toMatchObject({ + id: "command-1-complete", + command: "npm run build", + output: "build output\n", + itemType: "command_execution", + }); + expect(entries[1]).toMatchObject({ + id: "command-2-update", + command: "npm run lint", + output: "lint output\n", + itemType: "command_execution", + }); + }); + it("keeps separate tool entries when an identical call starts after the prior one completed", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index fc33827014..534b5399b8 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -38,6 +38,7 @@ export interface WorkLogEntry { label: string; detail?: string; command?: string; + output?: string; changedFiles?: ReadonlyArray; tone: "thinking" | "tool" | "info" | "error"; toolTitle?: string; @@ -45,9 +46,14 @@ export interface WorkLogEntry { requestKind?: PendingApproval["requestKind"]; } -interface DerivedWorkLogEntry extends WorkLogEntry { +export interface RuntimeAttachableWorkLogEntry extends WorkLogEntry { + itemId?: string; +} + +interface DerivedWorkLogEntry extends RuntimeAttachableWorkLogEntry { activityKind: OrchestrationThreadActivity["kind"]; collapseKey?: string; + groupKey?: string; } export interface PendingApproval { @@ -457,21 +463,57 @@ export function hasActionableProposedPlan( export function deriveWorkLogEntries( activities: ReadonlyArray, latestTurnId: TurnId | undefined, + runtimeOutputByItemId: ReadonlyMap = new Map(), ): WorkLogEntry[] { + return mergeRuntimeOutputIntoWorkLogEntries( + deriveBaseWorkLogEntries(activities, latestTurnId), + runtimeOutputByItemId, + ); +} + +export function deriveBaseWorkLogEntries( + activities: ReadonlyArray, + latestTurnId: TurnId | undefined, +): RuntimeAttachableWorkLogEntry[] { const ordered = [...activities].toSorted(compareActivitiesByOrder); const entries = ordered .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true)) - .filter((activity) => activity.kind !== "tool.started") + .filter((activity) => shouldIncludeWorkLogActivity(activity)) .filter((activity) => activity.kind !== "task.started" && activity.kind !== "task.completed") .filter((activity) => activity.kind !== "context-window.updated") .filter((activity) => activity.summary !== "Checkpoint captured") .filter((activity) => !isPlanBoundaryToolActivity(activity)) .map(toDerivedWorkLogEntry); return collapseDerivedWorkLogEntries(entries).map( - ({ activityKind: _activityKind, collapseKey: _collapseKey, ...entry }) => entry, + ({ activityKind: _activityKind, collapseKey: _collapseKey, groupKey: _groupKey, ...entry }) => + entry, ); } +export function mergeRuntimeOutputIntoWorkLogEntries( + entries: ReadonlyArray, + runtimeOutputByItemId: ReadonlyMap, +): WorkLogEntry[] { + return entries.map(({ itemId, ...entry }) => { + if (!itemId || !runtimeOutputByItemId.has(itemId)) { + return entry; + } + return Object.assign(entry, { output: runtimeOutputByItemId.get(itemId) }); + }); +} + +function shouldIncludeWorkLogActivity(activity: OrchestrationThreadActivity): boolean { + if (activity.kind !== "tool.started") { + return true; + } + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + const itemType = extractWorkLogItemType(payload); + return itemType === "command_execution" || itemType === "file_change"; +} + function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { return false; @@ -519,6 +561,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (itemType) { entry.itemType = itemType; } + const itemId = extractWorkLogItemId(payload); + if (itemId) { + entry.itemId = itemId; + } if (requestKind) { entry.requestKind = requestKind; } @@ -526,6 +572,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (collapseKey) { entry.collapseKey = collapseKey; } + const groupKey = deriveToolLifecycleGroupKey(entry); + if (groupKey) { + entry.groupKey = groupKey; + } return entry; } @@ -533,31 +583,103 @@ function collapseDerivedWorkLogEntries( entries: ReadonlyArray, ): DerivedWorkLogEntry[] { const collapsed: DerivedWorkLogEntry[] = []; + const openIndicesByGroupKey = new Map>(); + for (const entry of entries) { - const previous = collapsed.at(-1); - if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { - collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); + const isLifecycleEntry = + entry.activityKind === "tool.started" || + entry.activityKind === "tool.updated" || + entry.activityKind === "tool.completed"; + const groupKey = entry.groupKey; + const openIndices = + isLifecycleEntry && groupKey ? openIndicesByGroupKey.get(groupKey) : undefined; + const openIndex = findOpenLifecycleIndex(collapsed, openIndices, entry); + const openEntry = openIndex !== undefined ? collapsed[openIndex] : undefined; + + if ( + openIndex !== undefined && + openEntry && + shouldCollapseToolLifecycleEntries(openEntry, entry) + ) { + collapsed[openIndex] = mergeDerivedWorkLogEntries(openEntry, entry); + if (entry.activityKind === "tool.completed") { + if (openIndices) { + const queueIndex = openIndices.indexOf(openIndex); + if (queueIndex >= 0) { + openIndices.splice(queueIndex, 1); + } + } + if (groupKey && (!openIndices || openIndices.length === 0)) { + openIndicesByGroupKey.delete(groupKey); + } + } continue; } - collapsed.push(entry); + + const nextIndex = collapsed.push(entry) - 1; + if (isLifecycleEntry && groupKey && entry.activityKind !== "tool.completed") { + const queue = openIndicesByGroupKey.get(groupKey) ?? []; + queue.push(nextIndex); + openIndicesByGroupKey.set(groupKey, queue); + } } return collapsed; } +function findOpenLifecycleIndex( + collapsed: ReadonlyArray, + openIndices: ReadonlyArray | undefined, + next: DerivedWorkLogEntry, +): number | undefined { + if (!openIndices || openIndices.length === 0) { + return undefined; + } + const matchingCollapseKey = next.collapseKey; + if (matchingCollapseKey) { + for (const index of openIndices) { + if (collapsed[index]?.collapseKey === matchingCollapseKey) { + return index; + } + } + } + const fallbackCandidates = openIndices.filter((index) => { + const previous = collapsed[index]; + return previous !== undefined && shouldCollapseToolLifecycleEntries(previous, next); + }); + return fallbackCandidates.length === 1 ? fallbackCandidates[0] : undefined; +} + function shouldCollapseToolLifecycleEntries( previous: DerivedWorkLogEntry, next: DerivedWorkLogEntry, ): boolean { - if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") { + if ( + previous.activityKind !== "tool.started" && + previous.activityKind !== "tool.updated" && + previous.activityKind !== "tool.completed" + ) { return false; } - if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") { + if ( + next.activityKind !== "tool.started" && + next.activityKind !== "tool.updated" && + next.activityKind !== "tool.completed" + ) { return false; } if (previous.activityKind === "tool.completed") { return false; } - return previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey; + if (previous.itemId && next.itemId && previous.itemId !== next.itemId) { + return false; + } + if (previous.collapseKey && next.collapseKey && previous.collapseKey === next.collapseKey) { + return true; + } + if (!previous.groupKey || previous.groupKey !== next.groupKey) { + return false; + } + return previous.collapseKey === previous.groupKey || next.collapseKey === next.groupKey; } function mergeDerivedWorkLogEntries( @@ -571,6 +693,8 @@ function mergeDerivedWorkLogEntries( const itemType = next.itemType ?? previous.itemType; const requestKind = next.requestKind ?? previous.requestKind; const collapseKey = next.collapseKey ?? previous.collapseKey; + const groupKey = next.groupKey ?? previous.groupKey; + const itemId = next.itemId ?? previous.itemId; return { ...previous, ...next, @@ -581,6 +705,8 @@ function mergeDerivedWorkLogEntries( ...(itemType ? { itemType } : {}), ...(requestKind ? { requestKind } : {}), ...(collapseKey ? { collapseKey } : {}), + ...(groupKey ? { groupKey } : {}), + ...(itemId ? { itemId } : {}), }; } @@ -596,20 +722,68 @@ function mergeChangedFiles( } function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined { - if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { + if ( + entry.activityKind !== "tool.started" && + entry.activityKind !== "tool.updated" && + entry.activityKind !== "tool.completed" + ) { return undefined; } + if (entry.itemId && entry.itemId.trim().length > 0) { + return entry.itemId; + } const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label); - const detail = entry.detail?.trim() ?? ""; const itemType = entry.itemType ?? ""; + if (itemType === "command_execution" || itemType === "file_change") { + const commandIdentity = deriveLifecycleCommandIdentity(entry); + if (commandIdentity) { + return [itemType, normalizedLabel, commandIdentity].join("\u001f"); + } + if (normalizedLabel.length === 0 && itemType.length === 0) { + return undefined; + } + return [itemType, normalizedLabel].join("\u001f"); + } + const detail = entry.detail?.trim() ?? ""; if (normalizedLabel.length === 0 && detail.length === 0 && itemType.length === 0) { return undefined; } return [itemType, normalizedLabel, detail].join("\u001f"); } +function deriveToolLifecycleGroupKey(entry: DerivedWorkLogEntry): string | undefined { + if ( + entry.activityKind !== "tool.started" && + entry.activityKind !== "tool.updated" && + entry.activityKind !== "tool.completed" + ) { + return undefined; + } + if (entry.itemId && entry.itemId.trim().length > 0) { + return entry.itemId; + } + const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label); + const itemType = entry.itemType ?? ""; + if (normalizedLabel.length === 0 && itemType.length === 0) { + return undefined; + } + return [itemType, normalizedLabel].join("\u001f"); +} + +function deriveLifecycleCommandIdentity(entry: DerivedWorkLogEntry): string | undefined { + const normalizedCommand = entry.command?.trim(); + if (normalizedCommand) { + return normalizedCommand; + } + const normalizedDetail = entry.detail?.trim(); + if (!normalizedDetail || /^$/i.test(normalizedDetail)) { + return undefined; + } + return normalizedDetail; +} + function normalizeCompactToolLabel(value: string): string { - return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); + return value.replace(/\s+(?:start|started|update|updated|complete|completed)\s*$/i, "").trim(); } function toLatestProposedPlanState(proposedPlan: ProposedPlan): LatestProposedPlanState { @@ -698,6 +872,10 @@ function extractWorkLogItemType( return undefined; } +function extractWorkLogItemId(payload: Record | null): string | undefined { + return typeof payload?.itemId === "string" ? payload.itemId : undefined; +} + function extractWorkLogRequestKind( payload: Record | null, ): WorkLogEntry["requestKind"] | undefined { diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index c9bcc7ffd2..5eb01f7a55 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -5,6 +5,8 @@ import { EventId, ProjectId, type OrchestrationEvent, + type ProviderRuntimeEvent, + RuntimeItemId, type ServerConfig, type ServerProvider, type TerminalEvent, @@ -31,6 +33,7 @@ function registerListener(listeners: Set<(event: T) => void>, listener: (even const terminalEventListeners = new Set<(event: TerminalEvent) => void>(); const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>(); +const runtimeToolOutputEventListeners = new Set<(event: ProviderRuntimeEvent) => void>(); const rpcClientMock = { dispose: vi.fn(), @@ -83,6 +86,9 @@ const rpcClientMock = { onDomainEvent: vi.fn((listener: (event: OrchestrationEvent) => void) => registerListener(orchestrationEventListeners, listener), ), + onRuntimeToolOutputEvent: vi.fn((listener: (event: ProviderRuntimeEvent) => void) => + registerListener(runtimeToolOutputEventListeners, listener), + ), }, }; @@ -168,6 +174,7 @@ beforeEach(() => { showContextMenuFallbackMock.mockReset(); terminalEventListeners.clear(); orchestrationEventListeners.clear(); + runtimeToolOutputEventListeners.clear(); Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); }); @@ -194,9 +201,11 @@ describe("wsNativeApi", () => { const api = createWsNativeApi(); const onTerminalEvent = vi.fn(); const onDomainEvent = vi.fn(); + const onRuntimeToolOutputEvent = vi.fn(); api.terminal.onEvent(onTerminalEvent); api.orchestration.onDomainEvent(onDomainEvent); + api.orchestration.onRuntimeToolOutputEvent(onRuntimeToolOutputEvent); const terminalEvent = { threadId: "thread-1", @@ -233,8 +242,23 @@ describe("wsNativeApi", () => { } satisfies Extract; emitEvent(orchestrationEventListeners, orchestrationEvent); + const runtimeToolOutputEvent = { + eventId: EventId.makeUnsafe("runtime-1"), + provider: "codex", + threadId: ThreadId.makeUnsafe("thread-1"), + itemId: RuntimeItemId.makeUnsafe("item-1"), + createdAt: "2026-02-24T00:00:00.000Z", + type: "content.delta", + payload: { + streamKind: "command_output", + delta: "hello\n", + }, + } as const satisfies Extract; + emitEvent(runtimeToolOutputEventListeners, runtimeToolOutputEvent); + expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent); expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent); + expect(onRuntimeToolOutputEvent).toHaveBeenCalledWith(runtimeToolOutputEvent); }); it("sends orchestration dispatch commands as the direct RPC payload", async () => { diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 31160dfa1c..cabcba4150 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -99,6 +99,8 @@ export function createWsNativeApi(): NativeApi { .replayEvents({ fromSequenceExclusive }) .then((events) => [...events]), onDomainEvent: (callback) => rpcClient.orchestration.onDomainEvent(callback), + onRuntimeToolOutputEvent: (callback) => + rpcClient.orchestration.onRuntimeToolOutputEvent(callback), }, }; diff --git a/apps/web/src/wsRpcClient.ts b/apps/web/src/wsRpcClient.ts index 60f51ba707..2a70869ada 100644 --- a/apps/web/src/wsRpcClient.ts +++ b/apps/web/src/wsRpcClient.ts @@ -92,6 +92,9 @@ export interface WsRpcClient { readonly getFullThreadDiff: RpcUnaryMethod; readonly replayEvents: RpcUnaryMethod; readonly onDomainEvent: RpcStreamMethod; + readonly onRuntimeToolOutputEvent: RpcStreamMethod< + typeof WS_METHODS.subscribeProviderRuntimeToolOutputEvents + >; }; } @@ -202,6 +205,11 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { (client) => client[WS_METHODS.subscribeOrchestrationDomainEvents]({}), listener, ), + onRuntimeToolOutputEvent: (listener) => + transport.subscribe( + (client) => client[WS_METHODS.subscribeProviderRuntimeToolOutputEvents]({}), + listener, + ), }, }; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 3114f6f5be..e6ae726dde 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -47,6 +47,7 @@ import type { OrchestrationEvent, OrchestrationReadModel, } from "./orchestration"; +import type { ProviderRuntimeEvent } from "./providerRuntime"; import { EditorId } from "./editor"; import { ServerSettings, ServerSettingsPatch } from "./settings"; @@ -181,5 +182,6 @@ export interface NativeApi { ) => Promise; replayEvents: (fromSequenceExclusive: number) => Promise; onDomainEvent: (callback: (event: OrchestrationEvent) => void) => () => void; + onRuntimeToolOutputEvent: (callback: (event: ProviderRuntimeEvent) => void) => () => void; }; } diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 34968e66ec..5deef9e584 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -41,6 +41,7 @@ import { OrchestrationReplayEventsInput, OrchestrationRpcSchemas, } from "./orchestration"; +import { ProviderRuntimeEvent } from "./providerRuntime"; import { ProjectSearchEntriesError, ProjectSearchEntriesInput, @@ -111,6 +112,7 @@ export const WS_METHODS = { // Streaming subscriptions subscribeOrchestrationDomainEvents: "subscribeOrchestrationDomainEvents", + subscribeProviderRuntimeToolOutputEvents: "subscribeProviderRuntimeToolOutputEvents", subscribeTerminalEvents: "subscribeTerminalEvents", subscribeServerConfig: "subscribeServerConfig", subscribeServerLifecycle: "subscribeServerLifecycle", @@ -302,6 +304,15 @@ export const WsSubscribeOrchestrationDomainEventsRpc = Rpc.make( }, ); +export const WsSubscribeProviderRuntimeToolOutputEventsRpc = Rpc.make( + WS_METHODS.subscribeProviderRuntimeToolOutputEvents, + { + payload: Schema.Struct({}), + success: ProviderRuntimeEvent, + stream: true, + }, +); + export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, { payload: Schema.Struct({}), success: TerminalEvent, @@ -348,6 +359,7 @@ export const WsRpcGroup = RpcGroup.make( WsTerminalRestartRpc, WsTerminalCloseRpc, WsSubscribeOrchestrationDomainEventsRpc, + WsSubscribeProviderRuntimeToolOutputEventsRpc, WsSubscribeTerminalEventsRpc, WsSubscribeServerConfigRpc, WsSubscribeServerLifecycleRpc,