diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index f00736772766..d5b6e2b04b03 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -979,6 +979,15 @@ function renderFeedEntry( })} {showAssistantMeta ? ( + {message.actualModel ? ( + + Model: {message.actualModel} + + ) : null} { expect(completionEvents).toHaveLength(1); }); + it("enriches an already completed assistant message with the actual turn model", async () => { + const harness = await createHarness(); + const now = "2026-08-14T00:00:00.000Z"; + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("turn-actual-model"); + const itemId = asItemId("item-actual-model"); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-actual-model"), + provider: ProviderDriverKind.make("opencode"), + createdAt: now, + threadId, + turnId, + }); + await waitForThread(harness.readModel, (thread) => thread.session?.activeTurnId === turnId); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-actual-model"), + provider: ProviderDriverKind.make("opencode"), + createdAt: now, + threadId, + turnId, + itemId, + payload: { streamKind: "assistant_text", delta: "done" }, + }); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-message-completed-actual-model"), + provider: ProviderDriverKind.make("opencode"), + createdAt: now, + threadId, + turnId, + itemId, + payload: { itemType: "assistant_message", status: "completed" }, + }); + await waitForThread(harness.readModel, (thread) => + thread.messages.some( + (message) => message.id === "assistant:item-actual-model" && !message.streaming, + ), + ); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-actual-model"), + provider: ProviderDriverKind.make("opencode"), + createdAt: now, + threadId, + turnId, + payload: { state: "completed", actualModel: "gpt-5.6-luna" }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message) => + message.id === "assistant:item-actual-model" && message.actualModel === "gpt-5.6-luna", + ), + ); + const messages = thread.messages.filter( + (message) => message.id === "assistant:item-actual-model", + ); + expect(messages).toHaveLength(1); + expect(messages[0]?.actualModel).toBe("gpt-5.6-luna"); + }); + it("maps canonical request events into approval activities with requestKind", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index e24825d3d1fc..45eae524a618 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1198,6 +1198,7 @@ const make = Effect.gen(function* () { finalDeltaCommandTag: string; fallbackText?: string; hasProjectedMessage?: boolean; + actualModel?: string; }) => Effect.gen(function* () { const bufferedText = yield* takeBufferedAssistantText(input.messageId); @@ -1228,6 +1229,7 @@ const make = Effect.gen(function* () { threadId: input.threadId, messageId: input.messageId, ...(input.turnId ? { turnId: input.turnId } : {}), + ...(input.actualModel ? { actualModel: input.actualModel } : {}), createdAt: input.createdAt, }); } @@ -1837,7 +1839,20 @@ const make = Effect.gen(function* () { const proposedPlans = detailedThread?.proposedPlans ?? []; const turnId = toTurnId(event.turnId); if (turnId) { - const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId); + const trackedAssistantMessageIds = yield* getAssistantMessageIdsForTurn( + thread.id, + turnId, + ); + const completedAssistantMessageId = messages.findLast( + (message) => message.role === "assistant" && message.turnId === turnId, + )?.id; + const assistantMessageIds = + trackedAssistantMessageIds.size > 0 + ? Array.from(trackedAssistantMessageIds) + : event.payload.actualModel && completedAssistantMessageId + ? [completedAssistantMessageId] + : []; + const terminalAssistantMessageId = assistantMessageIds.at(-1); yield* Effect.forEach( assistantMessageIds, (assistantMessageId) => @@ -1850,6 +1865,9 @@ const make = Effect.gen(function* () { commandTag: "assistant-complete-finalize", finalDeltaCommandTag: "assistant-delta-finalize-fallback", hasProjectedMessage: findMessageById(messages, assistantMessageId) !== undefined, + ...(event.payload.actualModel && assistantMessageId === terminalAssistantMessageId + ? { actualModel: event.payload.actualModel } + : {}), }), { concurrency: 1 }, ).pipe(Effect.asVoid); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 4f61955fa6aa..115f549cb72a 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1271,6 +1271,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" messageId: command.messageId, role: "assistant", text: "", + ...(command.actualModel ? { actualModel: command.actualModel } : {}), turnId: command.turnId ?? null, streaming: false, createdAt: command.createdAt, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index f486dcb2bcbc..bdc432fa24cc 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -507,6 +507,7 @@ export function projectEvent( id: payload.messageId, role: payload.role, text: payload.text, + ...(payload.actualModel !== undefined ? { actualModel: payload.actualModel } : {}), ...(payload.attachments !== undefined ? { attachments: payload.attachments } : {}), turnId: payload.turnId, streaming: payload.streaming, @@ -531,6 +532,9 @@ export function projectEvent( streaming: message.streaming, updatedAt: message.updatedAt, turnId: message.turnId, + ...(message.actualModel !== undefined + ? { actualModel: message.actualModel } + : {}), ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index b1f394a9e577..1f28f688630d 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -12,6 +12,43 @@ const layer = it.layer( ); layer("ProjectionThreadMessageRepository", (it) => { + it.effect("persists actual model metadata across later partial upserts", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + const threadId = ThreadId.make("thread-actual-model"); + const messageId = MessageId.make("message-actual-model"); + const createdAt = "2026-08-14T00:00:00.000Z"; + + yield* repository.upsert({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: "complete", + actualModel: "gpt-5.6-luna", + isStreaming: false, + createdAt, + updatedAt: createdAt, + }); + yield* repository.upsert({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: "complete", + isStreaming: false, + createdAt, + updatedAt: "2026-08-14T00:00:01.000Z", + }); + + const row = yield* repository.getByMessageId({ messageId }); + assert.equal(row._tag, "Some"); + if (row._tag === "Some") { + assert.equal(row.value.actualModel, "gpt-5.6-luna"); + } + }), + ); + it.effect("preserves existing attachments when upsert omits attachments", () => Effect.gen(function* () { const repository = yield* ProjectionThreadMessageRepository; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 719191668869..78f2ae6adb70 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; -import { ChatAttachment } from "@t3tools/contracts"; +import { ChatAttachment, TrimmedNonEmptyString } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { @@ -20,6 +20,7 @@ import { const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( Struct.assign({ isStreaming: Schema.Number, + actualModel: Schema.NullOr(TrimmedNonEmptyString), attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), }), ); @@ -33,6 +34,7 @@ function toProjectionThreadMessage( turnId: row.turnId, role: row.role, text: row.text, + ...(row.actualModel !== null ? { actualModel: row.actualModel } : {}), isStreaming: row.isStreaming === 1, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -55,6 +57,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { turn_id, role, text, + actual_model, attachments_json, is_streaming, created_at, @@ -66,6 +69,14 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { ${row.turnId}, ${row.role}, ${row.text}, + COALESCE( + ${row.actualModel ?? null}, + ( + SELECT actual_model + FROM projection_thread_messages + WHERE message_id = ${row.messageId} + ) + ), COALESCE( ${nextAttachmentsJson}, ( @@ -84,6 +95,10 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { turn_id = excluded.turn_id, role = excluded.role, text = excluded.text, + actual_model = COALESCE( + excluded.actual_model, + projection_thread_messages.actual_model + ), attachments_json = COALESCE( excluded.attachments_json, projection_thread_messages.attachments_json @@ -106,6 +121,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { turn_id AS "turnId", role, text, + actual_model AS "actualModel", attachments_json AS "attachments", is_streaming AS "isStreaming", created_at AS "createdAt", @@ -127,6 +143,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { turn_id AS "turnId", role, text, + actual_model AS "actualModel", attachments_json AS "attachments", is_streaming AS "isStreaming", created_at AS "createdAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index b137cedfbedd..cf5205141b9e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -53,6 +53,7 @@ import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; +import Migration0041 from "./Migrations/041_ProjectionThreadMessageActualModel.ts"; /** * Migration loader with all migrations defined inline. @@ -105,6 +106,7 @@ export const migrationEntries = [ [38, "ProjectionThreadsPinOrderKey", Migration0038], [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], [40, "ProjectionProjectFaviconPath", Migration0040], + [41, "ProjectionThreadMessageActualModel", Migration0041], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/041_ProjectionThreadMessageActualModel.test.ts b/apps/server/src/persistence/Migrations/041_ProjectionThreadMessageActualModel.test.ts new file mode 100644 index 000000000000..884a0cb7ef3c --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_ProjectionThreadMessageActualModel.test.ts @@ -0,0 +1,28 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("041_ProjectionThreadMessageActualModel", (it) => { + it.effect("adds the nullable actual model to message projections", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 40 }); + yield* runMigrations({ toMigrationInclusive: 41 }); + + const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` + PRAGMA table_info(projection_thread_messages) + `; + const actualModel = columns.find((column) => column.name === "actual_model"); + + assert.equal(actualModel?.name, "actual_model"); + assert.equal(actualModel?.notnull, 0); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/041_ProjectionThreadMessageActualModel.ts b/apps/server/src/persistence/Migrations/041_ProjectionThreadMessageActualModel.ts new file mode 100644 index 000000000000..4e363e1c4dcd --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_ProjectionThreadMessageActualModel.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_messages) + `; + + if (!columns.some((column) => column.name === "actual_model")) { + yield* sql` + ALTER TABLE projection_thread_messages + ADD COLUMN actual_model TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index d50ff3202563..c48778db407f 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -13,6 +13,7 @@ import { ThreadId, TurnId, IsoDateTime, + TrimmedNonEmptyString, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; @@ -27,6 +28,7 @@ export const ProjectionThreadMessage = Schema.Struct({ turnId: Schema.NullOr(TurnId), role: OrchestrationMessageRole, text: Schema.String, + actualModel: Schema.optional(TrimmedNonEmptyString), attachments: Schema.optional(Schema.Array(ChatAttachment)), isStreaming: Schema.Boolean, createdAt: IsoDateTime, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index eea328e05d1e..a79ad7c35f9a 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -20,6 +20,7 @@ import { ProviderDriverKind, ProviderInstanceId, ThreadId, + TurnId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { ServerConfig } from "../../config.ts"; @@ -37,6 +38,8 @@ import { isSameOpenCodeDirectory, makeOpenCodeAdapter, mergeOpenCodeAssistantText, + rememberCompletedTurnWithoutModel, + rememberMessageTurn, } from "./OpenCodeAdapter.ts"; // Test-local service tag so the rest of the file can keep using `yield* OpenCodeAdapter`. @@ -68,6 +71,7 @@ const runtimeMock = { closeError: null as Error | null, messages: [] as MessageEntry[], subscribedEvents: [] as unknown[], + subscribedEventGate: null as Promise | null, sessionGetIds: [] as string[], missingSessionIds: new Set(), transientErrorSessionIds: new Set(), @@ -88,6 +92,7 @@ const runtimeMock = { this.state.closeError = null; this.state.messages = []; this.state.subscribedEvents = []; + this.state.subscribedEventGate = null; this.state.sessionGetIds.length = 0; this.state.missingSessionIds.clear(); this.state.transientErrorSessionIds.clear(); @@ -206,6 +211,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { event: { subscribe: async () => ({ stream: (async function* () { + if (runtimeMock.state.subscribedEventGate) { + await runtimeMock.state.subscribedEventGate; + } for (const event of runtimeMock.state.subscribedEvents) { yield event; } @@ -1055,6 +1063,33 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }).pipe(Effect.scoped), ); + it("bounds completed turns waiting for actual model metadata", () => { + const pendingTurns = new Map, "completed" | "failed">(); + for (let index = 0; index < 20; index += 1) { + rememberCompletedTurnWithoutModel( + pendingTurns, + TurnId.make(`turn-pending-model-${index}`), + "completed", + ); + } + + NodeAssert.equal(pendingTurns.size, 16); + NodeAssert.equal(pendingTurns.has(TurnId.make("turn-pending-model-0")), false); + NodeAssert.equal(pendingTurns.has(TurnId.make("turn-pending-model-3")), false); + NodeAssert.equal(pendingTurns.has(TurnId.make("turn-pending-model-4")), true); + NodeAssert.equal(pendingTurns.has(TurnId.make("turn-pending-model-19")), true); + }); + + it("does not rebind a message to a newer active turn", () => { + const messageTurns = new Map>(); + const originalTurnId = TurnId.make("turn-original-message"); + rememberMessageTurn(messageTurns, "msg-late-role", originalTurnId); + rememberMessageTurn(messageTurns, "msg-late-role", TurnId.make("turn-new-active")); + + NodeAssert.equal(messageTurns.size, 1); + NodeAssert.equal(messageTurns.get("msg-late-role"), originalTurnId); + }); + it.effect("appends raw assistant text deltas and reconciles part update snapshots", () => Effect.sync(() => { const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); @@ -1069,6 +1104,142 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("enriches turn completion when the response model arrives after idle", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-actual-model"); + let releaseEvents: (() => void) | undefined; + runtimeMock.state.subscribedEventGate = new Promise((resolve) => { + releaseEvents = resolve; + }); + runtimeMock.state.subscribedEvents = [ + { + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: "msg-actual-model", role: "assistant" }, + }, + }, + { + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }, + { + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + id: "part-step-finish", + sessionID: "http://127.0.0.1:9999/session", + messageID: "msg-actual-model", + type: "step-finish", + reason: "stop", + modelID: "gpt-5.6-luna", + cost: 0, + tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(5), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "identify the model", + modelSelection: createModelSelection(ProviderInstanceId.make("opencode"), "kedvai/auto"), + }); + releaseEvents?.(); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const completions = events.filter((event) => event.type === "turn.completed"); + NodeAssert.equal(completions.length, 2); + NodeAssert.equal(completions[0]?.payload.actualModel, undefined); + NodeAssert.equal(completions[1]?.payload.actualModel, "gpt-5.6-luna"); + }), + ); + + it.effect("captures a stored response model when message metadata arrives after idle", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-stored-actual-model"); + let releaseEvents: (() => void) | undefined; + runtimeMock.state.subscribedEventGate = new Promise((resolve) => { + releaseEvents = resolve; + }); + runtimeMock.state.subscribedEvents = [ + { + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + id: "part-stored-step-finish", + sessionID: "http://127.0.0.1:9999/session", + messageID: "msg-stored-actual-model", + type: "step-finish", + reason: "stop", + modelID: "gpt-5.6-luna", + cost: 0, + tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + }, + }, + { + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }, + { + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: "msg-stored-actual-model", role: "assistant" }, + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(5), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "identify the stored model", + modelSelection: createModelSelection(ProviderInstanceId.make("opencode"), "kedvai/auto"), + }); + releaseEvents?.(); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const completions = events.filter((event) => event.type === "turn.completed"); + NodeAssert.equal(completions.length, 2); + NodeAssert.equal(completions[0]?.payload.actualModel, undefined); + NodeAssert.equal(completions[1]?.payload.actualModel, "gpt-5.6-luna"); + }), + ); + it.effect("does not strip coincidental prefix overlap from OpenCode part deltas", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 8f7e42c11d7c..c575aa1ab0b5 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -61,6 +61,7 @@ const PROVIDER = ProviderDriverKind.make("opencode"); * rather than misread (mirrors GROK_RESUME_VERSION / CURSOR_RESUME_VERSION). */ const OPENCODE_RESUME_VERSION = 1 as const; +const MAX_PENDING_ACTUAL_MODEL_TURNS = 16; /** * Decode a persisted resume cursor into the upstream `ses_…` id. Anything @@ -221,6 +222,14 @@ function isOpenCodeDefaultTitle(title: string): boolean { return OPENCODE_DEFAULT_TITLE_PATTERN.test(title); } +function actualModelFromPart(part: Part): string | undefined { + if (part.type !== "step-finish") { + return undefined; + } + const modelID = (part as Part & { readonly modelID?: unknown }).modelID; + return typeof modelID === "string" ? trimText(modelID) : undefined; +} + interface OpenCodeSessionContext { session: ProviderSession; readonly client: OpencodeClient; @@ -230,11 +239,14 @@ interface OpenCodeSessionContext { readonly pendingPermissions: Map; readonly pendingQuestions: Map; readonly messageRoleById: Map; + readonly turnIdByMessageId: Map; readonly partById: Map; readonly emittedTextByPartId: Map; readonly completedAssistantPartIds: Set; readonly turns: Array; activeTurnId: TurnId | undefined; + activeActualModel: string | undefined; + readonly completedTurnsWithoutModel: Map; activeAgent: string | undefined; activeVariant: string | undefined; /** @@ -254,6 +266,32 @@ interface OpenCodeSessionContext { readonly sessionScope: Scope.Closeable; } +export function rememberCompletedTurnWithoutModel( + pendingTurns: Map, + turnId: TurnId, + state: "completed" | "failed", +): void { + pendingTurns.delete(turnId); + pendingTurns.set(turnId, state); + if (pendingTurns.size <= MAX_PENDING_ACTUAL_MODEL_TURNS) { + return; + } + const oldestTurnId = pendingTurns.keys().next().value; + if (oldestTurnId !== undefined) { + pendingTurns.delete(oldestTurnId); + } +} + +export function rememberMessageTurn( + messageTurns: Map, + messageId: string, + turnId: TurnId, +): void { + if (!messageTurns.has(messageId)) { + messageTurns.set(messageId, turnId); + } +} + export interface OpenCodeAdapterLiveOptions { readonly instanceId?: ProviderInstanceId; readonly environment?: NodeJS.ProcessEnv; @@ -799,6 +837,39 @@ export function makeOpenCodeAdapter( } }); + const captureActualModel = Effect.fn("captureActualModel")(function* ( + context: OpenCodeSessionContext, + part: Part, + raw: unknown, + ) { + const actualModel = actualModelFromPart(part); + const partTurnId = context.turnIdByMessageId.get(part.messageID); + if (!actualModel || !partTurnId) { + return; + } + if (context.activeTurnId === partTurnId) { + context.activeActualModel = actualModel; + return; + } + const completedState = context.completedTurnsWithoutModel.get(partTurnId); + if (!completedState) { + return; + } + context.completedTurnsWithoutModel.delete(partTurnId); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: partTurnId, + raw, + })), + type: "turn.completed", + payload: { + state: completedState, + actualModel, + }, + }); + }); + const handleSubscribedEvent = Effect.fn("handleSubscribedEvent")(function* ( context: OpenCodeSessionContext, event: OpenCodeSubscribedEvent, @@ -845,10 +916,14 @@ export function makeOpenCodeAdapter( case "message.updated": { context.messageRoleById.set(event.properties.info.id, event.properties.info.role); if (event.properties.info.role === "assistant") { + if (turnId) { + rememberMessageTurn(context.turnIdByMessageId, event.properties.info.id, turnId); + } for (const part of context.partById.values()) { if (part.messageID !== event.properties.info.id) { continue; } + yield* captureActualModel(context, part, event); yield* emitAssistantTextDelta(context, part, turnId, event); } } @@ -857,6 +932,7 @@ export function makeOpenCodeAdapter( case "message.removed": { context.messageRoleById.delete(event.properties.messageID); + context.turnIdByMessageId.delete(event.properties.messageID); break; } @@ -908,8 +984,15 @@ export function makeOpenCodeAdapter( case "message.part.updated": { const part = event.properties.part; context.partById.set(part.id, part); + if (turnId) { + rememberMessageTurn(context.turnIdByMessageId, part.messageID, turnId); + } const messageRole = messageRoleForPart(context, part); + if (messageRole === "assistant") { + yield* captureActualModel(context, part, event); + } + if (messageRole === "assistant") { yield* emitAssistantTextDelta(context, part, turnId, event); } @@ -1074,7 +1157,18 @@ export function makeOpenCodeAdapter( } if (event.properties.status.type === "idle" && turnId) { + const actualModel = context.activeActualModel; context.activeTurnId = undefined; + context.activeActualModel = undefined; + if (actualModel) { + context.completedTurnsWithoutModel.delete(turnId); + } else { + rememberCompletedTurnWithoutModel( + context.completedTurnsWithoutModel, + turnId, + "completed", + ); + } yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); yield* emit({ ...(yield* buildEventBase({ @@ -1085,6 +1179,7 @@ export function makeOpenCodeAdapter( type: "turn.completed", payload: { state: "completed", + ...(actualModel ? { actualModel } : {}), }, }); } @@ -1094,7 +1189,20 @@ export function makeOpenCodeAdapter( case "session.error": { const message = sessionErrorMessage(event.properties.error); const activeTurnId = context.activeTurnId; + const actualModel = context.activeActualModel; context.activeTurnId = undefined; + context.activeActualModel = undefined; + if (activeTurnId) { + if (actualModel) { + context.completedTurnsWithoutModel.delete(activeTurnId); + } else { + rememberCompletedTurnWithoutModel( + context.completedTurnsWithoutModel, + activeTurnId, + "failed", + ); + } + } yield* updateProviderSession( context, { @@ -1114,6 +1222,7 @@ export function makeOpenCodeAdapter( payload: { state: "failed", errorMessage: message, + ...(actualModel ? { actualModel } : {}), }, }); } @@ -1397,9 +1506,12 @@ export function makeOpenCodeAdapter( partById: new Map(), emittedTextByPartId: new Map(), messageRoleById: new Map(), + turnIdByMessageId: new Map(), completedAssistantPartIds: new Set(), turns: [], activeTurnId: undefined, + activeActualModel: undefined, + completedTurnsWithoutModel: new Map(), activeAgent: undefined, activeVariant: undefined, stopped: yield* Ref.make(false), @@ -1476,6 +1588,9 @@ export function makeOpenCodeAdapter( const variant = getModelSelectionStringOptionValue(modelSelection, "variant"); context.activeTurnId = turnId; + if (steeringTurnId === undefined) { + context.activeActualModel = undefined; + } context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); context.activeVariant = variant; yield* updateProviderSession( @@ -1519,6 +1634,7 @@ export function makeOpenCodeAdapter( ? Effect.void : Effect.gen(function* () { context.activeTurnId = undefined; + context.activeActualModel = undefined; context.activeAgent = undefined; context.activeVariant = undefined; yield* updateProviderSession( diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 617ee0b80d1c..b84d8ff5d22b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -237,6 +237,35 @@ function buildAssistantTimelineEntry(text: string) { } describe("MessagesTimeline", () => { + it("shows the actual model on a completed assistant turn", () => { + const turnId = TurnId.make("turn-model"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("data-assistant-actual-model"); + expect(markup).toContain("Model: gpt-5.6-luna"); + }); + it("uses the larger leading inset only when the top fade is enabled", () => { const timelineEntries = [buildUserTimelineEntry("Hello")]; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c90aa771f8d1..6bb3ae75b17b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1124,20 +1124,37 @@ function AssistantTimelineRow({ row }: { row: Extract {row.showAssistantMeta ? ( -
- - {!row.message.streaming && ( +
+ {row.message.actualModel ? ( } + render={ +

+ } > - {formatDayAwareTimestamp(row.message.updatedAt, ctx.timestampFormat)} + Model: {row.message.actualModel} - - {formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)} - + Actual model: {row.message.actualModel} - )} + ) : null} +

+ + {!row.message.streaming && ( + + } + > + {formatDayAwareTimestamp(row.message.updatedAt, ctx.timestampFormat)} + + + {formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)} + + + )} +
) : null}
diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 970fd94b1a16..072220453f28 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -286,6 +286,9 @@ export function applyThreadDetailEvent( id: event.payload.messageId, role: event.payload.role, text: event.payload.text, + ...(event.payload.actualModel !== undefined + ? { actualModel: event.payload.actualModel } + : {}), ...(event.payload.attachments !== undefined ? { attachments: event.payload.attachments } : {}), @@ -309,6 +312,9 @@ export function applyThreadDetailEvent( : entry.text, streaming: message.streaming, ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), + ...(message.actualModel !== undefined + ? { actualModel: message.actualModel } + : {}), ...(message.streaming ? {} : { updatedAt: message.updatedAt }), ...(message.attachments !== undefined ? { attachments: message.attachments } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index cd9f3a747876..b0b17d6fab4b 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -256,6 +256,7 @@ export const OrchestrationMessage = Schema.Struct({ id: MessageId, role: OrchestrationMessageRole, text: Schema.String, + actualModel: Schema.optional(TrimmedNonEmptyString), attachments: Schema.optional(Schema.Array(ChatAttachment)), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, @@ -988,6 +989,7 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({ threadId: ThreadId, messageId: MessageId, turnId: Schema.optional(TurnId), + actualModel: Schema.optional(TrimmedNonEmptyString), createdAt: IsoDateTime, }); @@ -1237,6 +1239,7 @@ export const ThreadMessageSentPayload = Schema.Struct({ messageId: MessageId, role: OrchestrationMessageRole, text: Schema.String, + actualModel: Schema.optional(TrimmedNonEmptyString), attachments: Schema.optional(Schema.Array(ChatAttachment)), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index bd525e6542e2..821f73576fdb 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -363,6 +363,7 @@ export type TurnStartedPayload = typeof TurnStartedPayload.Type; const TurnCompletedPayload = Schema.Struct({ state: RuntimeTurnState, + actualModel: Schema.optional(TrimmedNonEmptyStringSchema), stopReason: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)), usage: Schema.optional(Schema.Unknown), modelUsage: Schema.optional(UnknownRecordSchema),