authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 00:27:16-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 00:27:16-07:00
log61a91b6ef1bd45424169c6650362b358d49bbe34
tree041c3e9a41104f6010dec012fecbf8a6e92e925a
parent00f8b7c28056188e3c5630160806a0afe51c9010
signature Signed by PGP key B5690EEEBB952194

fix(web): group image views like other tool calls (#9597)

Co-authored-by: Claude Code <noreply@anthropic.com>

17 files changed, 239 insertions(+), 173 deletions(-)

apps/server/src/provider/CodexDeveloperInstructions.ts+2-6
......@@ -1,4 +1,5 @@
11import type { ProviderInteractionMode } from "@t3tools/contracts";
2import { buildRuntimeInstructions } from "./RuntimeInstructions.ts";
23
34const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = `
45
......@@ -175,11 +176,6 @@ export interface CodexRuntimeInfo {
175176 readonly reasoningEffort: string;
176177}
177178
178// Values come from trusted config, but keep the block single-line regardless.
179function toSingleLine(value: string): string {
180 return value.replaceAll(/\s+/g, " ").trim();
181}
182
183179export function buildCodexDeveloperInstructions(
184180 interactionMode: ProviderInteractionMode,
185181 runtime: CodexRuntimeInfo,
......@@ -196,5 +192,5 @@ export function buildCodexDeveloperInstructions(
196192 : codexDefaultModeDeveloperInstructions(browserToolsAvailable);
197193 return `${base}
198194
199<runtime_info>In case you're asked: you are running in T3 Code through the Codex harness, as ${toSingleLine(runtime.model)} with ${toSingleLine(runtime.reasoningEffort)} reasoning effort. No need to mention this otherwise.</runtime_info>`;
195${buildRuntimeInstructions({ harness: "Codex", ...runtime })}`;
200196}
apps/server/src/provider/Layers/AntigravityAdapter.test.ts+15-2
......@@ -49,6 +49,7 @@ const decodeRequestLog = Schema.decodeEffect(
4949
5050interface NativePrompt {
5151 readonly index: number;
52 readonly content: ReadonlyArray<AcpSchema.ContentBlock>;
5253 readonly result: Deferred.Deferred<AcpSchema.PromptResponse, AcpErrors.AcpError>;
5354}
5455
......@@ -179,12 +180,13 @@ const makeHarness = Effect.fn("makeAntigravityAdapterHarness")(function* (option
179180 }),
180181 getEvents: () => Stream.fromQueue(runtimeEvents),
181182 drainEvents,
182 prompt: (_payload, promptOptions) =>
183 prompt: (payload, promptOptions) =>
183184 Effect.gen(function* () {
184185 yield* Deferred.succeed(dispatchStarted, undefined);
185186 if (options?.holdDispatch) yield* Deferred.await(dispatchRelease);
186187 const prompt: NativePrompt = {
187188 index: ++promptIndex,
189 content: payload.prompt,
188190 result: yield* Deferred.make<AcpSchema.PromptResponse, AcpErrors.AcpError>(),
189191 };
190192 active = prompt;
......@@ -597,7 +599,11 @@ it.layer(layer)("AntigravityAdapter", (it) => {
597599 const first = yield* h.adapter
598600 .sendTurn({ threadId, input: "First prompt" })
599601 .pipe(Effect.forkChild);
600 yield* h.nextPrompt;
602 const initialPrompt = yield* h.nextPrompt;
603 expect(initialPrompt.content).toEqual([
604 { type: "text", text: "First prompt" },
605 { type: "text", text: expect.stringContaining(`Antigravity harness, as ${nativeDefault}`) },
606 ]);
601607 const marker = h.calls.length;
602608 const second = yield* h.adapter
603609 .sendTurn({
......@@ -615,6 +621,13 @@ it.layer(layer)("AntigravityAdapter", (it) => {
615621 });
616622 yield* Deferred.succeed(h.cancelRelease, undefined);
617623 const replacement = yield* h.nextPrompt;
624 expect(replacement.content).toEqual([
625 { type: "text", text: "Steer the turn" },
626 {
627 type: "text",
628 text: expect.stringContaining(`Antigravity harness, as ${nativeAlternative}`),
629 },
630 ]);
618631 expect(h.calls.slice(marker)).toEqual([
619632 "cancel:1",
620633 "drained:1",
apps/server/src/provider/Layers/AntigravityAdapter.ts+13-1
......@@ -36,6 +36,7 @@ import * as EffectAcpErrors from "effect-acp/errors";
3636import type * as EffectAcpSchema from "effect-acp/schema";
3737
3838import { ServerConfig } from "../../config.ts";
39import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
3940import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
4041import type { AntigravityAuth } from "../AntigravityAuth.ts";
4142import {
......@@ -1075,7 +1076,18 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi
10751076 };
10761077 const dispatched = yield* Deferred.make<void>();
10771078 const fiber = yield* context.runtime
1078 .prompt({ prompt }, { dispatched })
1079 .prompt(
1080 {
1081 prompt: [
1082 ...prompt,
1083 {
1084 type: "text",
1085 text: buildRuntimeInstructions({ harness: "Antigravity", model }),
1086 },
1087 ],
1088 },
1089 { dispatched },
1090 )
10791091 .pipe(Effect.forkIn(context.scope));
10801092 context.promptFiber = fiber;
10811093 // Fiber.join can skip a scope-close waiter when the child is interrupted.
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+6
......@@ -385,6 +385,12 @@ describe("ClaudeAdapterLive", () => {
385385
386386 const createInput = harness.getLastCreateQueryInput();
387387 assert.deepEqual(createInput?.options.settingSources, ["user", "project", "local"]);
388 assert.deepEqual(createInput?.options.systemPrompt, {
389 type: "preset",
390 preset: "claude_code",
391 append:
392 "<runtime_info>In case you're asked: you are running in T3 Code through the Claude Code harness. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.</runtime_info>",
393 });
388394 assert.equal(createInput?.options.permissionMode, "bypassPermissions");
389395 assert.equal(createInput?.options.allowDangerouslySkipPermissions, true);
390396 }).pipe(
apps/server/src/provider/Layers/ClaudeAdapter.ts+7-1
......@@ -83,6 +83,7 @@ import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
8383import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
8484import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts";
8585import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";
86import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
8687import {
8788 BUNDLED_CLAUDE_MODEL_CATALOG,
8889 type ClaudeModelCatalog,
......@@ -4370,7 +4371,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
43704371 ...(input.cwd ? { cwd: input.cwd } : {}),
43714372 ...(apiModelId ? { model: apiModelId } : {}),
43724373 pathToClaudeCodeExecutable: claudeBinaryPath,
4373 systemPrompt: { type: "preset", preset: "claude_code" },
4374 systemPrompt: {
4375 type: "preset",
4376 preset: "claude_code",
4377 // Model and effort can change after this session-level prompt is set.
4378 append: buildRuntimeInstructions({ harness: "Claude Code" }),
4379 },
43744380 settingSources: [...CLAUDE_SETTING_SOURCES],
43754381 // `ultracode` is a Claude Code setting, not an API effort level. It is
43764382 // normalized to `xhigh` above and paired with `settings.ultracode`.
apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+13
......@@ -465,6 +465,19 @@ describe("buildCodexDeveloperInstructions", () => {
465465 NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/);
466466 });
467467
468 it("describes Markdown media support in the runtime context in both modes", () => {
469 for (const mode of ["default", "plan"] as const) {
470 const instructions = buildCodexDeveloperInstructions(mode, {
471 model: "gpt-5.3-codex",
472 reasoningEffort: "high",
473 });
474 NodeAssert.match(
475 instructions,
476 /<runtime_info>.*embed images and videos.*Markdown.*<\/runtime_info>/,
477 );
478 }
479 });
480
468481 it("includes runtime info alongside plan mode instructions", () => {
469482 const instructions = buildCodexDeveloperInstructions("plan", {
470483 model: "gpt-5.3-codex",
apps/server/src/provider/Layers/CursorAdapter.test.ts+19-1
......@@ -26,6 +26,7 @@ import {
2626} from "@t3tools/contracts";
2727
2828import { ServerConfig } from "../../config.ts";
29import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
2930import { ServerSettingsService } from "../../serverSettings.ts";
3031import type { CursorAdapterShape } from "../Services/CursorAdapter.ts";
3132import { makeCursorAdapter } from "./CursorAdapter.ts";
......@@ -283,6 +284,18 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
283284 input: "please $review this",
284285 attachments: [],
285286 });
287 const snapshot = yield* adapter.readThread(threadId);
288 assert.deepStrictEqual(
289 snapshot.turns.map((turn) => turn.items),
290 [
291 [
292 {
293 prompt: [{ type: "text", text: "please /review this" }],
294 result: { stopReason: "end_turn" },
295 },
296 ],
297 ],
298 );
286299 yield* adapter.stopSession(threadId);
287300
288301 const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
......@@ -291,7 +304,12 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
291304 promptRequests.map(
292305 (request) => (request.params as Record<string, unknown> | undefined)?.prompt,
293306 ),
294 [[{ type: "text", text: "please /review this" }]],
307 [
308 [
309 { type: "text", text: "please /review this" },
310 { type: "text", text: buildRuntimeInstructions({ harness: "Cursor" }) },
311 ],
312 ],
295313 );
296314 }),
297315 );
apps/server/src/provider/Layers/CursorAdapter.ts+9-1
......@@ -42,6 +42,7 @@ import type * as EffectAcpSchema from "effect-acp/schema";
4242
4343import { resolveAttachmentPath } from "../../attachmentStore.ts";
4444import { ServerConfig } from "../../config.ts";
45import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
4546import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
4647import {
4748 ProviderAdapterProcessError,
......@@ -1043,9 +1044,16 @@ export function makeCursorAdapter(
10431044 });
10441045 }
10451046
1047 // ACP has no system-message field; keep runtime context separate from the user's text.
10461048 const result = yield* ctx.acp
10471049 .prompt({
1048 prompt: promptParts,
1050 prompt: [
1051 ...promptParts,
1052 {
1053 type: "text",
1054 text: buildRuntimeInstructions({ harness: "Cursor", model: resolvedModel }),
1055 },
1056 ],
10491057 })
10501058 .pipe(
10511059 Effect.mapError((error) =>
apps/server/src/provider/Layers/GrokAdapter.test.ts+62
......@@ -213,6 +213,68 @@ it("requires a settlement to match the live Grok turn", () => {
213213});
214214
215215it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
216 it.effect("sends runtime context with the current model without changing saved prompts", () =>
217 Effect.gen(function* () {
218 const threadId = ThreadId.make("grok-runtime-context");
219 const tempDir = yield* Effect.promise(() =>
220 NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-runtime-context-")),
221 );
222 const requestLogPath = NodePath.join(tempDir, "requests.ndjson");
223 const wrapperPath = yield* Effect.promise(() =>
224 makeMockGrokWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }),
225 );
226 const adapter = yield* makeTestAdapter(wrapperPath);
227 yield* adapter.startSession({
228 threadId,
229 cwd: process.cwd(),
230 runtimeMode: "full-access",
231 modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-mock-alt" },
232 });
233 yield* adapter.sendTurn({ threadId, input: "First prompt" });
234 yield* adapter.sendTurn({
235 threadId,
236 input: "Second prompt",
237 modelSelection: {
238 instanceId: ProviderInstanceId.make("grok"),
239 model: "grok-4.6",
240 options: [{ id: "reasoningEffort", value: "low" }],
241 },
242 });
243 const snapshot = yield* adapter.readThread(threadId);
244 assert.deepEqual(
245 snapshot.turns.map((turn) => turn.items),
246 [
247 [
248 {
249 prompt: [{ type: "text", text: "First prompt" }],
250 result: { stopReason: "end_turn" },
251 },
252 ],
253 [
254 {
255 prompt: [{ type: "text", text: "Second prompt" }],
256 result: { stopReason: "end_turn" },
257 },
258 ],
259 ],
260 );
261 yield* adapter.stopSession(threadId);
262 const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
263 const prompts = requests
264 .filter((request) => request.method === "session/prompt")
265 .map(
266 (request) => (request.params as { prompt: Array<{ type: string; text: string }> }).prompt,
267 );
268 assert.equal(prompts.length, 2);
269 assert.deepEqual(prompts[0]?.[0], { type: "text", text: "First prompt" });
270 assert.include(prompts[0]?.[1]?.text, "Grok harness, as grok-mock-alt");
271 assert.deepEqual(prompts[1]?.[0], { type: "text", text: "Second prompt" });
272 assert.include(prompts[1]?.[1]?.text, "Grok harness, as grok-4.6");
273 assert.include(prompts[1]?.[1]?.text, "with low reasoning effort");
274 assert.include(prompts[1]?.[1]?.text, "embed images and videos");
275 }),
276 );
277
216278 it.effect("starts a session and maps mock ACP prompt flow to runtime events", () =>
217279 Effect.gen(function* () {
218280 const threadId = ThreadId.make("grok-mock-thread");
apps/server/src/provider/Layers/GrokAdapter.ts+16-1
......@@ -40,6 +40,7 @@ import type * as EffectAcpSchema from "effect-acp/schema";
4040
4141import { resolveAttachmentPath } from "../../attachmentStore.ts";
4242import { ServerConfig } from "../../config.ts";
43import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
4344import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
4445import {
4546 ProviderAdapterProcessError,
......@@ -1583,6 +1584,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
15831584 const displayModel = currentModelId
15841585 ? resolveGrokAcpBaseModelId(currentModelId)
15851586 : undefined;
1587 const runtimeInstructions = buildRuntimeInstructions({
1588 harness: "Grok",
1589 model: displayModel,
1590 reasoningEffort: normalizeGrokReasoningEffort(requestedTurnReasoningEffort),
1591 });
15861592 for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) {
15871593 yield* Effect.yieldNow;
15881594 }
......@@ -1638,6 +1644,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
16381644 acpSessionId: ctx.acpSessionId,
16391645 displayModel,
16401646 promptParts,
1647 runtimeInstructions,
16411648 turnId,
16421649 promptEpoch,
16431650 promptLifecycle: ctx.promptLifecycle,
......@@ -1694,7 +1701,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
16941701 }
16951702 const dispatched = yield* Deferred.make<void>();
16961703 const fiber = yield* liveCtx.acp
1697 .prompt({ prompt: prepared.promptParts }, { dispatched })
1704 .prompt(
1705 {
1706 prompt: [
1707 ...prepared.promptParts,
1708 { type: "text", text: prepared.runtimeInstructions },
1709 ],
1710 },
1711 { dispatched },
1712 )
16981713 .pipe(Effect.forkChild({ startImmediately: true }));
16991714 // Hold the lifecycle permit until the runtime has registered this
17001715 // prompt's RPC fiber, so a later steer's session/cancel targets
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+9
......@@ -27,6 +27,7 @@ import {
2727import { createModelSelection } from "@t3tools/shared/model";
2828import { ServerConfig } from "../../config.ts";
2929import { ServerSettingsService } from "../../serverSettings.ts";
30import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
3031import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
3132import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
3233import {
......@@ -4958,6 +4959,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
49584959 },
49594960 agent: "github-copilot",
49604961 variant: "high",
4962 system: buildRuntimeInstructions({
4963 harness: "OpenCode",
4964 model: "anthropic/claude-sonnet-4-5",
4965 }),
49614966 parts: [{ type: "text", text: "Fix it" }],
49624967 });
49634968 }).pipe(Effect.provide(adapterLayer));
......@@ -5005,6 +5010,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
50055010 providerID: "anthropic",
50065011 modelID: "claude-sonnet-4-5",
50075012 },
5013 system: buildRuntimeInstructions({
5014 harness: "OpenCode",
5015 model: "anthropic/claude-sonnet-4-5",
5016 }),
50085017 parts: [{ type: "text", text: "Fix it" }],
50095018 });
50105019 }).pipe(Effect.provide(adapterLayer));
apps/server/src/provider/Layers/OpenCodeAdapter.ts+6
......@@ -41,6 +41,7 @@ import {
4141 ProviderAdapterSessionNotFoundError,
4242 ProviderAdapterValidationError,
4343} from "../Errors.ts";
44import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
4445import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
4546import {
4647 buildOpenCodePermissionRules,
......@@ -2856,6 +2857,11 @@ export function makeOpenCodeAdapter(
28562857 model: parsedModel,
28572858 ...(context.activeAgent ? { agent: context.activeAgent } : {}),
28582859 ...(context.activeVariant ? { variant: context.activeVariant } : {}),
2860 // OpenCode appends this after its own agent/provider prompts.
2861 system: buildRuntimeInstructions({
2862 harness: "OpenCode",
2863 model: `${parsedModel.providerID}/${parsedModel.modelID}`,
2864 }),
28592865 parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts],
28602866 },
28612867 { signal },
apps/server/src/provider/RuntimeInstructions.test.ts created+31
......@@ -0,0 +1,31 @@
1import { describe, expect, it } from "vite-plus/test";
2import { buildRuntimeInstructions } from "./RuntimeInstructions.ts";
3
4describe("buildRuntimeInstructions", () => {
5 it.each(["Codex", "Claude Code", "Cursor", "Grok", "OpenCode", "Antigravity"])(
6 "identifies the %s harness and describes media embedding",
7 (harness) => {
8 const instructions = buildRuntimeInstructions({ harness });
9 expect(instructions).toContain(`running in T3 Code through the ${harness} harness.`);
10 expect(instructions).toContain("embed images and videos");
11 expect(instructions).toContain("Markdown with absolute file paths");
12 expect(instructions).not.toContain("undefined");
13 },
14 );
15
16 it("keeps known model and effort metadata on one line", () => {
17 expect(
18 buildRuntimeInstructions({
19 harness: "Codex",
20 model: " custom\nmodel ",
21 reasoningEffort: " high\n",
22 }),
23 ).toContain("through the Codex harness, as custom model with high reasoning effort.");
24 });
25
26 it.each([undefined, "", "auto", "default"])("omits unresolved model %s", (model) => {
27 const instructions = buildRuntimeInstructions({ harness: "Cursor", model });
28 expect(instructions).toContain("through the Cursor harness.");
29 expect(instructions).not.toContain("reasoning effort");
30 });
31});
apps/server/src/provider/RuntimeInstructions.ts created+17
......@@ -0,0 +1,17 @@
1/** Shared runtime context; omit model and effort when the harness manages them dynamically. */
2export function buildRuntimeInstructions(runtime: {
3 readonly harness: string;
4 readonly model?: string | undefined;
5 readonly reasoningEffort?: string | undefined;
6}): string {
7 const harness = toSingleLine(runtime.harness);
8 const model = toSingleLine(runtime.model ?? "");
9 const effort = toSingleLine(runtime.reasoningEffort ?? "");
10 const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${model}` : "";
11 const effortInfo = effort ? ` with ${effort} reasoning effort` : "";
12 return `<runtime_info>In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.</runtime_info>`;
13}
14
15function toSingleLine(value: string): string {
16 return value.replaceAll(/\s+/g, " ").trim();
17}
apps/web/src/components/chat/MessagesTimeline.logic.test.ts-136
......@@ -12,7 +12,6 @@ import {
1212 shouldPreserveAssistantLineBreaks,
1313 type MessagesTimelineRow,
1414 workEntryDisplayLabel,
15 workEntryIsVisibleInGroup,
1615} from "./MessagesTimeline.logic";
1716
1817describe("expanded tool group scrolling", () => {
......@@ -472,22 +471,6 @@ describe("resolveAssistantMessageCopyState", () => {
472471 });
473472});
474473
475describe("workEntryIsVisibleInGroup", () => {
476 it("keeps an in-progress image preview row visible outside a group", () => {
477 const entry = {
478 id: "work-image",
479 createdAt: "2026-01-01T00:00:04Z",
480 turnId: "turn-1" as never,
481 label: "Image view",
482 detail: "screenshots/result.png",
483 itemType: "image_view" as const,
484 tone: "tool" as const,
485 toolLifecycleStatus: "inProgress" as const,
486 };
487 expect(workEntryIsVisibleInGroup(entry, false)).toBe(true);
488 });
489});
490
491474describe("deriveMessagesTimelineRows", () => {
492475 it("keeps context compaction visible outside folded work", () => {
493476 const rows = deriveMessagesTimelineRows({
......@@ -883,125 +866,6 @@ describe("deriveMessagesTimelineRows", () => {
883866 });
884867 });
885868
886 it("keeps an image preview row visible after the turn settles", () => {
887 const timelineEntries = [
888 {
889 id: "work-command-entry",
890 kind: "work" as const,
891 createdAt: "2026-01-01T00:00:02Z",
892 entry: {
893 id: "work-command",
894 createdAt: "2026-01-01T00:00:02Z",
895 turnId: "turn-1" as never,
896 label: "Ran command",
897 tone: "tool" as const,
898 },
899 },
900 {
901 id: "work-image-entry",
902 kind: "work" as const,
903 createdAt: "2026-01-01T00:00:04Z",
904 entry: {
905 id: "work-image",
906 createdAt: "2026-01-01T00:00:04Z",
907 turnId: "turn-1" as never,
908 label: "Image view",
909 detail: "screenshots/result.png",
910 itemType: "image_view" as const,
911 tone: "tool" as const,
912 },
913 },
914 {
915 id: "assistant-final-entry",
916 kind: "message" as const,
917 createdAt: "2026-01-01T00:00:06Z",
918 message: {
919 id: "assistant-final" as never,
920 role: "assistant" as const,
921 text: "Here is the screenshot.",
922 turnId: "turn-1" as never,
923 createdAt: "2026-01-01T00:00:06Z",
924 updatedAt: "2026-01-01T00:00:07Z",
925 streaming: false,
926 },
927 },
928 ];
929
930 const rows = deriveMessagesTimelineRows({
931 timelineEntries,
932 isWorking: false,
933 activeTurnStartedAt: null,
934 turnDiffSummaryByAssistantMessageId: new Map(),
935 revertTurnCountByUserMessageId: new Map(),
936 });
937
938 // The command row folds, the image row stays as its own visible row rather
939 // than collapsing into a tool group summary.
940 expect(rows.map((row) => row.id)).toEqual([
941 "turn-fold:turn-1",
942 "work-image-entry",
943 "assistant-final-entry",
944 ]);
945 expect(rows.find((row) => row.id === "work-image-entry")?.kind).toBe("work");
946 });
947
948 it("keeps a trailing image preview row visible while the turn is still working", () => {
949 const rows = deriveMessagesTimelineRows({
950 timelineEntries: [
951 {
952 id: "user-entry",
953 kind: "message" as const,
954 createdAt: "2026-01-01T00:00:00Z",
955 message: {
956 id: "user-1" as never,
957 role: "user" as const,
958 text: "show me the result",
959 turnId: null,
960 createdAt: "2026-01-01T00:00:00Z",
961 updatedAt: "2026-01-01T00:00:00Z",
962 streaming: false,
963 },
964 },
965 {
966 id: "work-command-entry",
967 kind: "work" as const,
968 createdAt: "2026-01-01T00:00:02Z",
969 entry: {
970 id: "work-command",
971 createdAt: "2026-01-01T00:00:02Z",
972 turnId: "turn-1" as never,
973 label: "Ran command",
974 tone: "tool" as const,
975 },
976 },
977 {
978 id: "work-image-entry",
979 kind: "work" as const,
980 createdAt: "2026-01-01T00:00:04Z",
981 entry: {
982 id: "work-image",
983 createdAt: "2026-01-01T00:00:04Z",
984 turnId: "turn-1" as never,
985 label: "Image view",
986 detail: "screenshots/result.png",
987 itemType: "image_view" as const,
988 tone: "tool" as const,
989 },
990 },
991 ],
992 isWorking: true,
993 activeTurnStartedAt: "2026-01-01T00:00:01Z",
994 turnDiffSummaryByAssistantMessageId: new Map(),
995 revertTurnCountByUserMessageId: new Map(),
996 });
997
998 // The live activity row must not swallow the image: it renders only a
999 // label, which would hide the image until the turn settles.
1000 const imageRow = rows.find((row) => row.id === "work-image-entry");
1001 expect(imageRow?.kind).toBe("work");
1002 expect(rows.some((row) => row.kind === "work-live")).toBe(false);
1003 });
1004
1005869 it("folds all assistant messages before the terminal message", () => {
1006870 const timelineEntries = [
1007871 {
apps/web/src/components/chat/MessagesTimeline.logic.ts+2-24
......@@ -9,7 +9,6 @@ import {
99 summarizeToolGroup,
1010 toolGroupAction,
1111 toolGroupSummaryKind,
12 workEntryViewedImagePath,
1312 type ToolGroupSummaryKind,
1413} from "@t3tools/client-runtime/work-log/presentation";
1514export {
......@@ -90,15 +89,6 @@ export function liveWorkEntryLabel(
9089 return workEntryDisplayLabel(entry, workspaceRoot);
9190}
9291
93/**
94 * Rows that preview an image the agent viewed or produced. They stay out of
95 * tool groups and out of the settled-turn fold: the image is the answer the
96 * user asked for, not tool noise to hide behind "Worked for ...".
97 */
98function workEntryRendersImagePreview(entry: WorkLogEntry): boolean {
99 return workEntryViewedImagePath(entry) !== null;
100}
101
10292export function workEntryIsVisibleInGroup(
10393 entry: WorkLogEntry,
10494 expandedToolGroupEntry = false,
......@@ -107,9 +97,6 @@ export function workEntryIsVisibleInGroup(
10797 (expandedToolGroupEntry &&
10898 (entry.toolLifecycleStatus === "inProgress" ||
10999 entry.sourceActivityKind === "task.progress")) ||
110 // An image row stands alone outside any group, so the neutral filter
111 // would leave an empty gap while its tool is still in progress.
112 workEntryRendersImagePreview(entry) ||
113100 !workEntryIndicatesToolNeutralStatus(entry)
114101 );
115102}
......@@ -645,9 +632,6 @@ function deriveTurnFolds(input: {
645632 ) {
646633 continue;
647634 }
648 if (entry.kind === "work" && workEntryRendersImagePreview(entry.entry)) {
649 continue;
650 }
651635 hiddenEntryIds.add(entry.id);
652636 }
653637 if (hiddenEntryIds.size === 0) {
......@@ -842,8 +826,7 @@ export function deriveMessagesTimelineRows(input: {
842826 entry.kind !== "work" ||
843827 entry.entry.agentSpawn !== undefined ||
844828 entry.entry.sourceActivityKind === "context-compaction" ||
845 entry.entry.tone === "error" ||
846 workEntryRendersImagePreview(entry.entry)
829 entry.entry.tone === "error"
847830 ) {
848831 break;
849832 }
......@@ -966,11 +949,7 @@ export function deriveMessagesTimelineRows(input: {
966949 }
967950
968951 if (timelineEntry.kind === "work") {
969 if (
970 timelineEntry.entry.agentSpawn !== undefined ||
971 timelineEntry.entry.tone === "error" ||
972 workEntryRendersImagePreview(timelineEntry.entry)
973 ) {
952 if (timelineEntry.entry.agentSpawn !== undefined || timelineEntry.entry.tone === "error") {
974953 nextRows.push({
975954 kind: "work",
976955 id: timelineEntry.id,
......@@ -990,7 +969,6 @@ export function deriveMessagesTimelineRows(input: {
990969 nextEntry.entry.agentSpawn !== undefined ||
991970 nextEntry.entry.sourceActivityKind === "context-compaction" ||
992971 nextEntry.entry.tone === "error" ||
993 workEntryRendersImagePreview(nextEntry.entry) ||
994972 activeWorkEntryIds.has(nextEntry.id) ||
995973 collapsedEntryIds.has(nextEntry.id) ||
996974 foldsByAnchorEntryId.has(nextEntry.id)
docs/internals/providers.md+12
......@@ -24,6 +24,18 @@ adapter in a child scope. Adapter implementations live beside them in
2424[`ProviderAdapter.ts`][adapter]. Read the driver plus its adapter to see how a specific agent's
2525transport, config, and event shapes are mapped.
2626
27## Runtime context
28
29Every adapter uses `apps/server/src/provider/RuntimeInstructions.ts` to identify T3 Code and
30the harness, and describe Markdown image/video embeds. Codex includes it in developer
31instructions; Claude appends it to its system preset; OpenCode sends it in each prompt's
32`system` field. Cursor, Grok, and Antigravity append a separate text block to ACP prompts,
33which have no system-message field. This does not change the stored user message.
34
35Per-turn context includes the current model when known. Codex includes reasoning effort;
36Grok includes it when explicitly selected for the turn. Claude's session-level context omits model and effort because they can
37change during a session. OpenCode variants are not assumed to be reasoning-effort levels.
38
2739## Codex async questions
2840
2941Codex 0.153 exposes `request_user_input_async` through `item/started` and `item/completed`