diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 7ce2e553e9e7050c81ebb4f514827c6e129321b8..2bb9647bd41404abca4b671d50b4f441e3353520 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -1,4 +1,5 @@ import type { ProviderInteractionMode } from "@t3tools/contracts"; +import { buildRuntimeInstructions } from "./RuntimeInstructions.ts"; const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = ` @@ -175,11 +176,6 @@ export interface CodexRuntimeInfo { readonly reasoningEffort: string; } -// Values come from trusted config, but keep the block single-line regardless. -function toSingleLine(value: string): string { - return value.replaceAll(/\s+/g, " ").trim(); -} - export function buildCodexDeveloperInstructions( interactionMode: ProviderInteractionMode, runtime: CodexRuntimeInfo, @@ -196,5 +192,5 @@ export function buildCodexDeveloperInstructions( : codexDefaultModeDeveloperInstructions(browserToolsAvailable); return `${base} -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.`; +${buildRuntimeInstructions({ harness: "Codex", ...runtime })}`; } diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts index dd8784bccaccff10a1145539ef422c6a688b1c6c..6020d688cbd4d5070bf9c2cd73228a6feb3cd1d4 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts @@ -49,6 +49,7 @@ const decodeRequestLog = Schema.decodeEffect( interface NativePrompt { readonly index: number; + readonly content: ReadonlyArray; readonly result: Deferred.Deferred; } @@ -179,12 +180,13 @@ const makeHarness = Effect.fn("makeAntigravityAdapterHarness")(function* (option }), getEvents: () => Stream.fromQueue(runtimeEvents), drainEvents, - prompt: (_payload, promptOptions) => + prompt: (payload, promptOptions) => Effect.gen(function* () { yield* Deferred.succeed(dispatchStarted, undefined); if (options?.holdDispatch) yield* Deferred.await(dispatchRelease); const prompt: NativePrompt = { index: ++promptIndex, + content: payload.prompt, result: yield* Deferred.make(), }; active = prompt; @@ -597,7 +599,11 @@ it.layer(layer)("AntigravityAdapter", (it) => { const first = yield* h.adapter .sendTurn({ threadId, input: "First prompt" }) .pipe(Effect.forkChild); - yield* h.nextPrompt; + const initialPrompt = yield* h.nextPrompt; + expect(initialPrompt.content).toEqual([ + { type: "text", text: "First prompt" }, + { type: "text", text: expect.stringContaining(`Antigravity harness, as ${nativeDefault}`) }, + ]); const marker = h.calls.length; const second = yield* h.adapter .sendTurn({ @@ -615,6 +621,13 @@ it.layer(layer)("AntigravityAdapter", (it) => { }); yield* Deferred.succeed(h.cancelRelease, undefined); const replacement = yield* h.nextPrompt; + expect(replacement.content).toEqual([ + { type: "text", text: "Steer the turn" }, + { + type: "text", + text: expect.stringContaining(`Antigravity harness, as ${nativeAlternative}`), + }, + ]); expect(h.calls.slice(marker)).toEqual([ "cancel:1", "drained:1", diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index 8e135b612ee49e5b3df95a5acab96885b315ed7d..aa7d4c6a6755794fb384e774d127d26aa7380a12 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -36,6 +36,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import { ServerConfig } from "../../config.ts"; +import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import type { AntigravityAuth } from "../AntigravityAuth.ts"; import { @@ -1075,7 +1076,18 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi }; const dispatched = yield* Deferred.make(); const fiber = yield* context.runtime - .prompt({ prompt }, { dispatched }) + .prompt( + { + prompt: [ + ...prompt, + { + type: "text", + text: buildRuntimeInstructions({ harness: "Antigravity", model }), + }, + ], + }, + { dispatched }, + ) .pipe(Effect.forkIn(context.scope)); context.promptFiber = fiber; // Fiber.join can skip a scope-close waiter when the child is interrupted. diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 06226d446db836f5fe318339333a6d01e0ef3ce4..99bb95d83c652033508eeeace7ab66e73e0c4b2a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -385,6 +385,12 @@ describe("ClaudeAdapterLive", () => { const createInput = harness.getLastCreateQueryInput(); assert.deepEqual(createInput?.options.settingSources, ["user", "project", "local"]); + assert.deepEqual(createInput?.options.systemPrompt, { + type: "preset", + preset: "claude_code", + append: + "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.", + }); assert.equal(createInput?.options.permissionMode, "bypassPermissions"); assert.equal(createInput?.options.allowDangerouslySkipPermissions, true); }).pipe( diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index d0569ec94f9a3113313453abce5471398ba528b5..5848bcc457de06f484b29bf078b8d93995f4aaa7 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -83,6 +83,7 @@ import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; +import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG, type ClaudeModelCatalog, @@ -4370,7 +4371,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(input.cwd ? { cwd: input.cwd } : {}), ...(apiModelId ? { model: apiModelId } : {}), pathToClaudeCodeExecutable: claudeBinaryPath, - systemPrompt: { type: "preset", preset: "claude_code" }, + systemPrompt: { + type: "preset", + preset: "claude_code", + // Model and effort can change after this session-level prompt is set. + append: buildRuntimeInstructions({ harness: "Claude Code" }), + }, settingSources: [...CLAUDE_SETTING_SOURCES], // `ultracode` is a Claude Code setting, not an API effort level. It is // normalized to `xhigh` above and paired with `settings.ultracode`. diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 6a6cec5b1e61e7964572f9d0ba5062e900ac5b65..fd4d66dd497b59e20ffdf466ae49adc6373d61c2 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -465,6 +465,19 @@ describe("buildCodexDeveloperInstructions", () => { NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/); }); + it("describes Markdown media support in the runtime context in both modes", () => { + for (const mode of ["default", "plan"] as const) { + const instructions = buildCodexDeveloperInstructions(mode, { + model: "gpt-5.3-codex", + reasoningEffort: "high", + }); + NodeAssert.match( + instructions, + /.*embed images and videos.*Markdown.*<\/runtime_info>/, + ); + } + }); + it("includes runtime info alongside plan mode instructions", () => { const instructions = buildCodexDeveloperInstructions("plan", { model: "gpt-5.3-codex", diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index b00442e9c6cb2b3c024aec73731dd2a5cd876767..b952c97ffac805116085be08dfb55405f0d1c58b 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -26,6 +26,7 @@ import { } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; +import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import type { CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { makeCursorAdapter } from "./CursorAdapter.ts"; @@ -283,6 +284,18 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { input: "please $review this", attachments: [], }); + const snapshot = yield* adapter.readThread(threadId); + assert.deepStrictEqual( + snapshot.turns.map((turn) => turn.items), + [ + [ + { + prompt: [{ type: "text", text: "please /review this" }], + result: { stopReason: "end_turn" }, + }, + ], + ], + ); yield* adapter.stopSession(threadId); const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); @@ -291,7 +304,12 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { promptRequests.map( (request) => (request.params as Record | undefined)?.prompt, ), - [[{ type: "text", text: "please /review this" }]], + [ + [ + { type: "text", text: "please /review this" }, + { type: "text", text: buildRuntimeInstructions({ harness: "Cursor" }) }, + ], + ], ); }), ); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 50a3131ff9500304818a8d8837dcd64c389e9f22..a8aea90e89722ffb01773938c9ebb6295081c523 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -42,6 +42,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ProviderAdapterProcessError, @@ -1043,9 +1044,16 @@ export function makeCursorAdapter( }); } + // ACP has no system-message field; keep runtime context separate from the user's text. const result = yield* ctx.acp .prompt({ - prompt: promptParts, + prompt: [ + ...promptParts, + { + type: "text", + text: buildRuntimeInstructions({ harness: "Cursor", model: resolvedModel }), + }, + ], }) .pipe( Effect.mapError((error) => diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index e2649006b8fcc494415b1187f93028395e682e50..44a0c7343d22b67c6913ff389b79ae0fe8feaa2d 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -213,6 +213,68 @@ it("requires a settlement to match the live Grok turn", () => { }); it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { + it.effect("sends runtime context with the current model without changing saved prompts", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-runtime-context"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-runtime-context-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-mock-alt" }, + }); + yield* adapter.sendTurn({ threadId, input: "First prompt" }); + yield* adapter.sendTurn({ + threadId, + input: "Second prompt", + modelSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-4.6", + options: [{ id: "reasoningEffort", value: "low" }], + }, + }); + const snapshot = yield* adapter.readThread(threadId); + assert.deepEqual( + snapshot.turns.map((turn) => turn.items), + [ + [ + { + prompt: [{ type: "text", text: "First prompt" }], + result: { stopReason: "end_turn" }, + }, + ], + [ + { + prompt: [{ type: "text", text: "Second prompt" }], + result: { stopReason: "end_turn" }, + }, + ], + ], + ); + yield* adapter.stopSession(threadId); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const prompts = requests + .filter((request) => request.method === "session/prompt") + .map( + (request) => (request.params as { prompt: Array<{ type: string; text: string }> }).prompt, + ); + assert.equal(prompts.length, 2); + assert.deepEqual(prompts[0]?.[0], { type: "text", text: "First prompt" }); + assert.include(prompts[0]?.[1]?.text, "Grok harness, as grok-mock-alt"); + assert.deepEqual(prompts[1]?.[0], { type: "text", text: "Second prompt" }); + assert.include(prompts[1]?.[1]?.text, "Grok harness, as grok-4.6"); + assert.include(prompts[1]?.[1]?.text, "with low reasoning effort"); + assert.include(prompts[1]?.[1]?.text, "embed images and videos"); + }), + ); + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-mock-thread"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index da9bdf6030f430315e367f2d01537efe42b90885..dae7c2ca08f694fbfd74229793290dc8a80224a2 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -40,6 +40,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ProviderAdapterProcessError, @@ -1583,6 +1584,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const displayModel = currentModelId ? resolveGrokAcpBaseModelId(currentModelId) : undefined; + const runtimeInstructions = buildRuntimeInstructions({ + harness: "Grok", + model: displayModel, + reasoningEffort: normalizeGrokReasoningEffort(requestedTurnReasoningEffort), + }); for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { yield* Effect.yieldNow; } @@ -1638,6 +1644,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte acpSessionId: ctx.acpSessionId, displayModel, promptParts, + runtimeInstructions, turnId, promptEpoch, promptLifecycle: ctx.promptLifecycle, @@ -1694,7 +1701,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } const dispatched = yield* Deferred.make(); const fiber = yield* liveCtx.acp - .prompt({ prompt: prepared.promptParts }, { dispatched }) + .prompt( + { + prompt: [ + ...prepared.promptParts, + { type: "text", text: prepared.runtimeInstructions }, + ], + }, + { dispatched }, + ) .pipe(Effect.forkChild({ startImmediately: true })); // Hold the lifecycle permit until the runtime has registered this // prompt's RPC fiber, so a later steer's session/cancel targets diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 7210aae75d69e48d00e4c53771201a32dc5fabaf..dfa6f20be7c0971e71f678b4f0a58a022c207a38 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -27,6 +27,7 @@ import { import { createModelSelection } from "@t3tools/shared/model"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; import { @@ -4958,6 +4959,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }, agent: "github-copilot", variant: "high", + system: buildRuntimeInstructions({ + harness: "OpenCode", + model: "anthropic/claude-sonnet-4-5", + }), parts: [{ type: "text", text: "Fix it" }], }); }).pipe(Effect.provide(adapterLayer)); @@ -5005,6 +5010,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { providerID: "anthropic", modelID: "claude-sonnet-4-5", }, + system: buildRuntimeInstructions({ + harness: "OpenCode", + model: "anthropic/claude-sonnet-4-5", + }), parts: [{ type: "text", text: "Fix it" }], }); }).pipe(Effect.provide(adapterLayer)); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index b6cfa4e366ca39bdd9a329ef9af9017d82d11935..e0305236a71706f461bb215dda27338f758ec8d0 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -41,6 +41,7 @@ import { ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, } from "../Errors.ts"; +import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; import { buildOpenCodePermissionRules, @@ -2856,6 +2857,11 @@ export function makeOpenCodeAdapter( model: parsedModel, ...(context.activeAgent ? { agent: context.activeAgent } : {}), ...(context.activeVariant ? { variant: context.activeVariant } : {}), + // OpenCode appends this after its own agent/provider prompts. + system: buildRuntimeInstructions({ + harness: "OpenCode", + model: `${parsedModel.providerID}/${parsedModel.modelID}`, + }), parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], }, { signal }, diff --git a/apps/server/src/provider/RuntimeInstructions.test.ts b/apps/server/src/provider/RuntimeInstructions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..350d8c73c150862380e7f9364e73006b8f336ca5 --- /dev/null +++ b/apps/server/src/provider/RuntimeInstructions.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vite-plus/test"; +import { buildRuntimeInstructions } from "./RuntimeInstructions.ts"; + +describe("buildRuntimeInstructions", () => { + it.each(["Codex", "Claude Code", "Cursor", "Grok", "OpenCode", "Antigravity"])( + "identifies the %s harness and describes media embedding", + (harness) => { + const instructions = buildRuntimeInstructions({ harness }); + expect(instructions).toContain(`running in T3 Code through the ${harness} harness.`); + expect(instructions).toContain("embed images and videos"); + expect(instructions).toContain("Markdown with absolute file paths"); + expect(instructions).not.toContain("undefined"); + }, + ); + + it("keeps known model and effort metadata on one line", () => { + expect( + buildRuntimeInstructions({ + harness: "Codex", + model: " custom\nmodel ", + reasoningEffort: " high\n", + }), + ).toContain("through the Codex harness, as custom model with high reasoning effort."); + }); + + it.each([undefined, "", "auto", "default"])("omits unresolved model %s", (model) => { + const instructions = buildRuntimeInstructions({ harness: "Cursor", model }); + expect(instructions).toContain("through the Cursor harness."); + expect(instructions).not.toContain("reasoning effort"); + }); +}); diff --git a/apps/server/src/provider/RuntimeInstructions.ts b/apps/server/src/provider/RuntimeInstructions.ts new file mode 100644 index 0000000000000000000000000000000000000000..102c49c78a9e114f0b836e7cefedd2792f08f0ba --- /dev/null +++ b/apps/server/src/provider/RuntimeInstructions.ts @@ -0,0 +1,17 @@ +/** Shared runtime context; omit model and effort when the harness manages them dynamically. */ +export function buildRuntimeInstructions(runtime: { + readonly harness: string; + readonly model?: string | undefined; + readonly reasoningEffort?: string | undefined; +}): string { + const harness = toSingleLine(runtime.harness); + const model = toSingleLine(runtime.model ?? ""); + const effort = toSingleLine(runtime.reasoningEffort ?? ""); + const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${model}` : ""; + const effortInfo = effort ? ` with ${effort} reasoning effort` : ""; + return `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.`; +} + +function toSingleLine(value: string): string { + return value.replaceAll(/\s+/g, " ").trim(); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 67dea03e44ea54d7cb4c8c113695b1a49f6d6e86..6e5cf29c2914568203fdc6ccc551309b31a6b4c8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -12,7 +12,6 @@ import { shouldPreserveAssistantLineBreaks, type MessagesTimelineRow, workEntryDisplayLabel, - workEntryIsVisibleInGroup, } from "./MessagesTimeline.logic"; describe("expanded tool group scrolling", () => { @@ -472,22 +471,6 @@ describe("resolveAssistantMessageCopyState", () => { }); }); -describe("workEntryIsVisibleInGroup", () => { - it("keeps an in-progress image preview row visible outside a group", () => { - const entry = { - id: "work-image", - createdAt: "2026-01-01T00:00:04Z", - turnId: "turn-1" as never, - label: "Image view", - detail: "screenshots/result.png", - itemType: "image_view" as const, - tone: "tool" as const, - toolLifecycleStatus: "inProgress" as const, - }; - expect(workEntryIsVisibleInGroup(entry, false)).toBe(true); - }); -}); - describe("deriveMessagesTimelineRows", () => { it("keeps context compaction visible outside folded work", () => { const rows = deriveMessagesTimelineRows({ @@ -883,125 +866,6 @@ describe("deriveMessagesTimelineRows", () => { }); }); - it("keeps an image preview row visible after the turn settles", () => { - const timelineEntries = [ - { - id: "work-command-entry", - kind: "work" as const, - createdAt: "2026-01-01T00:00:02Z", - entry: { - id: "work-command", - createdAt: "2026-01-01T00:00:02Z", - turnId: "turn-1" as never, - label: "Ran command", - tone: "tool" as const, - }, - }, - { - id: "work-image-entry", - kind: "work" as const, - createdAt: "2026-01-01T00:00:04Z", - entry: { - id: "work-image", - createdAt: "2026-01-01T00:00:04Z", - turnId: "turn-1" as never, - label: "Image view", - detail: "screenshots/result.png", - itemType: "image_view" as const, - tone: "tool" as const, - }, - }, - { - id: "assistant-final-entry", - kind: "message" as const, - createdAt: "2026-01-01T00:00:06Z", - message: { - id: "assistant-final" as never, - role: "assistant" as const, - text: "Here is the screenshot.", - turnId: "turn-1" as never, - createdAt: "2026-01-01T00:00:06Z", - updatedAt: "2026-01-01T00:00:07Z", - streaming: false, - }, - }, - ]; - - const rows = deriveMessagesTimelineRows({ - timelineEntries, - isWorking: false, - activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - }); - - // The command row folds, the image row stays as its own visible row rather - // than collapsing into a tool group summary. - expect(rows.map((row) => row.id)).toEqual([ - "turn-fold:turn-1", - "work-image-entry", - "assistant-final-entry", - ]); - expect(rows.find((row) => row.id === "work-image-entry")?.kind).toBe("work"); - }); - - it("keeps a trailing image preview row visible while the turn is still working", () => { - const rows = deriveMessagesTimelineRows({ - timelineEntries: [ - { - id: "user-entry", - kind: "message" as const, - createdAt: "2026-01-01T00:00:00Z", - message: { - id: "user-1" as never, - role: "user" as const, - text: "show me the result", - turnId: null, - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - streaming: false, - }, - }, - { - id: "work-command-entry", - kind: "work" as const, - createdAt: "2026-01-01T00:00:02Z", - entry: { - id: "work-command", - createdAt: "2026-01-01T00:00:02Z", - turnId: "turn-1" as never, - label: "Ran command", - tone: "tool" as const, - }, - }, - { - id: "work-image-entry", - kind: "work" as const, - createdAt: "2026-01-01T00:00:04Z", - entry: { - id: "work-image", - createdAt: "2026-01-01T00:00:04Z", - turnId: "turn-1" as never, - label: "Image view", - detail: "screenshots/result.png", - itemType: "image_view" as const, - tone: "tool" as const, - }, - }, - ], - isWorking: true, - activeTurnStartedAt: "2026-01-01T00:00:01Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - }); - - // The live activity row must not swallow the image: it renders only a - // label, which would hide the image until the turn settles. - const imageRow = rows.find((row) => row.id === "work-image-entry"); - expect(imageRow?.kind).toBe("work"); - expect(rows.some((row) => row.kind === "work-live")).toBe(false); - }); - it("folds all assistant messages before the terminal message", () => { const timelineEntries = [ { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index f33e847adc5d318b5202ba99ed8b2f9b8b935235..413b2c0f907531ae6c9b147e96da9a21ab95e4b2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -9,7 +9,6 @@ import { summarizeToolGroup, toolGroupAction, toolGroupSummaryKind, - workEntryViewedImagePath, type ToolGroupSummaryKind, } from "@t3tools/client-runtime/work-log/presentation"; export { @@ -90,15 +89,6 @@ export function liveWorkEntryLabel( return workEntryDisplayLabel(entry, workspaceRoot); } -/** - * Rows that preview an image the agent viewed or produced. They stay out of - * tool groups and out of the settled-turn fold: the image is the answer the - * user asked for, not tool noise to hide behind "Worked for ...". - */ -function workEntryRendersImagePreview(entry: WorkLogEntry): boolean { - return workEntryViewedImagePath(entry) !== null; -} - export function workEntryIsVisibleInGroup( entry: WorkLogEntry, expandedToolGroupEntry = false, @@ -107,9 +97,6 @@ export function workEntryIsVisibleInGroup( (expandedToolGroupEntry && (entry.toolLifecycleStatus === "inProgress" || entry.sourceActivityKind === "task.progress")) || - // An image row stands alone outside any group, so the neutral filter - // would leave an empty gap while its tool is still in progress. - workEntryRendersImagePreview(entry) || !workEntryIndicatesToolNeutralStatus(entry) ); } @@ -645,9 +632,6 @@ function deriveTurnFolds(input: { ) { continue; } - if (entry.kind === "work" && workEntryRendersImagePreview(entry.entry)) { - continue; - } hiddenEntryIds.add(entry.id); } if (hiddenEntryIds.size === 0) { @@ -842,8 +826,7 @@ export function deriveMessagesTimelineRows(input: { entry.kind !== "work" || entry.entry.agentSpawn !== undefined || entry.entry.sourceActivityKind === "context-compaction" || - entry.entry.tone === "error" || - workEntryRendersImagePreview(entry.entry) + entry.entry.tone === "error" ) { break; } @@ -966,11 +949,7 @@ export function deriveMessagesTimelineRows(input: { } if (timelineEntry.kind === "work") { - if ( - timelineEntry.entry.agentSpawn !== undefined || - timelineEntry.entry.tone === "error" || - workEntryRendersImagePreview(timelineEntry.entry) - ) { + if (timelineEntry.entry.agentSpawn !== undefined || timelineEntry.entry.tone === "error") { nextRows.push({ kind: "work", id: timelineEntry.id, @@ -990,7 +969,6 @@ export function deriveMessagesTimelineRows(input: { nextEntry.entry.agentSpawn !== undefined || nextEntry.entry.sourceActivityKind === "context-compaction" || nextEntry.entry.tone === "error" || - workEntryRendersImagePreview(nextEntry.entry) || activeWorkEntryIds.has(nextEntry.id) || collapsedEntryIds.has(nextEntry.id) || foldsByAnchorEntryId.has(nextEntry.id) diff --git a/docs/internals/providers.md b/docs/internals/providers.md index b4a5a19e6f5c9bd9cd9eaa02847bacbfb5e7b657..3baae679de487fb82798fd0541faf1d52b1c8d20 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -24,6 +24,18 @@ adapter in a child scope. Adapter implementations live beside them in [`ProviderAdapter.ts`][adapter]. Read the driver plus its adapter to see how a specific agent's transport, config, and event shapes are mapped. +## Runtime context + +Every adapter uses `apps/server/src/provider/RuntimeInstructions.ts` to identify T3 Code and +the harness, and describe Markdown image/video embeds. Codex includes it in developer +instructions; Claude appends it to its system preset; OpenCode sends it in each prompt's +`system` field. Cursor, Grok, and Antigravity append a separate text block to ACP prompts, +which have no system-message field. This does not change the stored user message. + +Per-turn context includes the current model when known. Codex includes reasoning effort; +Grok includes it when explicitly selected for the turn. Claude's session-level context omits model and effort because they can +change during a session. OpenCode variants are not assumed to be reasoning-effort levels. + ## Codex async questions Codex 0.153 exposes `request_user_input_async` through `item/started` and `item/completed`