Skip to content
9 changes: 9 additions & 0 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,15 @@ function renderFeedEntry(
})}
{showAssistantMeta ? (
<View className="mt-1 flex-row items-center gap-1">
{message.actualModel ? (
<Text
accessibilityLabel={`Actual model: ${message.actualModel}`}
className="mr-1 max-w-[70%] font-t3-medium text-xs text-neutral-600 dark:text-neutral-400"
numberOfLines={1}
>
Model: {message.actualModel}
</Text>
) : null}
<CopyTextButton
accessibilityLabel="Copy message"
text={message.text}
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,11 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
turnId: event.payload.turnId,
role: event.payload.role,
text: nextText,
...(event.payload.actualModel !== undefined
? { actualModel: event.payload.actualModel }
: previousMessage?.actualModel !== undefined
? { actualModel: previousMessage.actualModel }
: {}),
...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}),
isStreaming: event.payload.streaming,
createdAt: previousMessage?.createdAt ?? event.payload.createdAt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
ModelSelection,
ProjectId,
ThreadId,
TrimmedNonEmptyString,
} from "@t3tools/contracts";
import * as Arr from "effect/Array";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -82,6 +83,7 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(
Struct.assign({
isStreaming: Schema.Number,
actualModel: Schema.NullOr(TrimmedNonEmptyString),
attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))),
}),
);
Expand Down Expand Up @@ -531,6 +533,7 @@ const makeProjectionSnapshotQuery = 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",
Expand Down Expand Up @@ -974,6 +977,7 @@ const makeProjectionSnapshotQuery = 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",
Expand Down Expand Up @@ -1217,6 +1221,7 @@ const makeProjectionSnapshotQuery = 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",
Expand Down Expand Up @@ -1559,6 +1564,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
id: row.messageId,
role: row.role,
text: row.text,
...(row.actualModel !== null ? { actualModel: row.actualModel } : {}),
...(row.attachments !== null ? { attachments: row.attachments } : {}),
turnId: row.turnId,
streaming: row.isStreaming === 1,
Expand Down Expand Up @@ -2619,6 +2625,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
id: row.messageId,
role: row.role,
text: row.text,
...(row.actualModel !== null ? { actualModel: row.actualModel } : {}),
turnId: row.turnId,
streaming: row.isStreaming === 1,
createdAt: row.createdAt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2621,6 +2621,72 @@ describe("ProviderRuntimeIngestion", () => {
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";
Expand Down
20 changes: 19 additions & 1 deletion apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
});
}
Expand Down Expand Up @@ -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) =>
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/orchestration/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 }
: {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
19 changes: 18 additions & 1 deletion apps/server/src/persistence/Layers/ProjectionThreadMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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))),
}),
);
Expand All @@ -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,
Expand All @@ -55,6 +57,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {
turn_id,
role,
text,
actual_model,
attachments_json,
is_streaming,
created_at,
Expand All @@ -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},
(
Expand All @@ -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
Expand All @@ -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",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}),
);
});
Loading
Loading