Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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"],
},
},
},
});

Expand All @@ -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<string, unknown>)
: 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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
Expand All @@ -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,
Expand All @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 }))),
Expand Down
19 changes: 16 additions & 3 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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],
Expand Down
53 changes: 53 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MessagesTimeline
hasMessages
isWorking={false}
activeTurnInProgress={false}
activeTurnStartedAt={null}
scrollContainer={null}
timelineEntries={[
{
id: "entry-1",
kind: "work",
createdAt: "2026-03-17T19:12:28.000Z",
entry: {
id: "work-1",
createdAt: "2026-03-17T19:12:28.000Z",
label: "Ran command completed",
tone: "tool",
command: "bun fmt",
output:
"apps/web/src/session-logic.ts\napps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts\n",
toolTitle: "Ran command",
itemType: "command_execution",
},
},
]}
completionDividerBeforeEntryId={null}
completionSummary={null}
turnDiffSummaryByAssistantMessageId={new Map()}
nowIso="2026-03-17T19:12:30.000Z"
expandedWorkGroups={{}}
onToggleWorkGroup={() => {}}
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",
);
});
});
18 changes: 18 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -863,13 +863,15 @@ 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);
const preview = workEntryPreview(workEntry);
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 (
<div className="rounded-lg px-1 py-1">
Expand All @@ -895,6 +897,22 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
</p>
</div>
</div>
{hasOutput && (
<div className="mt-1 pl-6">
<button
type="button"
className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65 transition-colors duration-150 hover:text-foreground/75"
onClick={() => setOutputExpanded((current) => !current)}
>
{outputExpanded ? "Hide output" : "Show output"}
</button>
{outputExpanded && (
<pre className="mt-1 max-h-56 overflow-auto rounded-md border border-border/55 bg-background/75 px-2 py-1 font-mono text-[10px] leading-relaxed whitespace-pre-wrap break-words text-foreground/80">
{workEntry.output}
</pre>
)}
</div>
)}
{hasChangedFiles && !previewIsChangedFiles && (
<div className="mt-1 flex flex-wrap gap-1 pl-6">
{workEntry.changedFiles?.slice(0, 4).map((filePath) => (
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/orchestrationEventEffects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand All @@ -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);
});
Expand Down Expand Up @@ -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);
});
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/orchestrationEventEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts";
export interface OrchestrationBatchEffects {
clearPromotedDraftThreadIds: ThreadId[];
clearDeletedThreadIds: ThreadId[];
clearRuntimeToolOutputThreadIds: ThreadId[];
removeTerminalStateThreadIds: ThreadId[];
needsProviderInvalidation: boolean;
}
Expand All @@ -15,6 +16,7 @@ export function deriveOrchestrationBatchEffects(
{
clearPromotedDraft: boolean;
clearDeletedThread: boolean;
clearRuntimeToolOutput: boolean;
removeTerminalState: boolean;
}
>();
Expand All @@ -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;
Expand All @@ -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) {
Expand All @@ -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);
}
Expand All @@ -70,6 +89,7 @@ export function deriveOrchestrationBatchEffects(
return {
clearPromotedDraftThreadIds,
clearDeletedThreadIds,
clearRuntimeToolOutputThreadIds,
removeTerminalStateThreadIds,
needsProviderInvalidation,
};
Expand Down
Loading
Loading