From ba49da6f51703df91d86f95c037c4f6596b2ef2a Mon Sep 17 00:00:00 2001 From: Giuseppe Crescimanno Date: Fri, 14 Aug 2026 01:29:06 +0200 Subject: [PATCH 1/7] feat(opencode): show actual model per turn --- .../src/features/threads/ThreadFeed.tsx | 9 +++ .../Layers/ProjectionPipeline.ts | 5 ++ .../Layers/ProjectionSnapshotQuery.ts | 7 ++ .../Layers/ProviderRuntimeIngestion.test.ts | 66 +++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 20 ++++- apps/server/src/orchestration/decider.ts | 1 + apps/server/src/orchestration/projector.ts | 4 + .../Layers/ProjectionThreadMessages.test.ts | 37 ++++++++++ .../Layers/ProjectionThreadMessages.ts | 19 ++++- apps/server/src/persistence/Migrations.ts | 2 + ...ProjectionThreadMessageActualModel.test.ts | 28 +++++++ .../041_ProjectionThreadMessageActualModel.ts | 16 ++++ .../Services/ProjectionThreadMessages.ts | 2 + .../provider/Layers/OpenCodeAdapter.test.ts | 74 +++++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 25 +++++++ .../components/chat/MessagesTimeline.test.tsx | 29 ++++++++ .../src/components/chat/MessagesTimeline.tsx | 39 ++++++---- .../client-runtime/src/state/threadReducer.ts | 6 ++ packages/contracts/src/orchestration.ts | 3 + packages/contracts/src/providerRuntime.ts | 1 + 20 files changed, 377 insertions(+), 16 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/041_ProjectionThreadMessageActualModel.test.ts create mode 100644 apps/server/src/persistence/Migrations/041_ProjectionThreadMessageActualModel.ts diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 874c31249ce7..956e880de9d6 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1005,6 +1005,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 03253797242e..0ca191b50c40 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1189,6 +1189,7 @@ const make = Effect.gen(function* () { finalDeltaCommandTag: string; fallbackText?: string; hasProjectedMessage?: boolean; + actualModel?: string; }) => Effect.gen(function* () { const bufferedText = yield* takeBufferedAssistantText(input.messageId); @@ -1219,6 +1220,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, }); } @@ -1828,7 +1830,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) => @@ -1841,6 +1856,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 a48bb29e154b..1d0bf4e2b3e8 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1259,6 +1259,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 1385ccbaabec..9e9a1d967cb2 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -68,6 +68,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 +89,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 +208,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; } @@ -1069,6 +1074,75 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("emits the actual response model on turn completion", () => + 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: "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 } }, + }, + }, + }, + { + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(4), + 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).pipe(Effect.timeout("1 second"))); + const completed = events.find((event) => event.type === "turn.completed"); + NodeAssert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + NodeAssert.equal(completed.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 73c23b77e686..ce70f9e7bf36 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -204,6 +204,14 @@ function openCodeEventSessionTitle(event: OpenCodeSubscribedEvent): string | und return trimText(event.properties.info.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; @@ -218,6 +226,7 @@ interface OpenCodeSessionContext { readonly completedAssistantPartIds: Set; readonly turns: Array; activeTurnId: TurnId | undefined; + activeActualModel: string | undefined; activeAgent: string | undefined; activeVariant: string | undefined; /** @@ -893,6 +902,11 @@ export function makeOpenCodeAdapter( context.partById.set(part.id, part); const messageRole = messageRoleForPart(context, part); + const actualModel = actualModelFromPart(part); + if (messageRole === "assistant" && context.activeTurnId && actualModel) { + context.activeActualModel = actualModel; + } + if (messageRole === "assistant") { yield* emitAssistantTextDelta(context, part, turnId, event); } @@ -1057,7 +1071,9 @@ export function makeOpenCodeAdapter( } if (event.properties.status.type === "idle" && turnId) { + const actualModel = context.activeActualModel; context.activeTurnId = undefined; + context.activeActualModel = undefined; yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); yield* emit({ ...(yield* buildEventBase({ @@ -1068,6 +1084,7 @@ export function makeOpenCodeAdapter( type: "turn.completed", payload: { state: "completed", + ...(actualModel ? { actualModel } : {}), }, }); } @@ -1077,7 +1094,9 @@ 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; yield* updateProviderSession( context, { @@ -1097,6 +1116,7 @@ export function makeOpenCodeAdapter( payload: { state: "failed", errorMessage: message, + ...(actualModel ? { actualModel } : {}), }, }); } @@ -1382,6 +1402,7 @@ export function makeOpenCodeAdapter( completedAssistantPartIds: new Set(), turns: [], activeTurnId: undefined, + activeActualModel: undefined, activeAgent: undefined, activeVariant: undefined, stopped: yield* Ref.make(false), @@ -1458,6 +1479,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( @@ -1501,6 +1525,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 f22130906ebf..2858eb153602 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -226,6 +226,35 @@ function buildUserTimelineEntry(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 9cd1c78aa4d5..20676b6821f6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1122,20 +1122,31 @@ function AssistantTimelineRow({ row }: { row: Extract {row.showAssistantMeta ? ( -
- - {!row.message.streaming && ( - - } - > - {formatShortTimestamp(row.message.updatedAt, ctx.timestampFormat)} - - - {formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)} - - - )} +
+ {row.message.actualModel ? ( +

+ Model: {row.message.actualModel} +

+ ) : null} +
+ + {!row.message.streaming && ( + + } + > + {formatShortTimestamp(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 35fef721efa7..2a947316a03d 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -242,6 +242,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, @@ -974,6 +975,7 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({ threadId: ThreadId, messageId: MessageId, turnId: Schema.optional(TurnId), + actualModel: Schema.optional(TrimmedNonEmptyString), createdAt: IsoDateTime, }); @@ -1223,6 +1225,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), From 35fa06aef03b4aeddb9e9654bb84ff4b36ece55c Mon Sep 17 00:00:00 2001 From: Giuseppe Crescimanno Date: Fri, 14 Aug 2026 02:03:29 +0200 Subject: [PATCH 2/7] fix(opencode): address review feedback --- .../provider/Layers/OpenCodeAdapter.test.ts | 2 +- .../src/components/chat/MessagesTimeline.tsx | 20 ++++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 9e9a1d967cb2..3f7368737b9b 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1134,7 +1134,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }); releaseEvents?.(); - const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + const events = Array.from(yield* Fiber.join(eventsFiber)); const completed = events.find((event) => event.type === "turn.completed"); NodeAssert.equal(completed?.type, "turn.completed"); if (completed?.type === "turn.completed") { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 20676b6821f6..053c0beca2cf 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1124,13 +1124,19 @@ function AssistantTimelineRow({ row }: { row: Extract {row.message.actualModel ? ( -

- Model: {row.message.actualModel} -

+ + + } + > + Model: {row.message.actualModel} + + Actual model: {row.message.actualModel} + ) : null}
From b06a61ae7ee56b2e174b77458dfe66d9943c2abc Mon Sep 17 00:00:00 2001 From: Giuseppe Crescimanno Date: Fri, 14 Aug 2026 02:09:06 +0200 Subject: [PATCH 3/7] fix(opencode): preserve late model metadata --- .../provider/Layers/OpenCodeAdapter.test.ts | 27 +++++++------- .../src/provider/Layers/OpenCodeAdapter.ts | 37 ++++++++++++++++++- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 3f7368737b9b..d26427e52471 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1074,7 +1074,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); - it.effect("emits the actual response model on turn completion", () => + 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"); @@ -1090,6 +1090,13 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { 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: { @@ -1106,18 +1113,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }, }, }, - { - type: "session.status", - properties: { - sessionID: "http://127.0.0.1:9999/session", - status: { type: "idle" }, - }, - }, ]; const eventsFiber = yield* adapter.streamEvents.pipe( Stream.filter((event) => event.threadId === threadId), - Stream.take(4), + Stream.take(5), Stream.runCollect, Effect.forkChild, ); @@ -1135,11 +1135,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { releaseEvents?.(); const events = Array.from(yield* Fiber.join(eventsFiber)); - const completed = events.find((event) => event.type === "turn.completed"); - NodeAssert.equal(completed?.type, "turn.completed"); - if (completed?.type === "turn.completed") { - NodeAssert.equal(completed.payload.actualModel, "gpt-5.6-luna"); - } + 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"); }), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index ce70f9e7bf36..36294beb593e 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -221,12 +221,16 @@ interface OpenCodeSessionContext { readonly pendingPermissions: Map; readonly pendingQuestions: Map; readonly messageRoleById: Map; + readonly assistantTurnIdByMessageId: Map; readonly partById: Map; readonly emittedTextByPartId: Map; readonly completedAssistantPartIds: Set; readonly turns: Array; activeTurnId: TurnId | undefined; activeActualModel: string | undefined; + completedTurnWithoutModel: + | { readonly turnId: TurnId; readonly state: "completed" | "failed" } + | undefined; activeAgent: string | undefined; activeVariant: string | undefined; /** @@ -837,6 +841,9 @@ 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) { + context.assistantTurnIdByMessageId.set(event.properties.info.id, turnId); + } for (const part of context.partById.values()) { if (part.messageID !== event.properties.info.id) { continue; @@ -849,6 +856,7 @@ export function makeOpenCodeAdapter( case "message.removed": { context.messageRoleById.delete(event.properties.messageID); + context.assistantTurnIdByMessageId.delete(event.properties.messageID); break; } @@ -903,8 +911,26 @@ export function makeOpenCodeAdapter( const messageRole = messageRoleForPart(context, part); const actualModel = actualModelFromPart(part); - if (messageRole === "assistant" && context.activeTurnId && actualModel) { - context.activeActualModel = actualModel; + const partTurnId = context.assistantTurnIdByMessageId.get(part.messageID); + if (messageRole === "assistant" && partTurnId && actualModel) { + if (context.activeTurnId === partTurnId) { + context.activeActualModel = actualModel; + } else if (context.completedTurnWithoutModel?.turnId === partTurnId) { + const completedTurn = context.completedTurnWithoutModel; + context.completedTurnWithoutModel = undefined; + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: completedTurn.turnId, + raw: event, + })), + type: "turn.completed", + payload: { + state: completedTurn.state, + actualModel, + }, + }); + } } if (messageRole === "assistant") { @@ -1074,6 +1100,9 @@ export function makeOpenCodeAdapter( const actualModel = context.activeActualModel; context.activeTurnId = undefined; context.activeActualModel = undefined; + context.completedTurnWithoutModel = actualModel + ? undefined + : { turnId, state: "completed" }; yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); yield* emit({ ...(yield* buildEventBase({ @@ -1097,6 +1126,8 @@ export function makeOpenCodeAdapter( const actualModel = context.activeActualModel; context.activeTurnId = undefined; context.activeActualModel = undefined; + context.completedTurnWithoutModel = + activeTurnId && !actualModel ? { turnId: activeTurnId, state: "failed" } : undefined; yield* updateProviderSession( context, { @@ -1399,10 +1430,12 @@ export function makeOpenCodeAdapter( partById: new Map(), emittedTextByPartId: new Map(), messageRoleById: new Map(), + assistantTurnIdByMessageId: new Map(), completedAssistantPartIds: new Set(), turns: [], activeTurnId: undefined, activeActualModel: undefined, + completedTurnWithoutModel: undefined, activeAgent: undefined, activeVariant: undefined, stopped: yield* Ref.make(false), From 39137e46c158e8334f0014126318e9c660a424ae Mon Sep 17 00:00:00 2001 From: Giuseppe Crescimanno Date: Fri, 14 Aug 2026 02:19:51 +0200 Subject: [PATCH 4/7] fix(opencode): handle reordered model events --- .../provider/Layers/OpenCodeAdapter.test.ts | 68 ++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 91 ++++++++++++------- 2 files changed, 125 insertions(+), 34 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index d26427e52471..0823913f1030 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1142,6 +1142,74 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + 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 36294beb593e..aca655973b5b 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -221,16 +221,14 @@ interface OpenCodeSessionContext { readonly pendingPermissions: Map; readonly pendingQuestions: Map; readonly messageRoleById: Map; - readonly assistantTurnIdByMessageId: Map; + readonly turnIdByMessageId: Map; readonly partById: Map; readonly emittedTextByPartId: Map; readonly completedAssistantPartIds: Set; readonly turns: Array; activeTurnId: TurnId | undefined; activeActualModel: string | undefined; - completedTurnWithoutModel: - | { readonly turnId: TurnId; readonly state: "completed" | "failed" } - | undefined; + readonly completedTurnsWithoutModel: Map; activeAgent: string | undefined; activeVariant: string | undefined; /** @@ -795,6 +793,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, @@ -842,12 +873,13 @@ export function makeOpenCodeAdapter( context.messageRoleById.set(event.properties.info.id, event.properties.info.role); if (event.properties.info.role === "assistant") { if (turnId) { - context.assistantTurnIdByMessageId.set(event.properties.info.id, turnId); + context.turnIdByMessageId.set(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); } } @@ -856,7 +888,7 @@ export function makeOpenCodeAdapter( case "message.removed": { context.messageRoleById.delete(event.properties.messageID); - context.assistantTurnIdByMessageId.delete(event.properties.messageID); + context.turnIdByMessageId.delete(event.properties.messageID); break; } @@ -908,29 +940,13 @@ export function makeOpenCodeAdapter( case "message.part.updated": { const part = event.properties.part; context.partById.set(part.id, part); + if (turnId && !context.turnIdByMessageId.has(part.messageID)) { + context.turnIdByMessageId.set(part.messageID, turnId); + } const messageRole = messageRoleForPart(context, part); - const actualModel = actualModelFromPart(part); - const partTurnId = context.assistantTurnIdByMessageId.get(part.messageID); - if (messageRole === "assistant" && partTurnId && actualModel) { - if (context.activeTurnId === partTurnId) { - context.activeActualModel = actualModel; - } else if (context.completedTurnWithoutModel?.turnId === partTurnId) { - const completedTurn = context.completedTurnWithoutModel; - context.completedTurnWithoutModel = undefined; - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId: completedTurn.turnId, - raw: event, - })), - type: "turn.completed", - payload: { - state: completedTurn.state, - actualModel, - }, - }); - } + if (messageRole === "assistant") { + yield* captureActualModel(context, part, event); } if (messageRole === "assistant") { @@ -1100,9 +1116,11 @@ export function makeOpenCodeAdapter( const actualModel = context.activeActualModel; context.activeTurnId = undefined; context.activeActualModel = undefined; - context.completedTurnWithoutModel = actualModel - ? undefined - : { turnId, state: "completed" }; + if (actualModel) { + context.completedTurnsWithoutModel.delete(turnId); + } else { + context.completedTurnsWithoutModel.set(turnId, "completed"); + } yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); yield* emit({ ...(yield* buildEventBase({ @@ -1126,8 +1144,13 @@ export function makeOpenCodeAdapter( const actualModel = context.activeActualModel; context.activeTurnId = undefined; context.activeActualModel = undefined; - context.completedTurnWithoutModel = - activeTurnId && !actualModel ? { turnId: activeTurnId, state: "failed" } : undefined; + if (activeTurnId) { + if (actualModel) { + context.completedTurnsWithoutModel.delete(activeTurnId); + } else { + context.completedTurnsWithoutModel.set(activeTurnId, "failed"); + } + } yield* updateProviderSession( context, { @@ -1430,12 +1453,12 @@ export function makeOpenCodeAdapter( partById: new Map(), emittedTextByPartId: new Map(), messageRoleById: new Map(), - assistantTurnIdByMessageId: new Map(), + turnIdByMessageId: new Map(), completedAssistantPartIds: new Set(), turns: [], activeTurnId: undefined, activeActualModel: undefined, - completedTurnWithoutModel: undefined, + completedTurnsWithoutModel: new Map(), activeAgent: undefined, activeVariant: undefined, stopped: yield* Ref.make(false), From 9d9258a858bf9efeac55db5ea7177ed27c184226 Mon Sep 17 00:00:00 2001 From: Giuseppe Crescimanno Date: Fri, 14 Aug 2026 02:34:00 +0200 Subject: [PATCH 5/7] fix(opencode): bound pending model metadata --- .../provider/Layers/OpenCodeAdapter.test.ts | 19 ++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 29 +++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 0823913f1030..8b138bdcb31e 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,7 @@ import { isSameOpenCodeDirectory, makeOpenCodeAdapter, mergeOpenCodeAssistantText, + rememberCompletedTurnWithoutModel, } from "./OpenCodeAdapter.ts"; // Test-local service tag so the rest of the file can keep using `yield* OpenCodeAdapter`. @@ -1060,6 +1062,23 @@ 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.effect("appends raw assistant text deltas and reconciles part update snapshots", () => Effect.sync(() => { const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index aca655973b5b..1cbfc00f1bbe 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 @@ -248,6 +249,22 @@ 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 interface OpenCodeAdapterLiveOptions { readonly instanceId?: ProviderInstanceId; readonly environment?: NodeJS.ProcessEnv; @@ -1119,7 +1136,11 @@ export function makeOpenCodeAdapter( if (actualModel) { context.completedTurnsWithoutModel.delete(turnId); } else { - context.completedTurnsWithoutModel.set(turnId, "completed"); + rememberCompletedTurnWithoutModel( + context.completedTurnsWithoutModel, + turnId, + "completed", + ); } yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); yield* emit({ @@ -1148,7 +1169,11 @@ export function makeOpenCodeAdapter( if (actualModel) { context.completedTurnsWithoutModel.delete(activeTurnId); } else { - context.completedTurnsWithoutModel.set(activeTurnId, "failed"); + rememberCompletedTurnWithoutModel( + context.completedTurnsWithoutModel, + activeTurnId, + "failed", + ); } } yield* updateProviderSession( From 1257cff5dca2a655d4fe963ca10f3362ff179a9f Mon Sep 17 00:00:00 2001 From: Giuseppe Crescimanno Date: Fri, 14 Aug 2026 02:45:37 +0200 Subject: [PATCH 6/7] fix(opencode): retain original message turn --- .../src/provider/Layers/OpenCodeAdapter.test.ts | 11 +++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 16 +++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 8b138bdcb31e..3d8b885ae636 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -39,6 +39,7 @@ import { makeOpenCodeAdapter, mergeOpenCodeAssistantText, rememberCompletedTurnWithoutModel, + rememberMessageTurn, } from "./OpenCodeAdapter.ts"; // Test-local service tag so the rest of the file can keep using `yield* OpenCodeAdapter`. @@ -1079,6 +1080,16 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { 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"); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 1cbfc00f1bbe..9a1a331b5011 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -265,6 +265,16 @@ export function rememberCompletedTurnWithoutModel( } } +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; @@ -890,7 +900,7 @@ export function makeOpenCodeAdapter( context.messageRoleById.set(event.properties.info.id, event.properties.info.role); if (event.properties.info.role === "assistant") { if (turnId) { - context.turnIdByMessageId.set(event.properties.info.id, turnId); + rememberMessageTurn(context.turnIdByMessageId, event.properties.info.id, turnId); } for (const part of context.partById.values()) { if (part.messageID !== event.properties.info.id) { @@ -957,8 +967,8 @@ export function makeOpenCodeAdapter( case "message.part.updated": { const part = event.properties.part; context.partById.set(part.id, part); - if (turnId && !context.turnIdByMessageId.has(part.messageID)) { - context.turnIdByMessageId.set(part.messageID, turnId); + if (turnId) { + rememberMessageTurn(context.turnIdByMessageId, part.messageID, turnId); } const messageRole = messageRoleForPart(context, part); From f1ee06297d185384d5f9201b13d6eaa05d2c8e9a Mon Sep 17 00:00:00 2001 From: Giuseppe Crescimanno Date: Sat, 22 Aug 2026 11:06:39 +0200 Subject: [PATCH 7/7] fix(web): let model labels shrink in narrow views --- apps/web/src/components/chat/MessagesTimeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 258544ea0274..6bb3ae75b17b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1131,7 +1131,7 @@ function AssistantTimelineRow({ row }: { row: Extract } >