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 @@...@@ -1,4 +1,5 @@
1import type { ProviderInteractionMode } from "@t3tools/contracts";1import type { ProviderInteractionMode } from "@t3tools/contracts";
2import { buildRuntimeInstructions } from "./RuntimeInstructions.ts";
23
3const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = `4const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = `
45
...@@ -175,11 +176,6 @@ export interface CodexRuntimeInfo {...@@ -175,11 +176,6 @@ export interface CodexRuntimeInfo {
175 readonly reasoningEffort: string;176 readonly reasoningEffort: string;
176}177}
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
183export function buildCodexDeveloperInstructions(179export function buildCodexDeveloperInstructions(
184 interactionMode: ProviderInteractionMode,180 interactionMode: ProviderInteractionMode,
185 runtime: CodexRuntimeInfo,181 runtime: CodexRuntimeInfo,
...@@ -196,5 +192,5 @@ export function buildCodexDeveloperInstructions(...@@ -196,5 +192,5 @@ export function buildCodexDeveloperInstructions(
196 : codexDefaultModeDeveloperInstructions(browserToolsAvailable);192 : codexDefaultModeDeveloperInstructions(browserToolsAvailable);
197 return `${base}193 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 })}`;
200}196}
apps/server/src/provider/Layers/AntigravityAdapter.test.ts+15-2
...@@ -49,6 +49,7 @@ const decodeRequestLog = Schema.decodeEffect(...@@ -49,6 +49,7 @@ const decodeRequestLog = Schema.decodeEffect(
4949
50interface NativePrompt {50interface NativePrompt {
51 readonly index: number;51 readonly index: number;
52 readonly content: ReadonlyArray<AcpSchema.ContentBlock>;
52 readonly result: Deferred.Deferred<AcpSchema.PromptResponse, AcpErrors.AcpError>;53 readonly result: Deferred.Deferred<AcpSchema.PromptResponse, AcpErrors.AcpError>;
53}54}
5455
...@@ -179,12 +180,13 @@ const makeHarness = Effect.fn("makeAntigravityAdapterHarness")(function* (option...@@ -179,12 +180,13 @@ const makeHarness = Effect.fn("makeAntigravityAdapterHarness")(function* (option
179 }),180 }),
180 getEvents: () => Stream.fromQueue(runtimeEvents),181 getEvents: () => Stream.fromQueue(runtimeEvents),
181 drainEvents,182 drainEvents,
182 prompt: (_payload, promptOptions) =>183 prompt: (payload, promptOptions) =>
183 Effect.gen(function* () {184 Effect.gen(function* () {
184 yield* Deferred.succeed(dispatchStarted, undefined);185 yield* Deferred.succeed(dispatchStarted, undefined);
185 if (options?.holdDispatch) yield* Deferred.await(dispatchRelease);186 if (options?.holdDispatch) yield* Deferred.await(dispatchRelease);
186 const prompt: NativePrompt = {187 const prompt: NativePrompt = {
187 index: ++promptIndex,188 index: ++promptIndex,
189 content: payload.prompt,
188 result: yield* Deferred.make<AcpSchema.PromptResponse, AcpErrors.AcpError>(),190 result: yield* Deferred.make<AcpSchema.PromptResponse, AcpErrors.AcpError>(),
189 };191 };
190 active = prompt;192 active = prompt;
...@@ -597,7 +599,11 @@ it.layer(layer)("AntigravityAdapter", (it) => {...@@ -597,7 +599,11 @@ it.layer(layer)("AntigravityAdapter", (it) => {
597 const first = yield* h.adapter599 const first = yield* h.adapter
598 .sendTurn({ threadId, input: "First prompt" })600 .sendTurn({ threadId, input: "First prompt" })
599 .pipe(Effect.forkChild);601 .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 ]);
601 const marker = h.calls.length;607 const marker = h.calls.length;
602 const second = yield* h.adapter608 const second = yield* h.adapter
603 .sendTurn({609 .sendTurn({
...@@ -615,6 +621,13 @@ it.layer(layer)("AntigravityAdapter", (it) => {...@@ -615,6 +621,13 @@ it.layer(layer)("AntigravityAdapter", (it) => {
615 });621 });
616 yield* Deferred.succeed(h.cancelRelease, undefined);622 yield* Deferred.succeed(h.cancelRelease, undefined);
617 const replacement = yield* h.nextPrompt;623 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 ]);
618 expect(h.calls.slice(marker)).toEqual([631 expect(h.calls.slice(marker)).toEqual([
619 "cancel:1",632 "cancel:1",
620 "drained:1",633 "drained:1",
apps/server/src/provider/Layers/AntigravityAdapter.ts+13-1
...@@ -36,6 +36,7 @@ import * as EffectAcpErrors from "effect-acp/errors";...@@ -36,6 +36,7 @@ import * as EffectAcpErrors from "effect-acp/errors";
36import type * as EffectAcpSchema from "effect-acp/schema";36import type * as EffectAcpSchema from "effect-acp/schema";
3737
38import { ServerConfig } from "../../config.ts";38import { ServerConfig } from "../../config.ts";
39import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
39import * as McpProviderSession from "../../mcp/McpProviderSession.ts";40import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
40import type { AntigravityAuth } from "../AntigravityAuth.ts";41import type { AntigravityAuth } from "../AntigravityAuth.ts";
41import {42import {
...@@ -1075,7 +1076,18 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi...@@ -1075,7 +1076,18 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi
1075 };1076 };
1076 const dispatched = yield* Deferred.make<void>();1077 const dispatched = yield* Deferred.make<void>();
1077 const fiber = yield* context.runtime1078 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 )
1079 .pipe(Effect.forkIn(context.scope));1091 .pipe(Effect.forkIn(context.scope));
1080 context.promptFiber = fiber;1092 context.promptFiber = fiber;
1081 // Fiber.join can skip a scope-close waiter when the child is interrupted.1093 // 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", () => {...@@ -385,6 +385,12 @@ describe("ClaudeAdapterLive", () => {
385385
386 const createInput = harness.getLastCreateQueryInput();386 const createInput = harness.getLastCreateQueryInput();
387 assert.deepEqual(createInput?.options.settingSources, ["user", "project", "local"]);387 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 });
388 assert.equal(createInput?.options.permissionMode, "bypassPermissions");394 assert.equal(createInput?.options.permissionMode, "bypassPermissions");
389 assert.equal(createInput?.options.allowDangerouslySkipPermissions, true);395 assert.equal(createInput?.options.allowDangerouslySkipPermissions, true);
390 }).pipe(396 }).pipe(
apps/server/src/provider/Layers/ClaudeAdapter.ts+7-1
...@@ -83,6 +83,7 @@ import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";...@@ -83,6 +83,7 @@ import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
83import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";83import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
84import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts";84import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts";
85import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";85import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";
86import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
86import {87import {
87 BUNDLED_CLAUDE_MODEL_CATALOG,88 BUNDLED_CLAUDE_MODEL_CATALOG,
88 type ClaudeModelCatalog,89 type ClaudeModelCatalog,
...@@ -4370,7 +4371,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4370,7 +4371,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4370 ...(input.cwd ? { cwd: input.cwd } : {}),4371 ...(input.cwd ? { cwd: input.cwd } : {}),
4371 ...(apiModelId ? { model: apiModelId } : {}),4372 ...(apiModelId ? { model: apiModelId } : {}),
4372 pathToClaudeCodeExecutable: claudeBinaryPath,4373 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 },
4374 settingSources: [...CLAUDE_SETTING_SOURCES],4380 settingSources: [...CLAUDE_SETTING_SOURCES],
4375 // `ultracode` is a Claude Code setting, not an API effort level. It is4381 // `ultracode` is a Claude Code setting, not an API effort level. It is
4376 // normalized to `xhigh` above and paired with `settings.ultracode`.4382 // normalized to `xhigh` above and paired with `settings.ultracode`.
apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+13
...@@ -465,6 +465,19 @@ describe("buildCodexDeveloperInstructions", () => {...@@ -465,6 +465,19 @@ describe("buildCodexDeveloperInstructions", () => {
465 NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/);465 NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/);
466 });466 });
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
468 it("includes runtime info alongside plan mode instructions", () => {481 it("includes runtime info alongside plan mode instructions", () => {
469 const instructions = buildCodexDeveloperInstructions("plan", {482 const instructions = buildCodexDeveloperInstructions("plan", {
470 model: "gpt-5.3-codex",483 model: "gpt-5.3-codex",
apps/server/src/provider/Layers/CursorAdapter.test.ts+19-1
...@@ -26,6 +26,7 @@ import {...@@ -26,6 +26,7 @@ import {
26} from "@t3tools/contracts";26} from "@t3tools/contracts";
2727
28import { ServerConfig } from "../../config.ts";28import { ServerConfig } from "../../config.ts";
29import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
29import { ServerSettingsService } from "../../serverSettings.ts";30import { ServerSettingsService } from "../../serverSettings.ts";
30import type { CursorAdapterShape } from "../Services/CursorAdapter.ts";31import type { CursorAdapterShape } from "../Services/CursorAdapter.ts";
31import { makeCursorAdapter } from "./CursorAdapter.ts";32import { makeCursorAdapter } from "./CursorAdapter.ts";
...@@ -283,6 +284,18 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {...@@ -283,6 +284,18 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
283 input: "please $review this",284 input: "please $review this",
284 attachments: [],285 attachments: [],
285 });286 });
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 );
286 yield* adapter.stopSession(threadId);299 yield* adapter.stopSession(threadId);
287300
288 const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));301 const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
...@@ -291,7 +304,12 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {...@@ -291,7 +304,12 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
291 promptRequests.map(304 promptRequests.map(
292 (request) => (request.params as Record<string, unknown> | undefined)?.prompt,305 (request) => (request.params as Record<string, unknown> | undefined)?.prompt,
293 ),306 ),
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 ],
295 );313 );
296 }),314 }),
297 );315 );
apps/server/src/provider/Layers/CursorAdapter.ts+9-1
...@@ -42,6 +42,7 @@ import type * as EffectAcpSchema from "effect-acp/schema";...@@ -42,6 +42,7 @@ import type * as EffectAcpSchema from "effect-acp/schema";
4242
43import { resolveAttachmentPath } from "../../attachmentStore.ts";43import { resolveAttachmentPath } from "../../attachmentStore.ts";
44import { ServerConfig } from "../../config.ts";44import { ServerConfig } from "../../config.ts";
45import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
45import * as McpProviderSession from "../../mcp/McpProviderSession.ts";46import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
46import {47import {
47 ProviderAdapterProcessError,48 ProviderAdapterProcessError,
...@@ -1043,9 +1044,16 @@ export function makeCursorAdapter(...@@ -1043,9 +1044,16 @@ export function makeCursorAdapter(
1043 });1044 });
1044 }1045 }
10451046
1047 // ACP has no system-message field; keep runtime context separate from the user's text.
1046 const result = yield* ctx.acp1048 const result = yield* ctx.acp
1047 .prompt({1049 .prompt({
1048 prompt: promptParts,1050 prompt: [
1051 ...promptParts,
1052 {
1053 type: "text",
1054 text: buildRuntimeInstructions({ harness: "Cursor", model: resolvedModel }),
1055 },
1056 ],
1049 })1057 })
1050 .pipe(1058 .pipe(
1051 Effect.mapError((error) =>1059 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", () => {...@@ -213,6 +213,68 @@ it("requires a settlement to match the live Grok turn", () => {
213});213});
214214
215it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {215it.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
216 it.effect("starts a session and maps mock ACP prompt flow to runtime events", () =>278 it.effect("starts a session and maps mock ACP prompt flow to runtime events", () =>
217 Effect.gen(function* () {279 Effect.gen(function* () {
218 const threadId = ThreadId.make("grok-mock-thread");280 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";...@@ -40,6 +40,7 @@ import type * as EffectAcpSchema from "effect-acp/schema";
4040
41import { resolveAttachmentPath } from "../../attachmentStore.ts";41import { resolveAttachmentPath } from "../../attachmentStore.ts";
42import { ServerConfig } from "../../config.ts";42import { ServerConfig } from "../../config.ts";
43import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
43import * as McpProviderSession from "../../mcp/McpProviderSession.ts";44import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
44import {45import {
45 ProviderAdapterProcessError,46 ProviderAdapterProcessError,
...@@ -1583,6 +1584,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -1583,6 +1584,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
1583 const displayModel = currentModelId1584 const displayModel = currentModelId
1584 ? resolveGrokAcpBaseModelId(currentModelId)1585 ? resolveGrokAcpBaseModelId(currentModelId)
1585 : undefined;1586 : undefined;
1587 const runtimeInstructions = buildRuntimeInstructions({
1588 harness: "Grok",
1589 model: displayModel,
1590 reasoningEffort: normalizeGrokReasoningEffort(requestedTurnReasoningEffort),
1591 });
1586 for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) {1592 for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) {
1587 yield* Effect.yieldNow;1593 yield* Effect.yieldNow;
1588 }1594 }
...@@ -1638,6 +1644,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -1638,6 +1644,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
1638 acpSessionId: ctx.acpSessionId,1644 acpSessionId: ctx.acpSessionId,
1639 displayModel,1645 displayModel,
1640 promptParts,1646 promptParts,
1647 runtimeInstructions,
1641 turnId,1648 turnId,
1642 promptEpoch,1649 promptEpoch,
1643 promptLifecycle: ctx.promptLifecycle,1650 promptLifecycle: ctx.promptLifecycle,
...@@ -1694,7 +1701,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -1694,7 +1701,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
1694 }1701 }
1695 const dispatched = yield* Deferred.make<void>();1702 const dispatched = yield* Deferred.make<void>();
1696 const fiber = yield* liveCtx.acp1703 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 )
1698 .pipe(Effect.forkChild({ startImmediately: true }));1713 .pipe(Effect.forkChild({ startImmediately: true }));
1699 // Hold the lifecycle permit until the runtime has registered this1714 // Hold the lifecycle permit until the runtime has registered this
1700 // prompt's RPC fiber, so a later steer's session/cancel targets1715 // 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 {...@@ -27,6 +27,7 @@ import {
27import { createModelSelection } from "@t3tools/shared/model";27import { createModelSelection } from "@t3tools/shared/model";
28import { ServerConfig } from "../../config.ts";28import { ServerConfig } from "../../config.ts";
29import { ServerSettingsService } from "../../serverSettings.ts";29import { ServerSettingsService } from "../../serverSettings.ts";
30import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
30import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";31import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
31import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";32import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
32import {33import {
...@@ -4958,6 +4959,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -4958,6 +4959,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
4958 },4959 },
4959 agent: "github-copilot",4960 agent: "github-copilot",
4960 variant: "high",4961 variant: "high",
4962 system: buildRuntimeInstructions({
4963 harness: "OpenCode",
4964 model: "anthropic/claude-sonnet-4-5",
4965 }),
4961 parts: [{ type: "text", text: "Fix it" }],4966 parts: [{ type: "text", text: "Fix it" }],
4962 });4967 });
4963 }).pipe(Effect.provide(adapterLayer));4968 }).pipe(Effect.provide(adapterLayer));
...@@ -5005,6 +5010,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -5005,6 +5010,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
5005 providerID: "anthropic",5010 providerID: "anthropic",
5006 modelID: "claude-sonnet-4-5",5011 modelID: "claude-sonnet-4-5",
5007 },5012 },
5013 system: buildRuntimeInstructions({
5014 harness: "OpenCode",
5015 model: "anthropic/claude-sonnet-4-5",
5016 }),
5008 parts: [{ type: "text", text: "Fix it" }],5017 parts: [{ type: "text", text: "Fix it" }],
5009 });5018 });
5010 }).pipe(Effect.provide(adapterLayer));5019 }).pipe(Effect.provide(adapterLayer));
apps/server/src/provider/Layers/OpenCodeAdapter.ts+6
...@@ -41,6 +41,7 @@ import {...@@ -41,6 +41,7 @@ import {
41 ProviderAdapterSessionNotFoundError,41 ProviderAdapterSessionNotFoundError,
42 ProviderAdapterValidationError,42 ProviderAdapterValidationError,
43} from "../Errors.ts";43} from "../Errors.ts";
44import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
44import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";45import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
45import {46import {
46 buildOpenCodePermissionRules,47 buildOpenCodePermissionRules,
...@@ -2856,6 +2857,11 @@ export function makeOpenCodeAdapter(...@@ -2856,6 +2857,11 @@ export function makeOpenCodeAdapter(
2856 model: parsedModel,2857 model: parsedModel,
2857 ...(context.activeAgent ? { agent: context.activeAgent } : {}),2858 ...(context.activeAgent ? { agent: context.activeAgent } : {}),
2858 ...(context.activeVariant ? { variant: context.activeVariant } : {}),2859 ...(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 }),
2859 parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts],2865 parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts],
2860 },2866 },
2861 { signal },2867 { 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 {...@@ -12,7 +12,6 @@ import {
12 shouldPreserveAssistantLineBreaks,12 shouldPreserveAssistantLineBreaks,
13 type MessagesTimelineRow,13 type MessagesTimelineRow,
14 workEntryDisplayLabel,14 workEntryDisplayLabel,
15 workEntryIsVisibleInGroup,
16} from "./MessagesTimeline.logic";15} from "./MessagesTimeline.logic";
1716
18describe("expanded tool group scrolling", () => {17describe("expanded tool group scrolling", () => {
...@@ -472,22 +471,6 @@ describe("resolveAssistantMessageCopyState", () => {...@@ -472,22 +471,6 @@ describe("resolveAssistantMessageCopyState", () => {
472 });471 });
473});472});
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
491describe("deriveMessagesTimelineRows", () => {474describe("deriveMessagesTimelineRows", () => {
492 it("keeps context compaction visible outside folded work", () => {475 it("keeps context compaction visible outside folded work", () => {
493 const rows = deriveMessagesTimelineRows({476 const rows = deriveMessagesTimelineRows({
...@@ -883,125 +866,6 @@ describe("deriveMessagesTimelineRows", () => {...@@ -883,125 +866,6 @@ describe("deriveMessagesTimelineRows", () => {
883 });866 });
884 });867 });
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
1005 it("folds all assistant messages before the terminal message", () => {869 it("folds all assistant messages before the terminal message", () => {
1006 const timelineEntries = [870 const timelineEntries = [
1007 {871 {
apps/web/src/components/chat/MessagesTimeline.logic.ts+2-24
...@@ -9,7 +9,6 @@ import {...@@ -9,7 +9,6 @@ import {
9 summarizeToolGroup,9 summarizeToolGroup,
10 toolGroupAction,10 toolGroupAction,
11 toolGroupSummaryKind,11 toolGroupSummaryKind,
12 workEntryViewedImagePath,
13 type ToolGroupSummaryKind,12 type ToolGroupSummaryKind,
14} from "@t3tools/client-runtime/work-log/presentation";13} from "@t3tools/client-runtime/work-log/presentation";
15export {14export {
...@@ -90,15 +89,6 @@ export function liveWorkEntryLabel(...@@ -90,15 +89,6 @@ export function liveWorkEntryLabel(
90 return workEntryDisplayLabel(entry, workspaceRoot);89 return workEntryDisplayLabel(entry, workspaceRoot);
91}90}
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
102export function workEntryIsVisibleInGroup(92export function workEntryIsVisibleInGroup(
103 entry: WorkLogEntry,93 entry: WorkLogEntry,
104 expandedToolGroupEntry = false,94 expandedToolGroupEntry = false,
...@@ -107,9 +97,6 @@ export function workEntryIsVisibleInGroup(...@@ -107,9 +97,6 @@ export function workEntryIsVisibleInGroup(
107 (expandedToolGroupEntry &&97 (expandedToolGroupEntry &&
108 (entry.toolLifecycleStatus === "inProgress" ||98 (entry.toolLifecycleStatus === "inProgress" ||
109 entry.sourceActivityKind === "task.progress")) ||99 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) ||
113 !workEntryIndicatesToolNeutralStatus(entry)100 !workEntryIndicatesToolNeutralStatus(entry)
114 );101 );
115}102}
...@@ -645,9 +632,6 @@ function deriveTurnFolds(input: {...@@ -645,9 +632,6 @@ function deriveTurnFolds(input: {
645 ) {632 ) {
646 continue;633 continue;
647 }634 }
648 if (entry.kind === "work" && workEntryRendersImagePreview(entry.entry)) {
649 continue;
650 }
651 hiddenEntryIds.add(entry.id);635 hiddenEntryIds.add(entry.id);
652 }636 }
653 if (hiddenEntryIds.size === 0) {637 if (hiddenEntryIds.size === 0) {
...@@ -842,8 +826,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -842,8 +826,7 @@ export function deriveMessagesTimelineRows(input: {
842 entry.kind !== "work" ||826 entry.kind !== "work" ||
843 entry.entry.agentSpawn !== undefined ||827 entry.entry.agentSpawn !== undefined ||
844 entry.entry.sourceActivityKind === "context-compaction" ||828 entry.entry.sourceActivityKind === "context-compaction" ||
845 entry.entry.tone === "error" ||829 entry.entry.tone === "error"
846 workEntryRendersImagePreview(entry.entry)
847 ) {830 ) {
848 break;831 break;
849 }832 }
...@@ -966,11 +949,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -966,11 +949,7 @@ export function deriveMessagesTimelineRows(input: {
966 }949 }
967950
968 if (timelineEntry.kind === "work") {951 if (timelineEntry.kind === "work") {
969 if (952 if (timelineEntry.entry.agentSpawn !== undefined || timelineEntry.entry.tone === "error") {
970 timelineEntry.entry.agentSpawn !== undefined ||
971 timelineEntry.entry.tone === "error" ||
972 workEntryRendersImagePreview(timelineEntry.entry)
973 ) {
974 nextRows.push({953 nextRows.push({
975 kind: "work",954 kind: "work",
976 id: timelineEntry.id,955 id: timelineEntry.id,
...@@ -990,7 +969,6 @@ export function deriveMessagesTimelineRows(input: {...@@ -990,7 +969,6 @@ export function deriveMessagesTimelineRows(input: {
990 nextEntry.entry.agentSpawn !== undefined ||969 nextEntry.entry.agentSpawn !== undefined ||
991 nextEntry.entry.sourceActivityKind === "context-compaction" ||970 nextEntry.entry.sourceActivityKind === "context-compaction" ||
992 nextEntry.entry.tone === "error" ||971 nextEntry.entry.tone === "error" ||
993 workEntryRendersImagePreview(nextEntry.entry) ||
994 activeWorkEntryIds.has(nextEntry.id) ||972 activeWorkEntryIds.has(nextEntry.id) ||
995 collapsedEntryIds.has(nextEntry.id) ||973 collapsedEntryIds.has(nextEntry.id) ||
996 foldsByAnchorEntryId.has(nextEntry.id)974 foldsByAnchorEntryId.has(nextEntry.id)
docs/internals/providers.md+12
...@@ -24,6 +24,18 @@ adapter in a child scope. Adapter implementations live beside them in...@@ -24,6 +24,18 @@ adapter in a child scope. Adapter implementations live beside them in
24[`ProviderAdapter.ts`][adapter]. Read the driver plus its adapter to see how a specific agent's24[`ProviderAdapter.ts`][adapter]. Read the driver plus its adapter to see how a specific agent's
25transport, config, and event shapes are mapped.25transport, 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
27## Codex async questions39## Codex async questions
2840
29Codex 0.153 exposes `request_user_input_async` through `item/started` and `item/completed`41Codex 0.153 exposes `request_user_input_async` through `item/started` and `item/completed`