authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-02 20:50:04-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-02 20:50:04-07:00
log4f7760e6a0037b06917adaae1b8a220e3ee6e5cc
tree8ee96f13f8b13dd55fe58be845358fa0d6c8c18b
parent858b98b8fa618cf9b9528b5415493fcf0572ac88
signature Signed by PGP key B5690EEEBB952194

fix(server): Claude subagents show the model their agent file picks (#14994)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

7 files changed, 216 insertions(+), 8 deletions(-)

apps/mobile/src/features/threads/ThreadDetailScreen.tsx+10-2
...@@ -35,7 +35,7 @@ import {...@@ -35,7 +35,7 @@ import {
35 formatModelSelectionEffort,35 formatModelSelectionEffort,
36 type ProviderSubagentStatus,36 type ProviderSubagentStatus,
37} from "@t3tools/client-runtime/state/thread-execution";37} from "@t3tools/client-runtime/state/thread-execution";
38import { formatModelSlugName } from "@t3tools/shared/model";38import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model";
39import { isProviderNativeSubagentThread } from "@t3tools/contracts";39import { isProviderNativeSubagentThread } from "@t3tools/contracts";
40import type { QueuedRunEdit } from "../../state/queued-run-edit";40import type { QueuedRunEdit } from "../../state/queued-run-edit";
41import type { FollowUpBehavior } from "../../lib/followUpBehavior";41import type { FollowUpBehavior } from "../../lib/followUpBehavior";
...@@ -760,8 +760,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -760,8 +760,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
760 const providerSubagentProvider = props.serverConfig?.providers.find(760 const providerSubagentProvider = props.serverConfig?.providers.find(
761 (provider) => provider.instanceId === props.selectedThread.modelSelection.instanceId,761 (provider) => provider.instanceId === props.selectedThread.modelSelection.instanceId,
762 );762 );
763 // Providers can report a dated id or alias (claude-haiku-4-5-20251001).
764 const providerSubagentModelSlug = providerSubagentProvider
765 ? resolveSelectableModel(
766 providerSubagentProvider.driver,
767 props.selectedThread.modelSelection.model,
768 providerSubagentProvider.models,
769 )
770 : null;
763 const providerSubagentCatalogModel = providerSubagentProvider?.models.find(771 const providerSubagentCatalogModel = providerSubagentProvider?.models.find(
764 (model) => model.slug === props.selectedThread.modelSelection.model,772 (model) => model.slug === providerSubagentModelSlug,
765 );773 );
766 const workspaceContentWidth = useWorkspaceContentWidth();774 const workspaceContentWidth = useWorkspaceContentWidth();
767 // Clearing animated width can retain the unfolded width after Android resumes folded.775 // Clearing animated width can retain the unfolded width after Android resumes folded.
apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts+105-1
...@@ -33,6 +33,7 @@ import * as EventStore from "./EventStore.ts";...@@ -33,6 +33,7 @@ import * as EventStore from "./EventStore.ts";
33import * as IdAllocator from "./IdAllocator.ts";33import * as IdAllocator from "./IdAllocator.ts";
34import * as ProjectionStore from "./ProjectionStore.ts";34import * as ProjectionStore from "./ProjectionStore.ts";
35import * as ProviderEventIngestor from "./ProviderEventIngestor.ts";35import * as ProviderEventIngestor from "./ProviderEventIngestor.ts";
36import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
36import { makeProviderFailure } from "./ProviderFailure.ts";37import { makeProviderFailure } from "./ProviderFailure.ts";
37import {38import {
38 makeProviderEventRoutingState,39 makeProviderEventRoutingState,
...@@ -54,8 +55,16 @@ const TestLayer = Layer.mergeAll(...@@ -54,8 +55,16 @@ const TestLayer = Layer.mergeAll(
54 TestStoresLayer,55 TestStoresLayer,
55 TestEventSinkLayer,56 TestEventSinkLayer,
56 IdAllocator.layer,57 IdAllocator.layer,
58 ThreadCommandExecutor.layer,
57 ProviderEventIngestor.layer.pipe(59 ProviderEventIngestor.layer.pipe(
58 Layer.provide(Layer.mergeAll(TestStoresLayer, TestEventSinkLayer, IdAllocator.layer)),60 Layer.provide(
61 Layer.mergeAll(
62 TestStoresLayer,
63 TestEventSinkLayer,
64 IdAllocator.layer,
65 ThreadCommandExecutor.layer,
66 ),
67 ),
59 ),68 ),
60);69);
61const modelSelection = {70const modelSelection = {
...@@ -1234,4 +1243,99 @@ layer("ProviderEventIngestorV2", (it) => {...@@ -1234,4 +1243,99 @@ layer("ProviderEventIngestorV2", (it) => {
1234 assert.equal(messageEvents[0]?.threadId, childThreadId);1243 assert.equal(messageEvents[0]?.threadId, childThreadId);
1235 }),1244 }),
1236 );1245 );
1246
1247 it.effect("moves a native subagent's thread to the model its provider reports later", () =>
1248 Effect.gen(function* () {
1249 const now = yield* DateTime.now;
1250 const eventSink = yield* EventSink.EventSinkV2;
1251 const projectionStore = yield* ProjectionStore.ProjectionStoreV2;
1252 const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2;
1253 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1254 const rootEvent = yield* threadCreatedEvent(now);
1255 if (rootEvent.type !== "thread.created") {
1256 throw new Error("Expected a thread.created fixture event");
1257 }
1258 const childThreadId = idAllocator.derive.threadFromProviderThread({
1259 driver: CODEX_DRIVER,
1260 nativeThreadId: "native-late-model-subagent",
1261 });
1262 const providerSessionId = yield* idAllocator.allocate.providerSession({
1263 providerInstanceId: modelSelection.instanceId,
1264 threadId: rootEvent.threadId,
1265 });
1266 const ingest = (event: ProviderEventIngestor.ProviderEventIngestInput["event"]) =>
1267 ingestor.ingestNormalized({
1268 providerSessionId,
1269 providerInstanceId: modelSelection.instanceId,
1270 threadId: rootEvent.threadId,
1271 event,
1272 });
1273 yield* eventSink.write({ events: [rootEvent] });
1274 // The subagent's thread starts on the parent's model and options.
1275 yield* ingest({
1276 type: "app_thread.created",
1277 driver: CODEX_DRIVER,
1278 appThread: {
1279 ...rootEvent.payload,
1280 id: childThreadId,
1281 title: "review design",
1282 modelSelection: {
1283 ...modelSelection,
1284 options: [{ id: "reasoningEffort", value: "xhigh" }],
1285 },
1286 activeProviderThreadId: null,
1287 lineage: {
1288 parentThreadId: rootEvent.threadId,
1289 relationshipToParent: "subagent",
1290 rootThreadId: rootEvent.threadId,
1291 },
1292 },
1293 });
1294 const subagentUpdated = {
1295 type: "subagent.updated",
1296 driver: CODEX_DRIVER,
1297 subagent: {
1298 id: NodeId.make("node:late-model-subagent"),
1299 threadId: rootEvent.threadId,
1300 runId: null,
1301 parentNodeId: NodeId.make("node:root"),
1302 origin: "provider_native",
1303 createdBy: "agent",
1304 driver: CODEX_DRIVER,
1305 providerInstanceId: modelSelection.instanceId,
1306 providerThreadId: null,
1307 childThreadId,
1308 nativeTaskRef: null,
1309 prompt: "Review the design",
1310 title: "review design",
1311 model: "gpt-6.1-sol",
1312 status: "running",
1313 result: null,
1314 startedAt: now,
1315 completedAt: null,
1316 updatedAt: now,
1317 },
1318 } satisfies ProviderEventIngestor.ProviderEventIngestInput["event"];
1319
1320 const first = yield* ingest(subagentUpdated);
1321 const repeated = yield* ingest(subagentUpdated);
1322 const childThread = yield* projectionStore.getThread(childThreadId);
1323
1324 assert.deepEqual(
1325 first.map((stored) => [stored.event.type, stored.event.threadId]),
1326 [
1327 ["subagent.updated", rootEvent.threadId],
1328 ["thread.model-selection-updated", childThreadId],
1329 ],
1330 );
1331 assert.deepEqual(
1332 repeated.map((stored) => stored.event.type),
1333 ["subagent.updated"],
1334 );
1335 assert.deepEqual(childThread.modelSelection, {
1336 instanceId: modelSelection.instanceId,
1337 model: "gpt-6.1-sol",
1338 });
1339 }),
1340 );
1237});1341});
apps/server/src/orchestration-v2/ProviderEventIngestor.ts+64-1
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 type OrchestrationV2PlanArtifact,6 type OrchestrationV2PlanArtifact,
7 type OrchestrationV2Run,7 type OrchestrationV2Run,
8 type OrchestrationV2ProviderTurn,8 type OrchestrationV2ProviderTurn,
9 type OrchestrationV2Subagent,
9 type ModelSelection,10 type ModelSelection,
10 type RuntimeMode,11 type RuntimeMode,
11 type ProviderInteractionMode,12 type ProviderInteractionMode,
...@@ -31,6 +32,7 @@ import * as ProjectionStore from "./ProjectionStore.ts";...@@ -31,6 +32,7 @@ import * as ProjectionStore from "./ProjectionStore.ts";
31import * as IdAllocator from "./IdAllocator.ts";32import * as IdAllocator from "./IdAllocator.ts";
32import { ProviderAdapterV2Event } from "./ProviderAdapter.ts";33import { ProviderAdapterV2Event } from "./ProviderAdapter.ts";
33import { makeProviderFailureTurnItem } from "./ProviderFailure.ts";34import { makeProviderFailureTurnItem } from "./ProviderFailure.ts";
35import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
3436
35export class ProviderEventNormalizeError extends Schema.TaggedError<ProviderEventNormalizeError>()(37export class ProviderEventNormalizeError extends Schema.TaggedError<ProviderEventNormalizeError>()(
36 "ProviderEventNormalizeError",38 "ProviderEventNormalizeError",
...@@ -249,13 +251,17 @@ const decodeDomainEvent = Schema.decodeUnknownEffect(OrchestrationV2DomainEvent)...@@ -249,13 +251,17 @@ const decodeDomainEvent = Schema.decodeUnknownEffect(OrchestrationV2DomainEvent)
249export const layer: Layer.Layer<251export const layer: Layer.Layer<
250 ProviderEventIngestorV2,252 ProviderEventIngestorV2,
251 never,253 never,
252 EventSink.EventSinkV2 | IdAllocator.IdAllocatorV2 | ProjectionStore.ProjectionStoreV2254 | EventSink.EventSinkV2
255 | IdAllocator.IdAllocatorV2
256 | ProjectionStore.ProjectionStoreV2
257 | ThreadCommandExecutor.ThreadCommandExecutor
253> = Layer.effect(258> = Layer.effect(
254 ProviderEventIngestorV2,259 ProviderEventIngestorV2,
255 Effect.gen(function* () {260 Effect.gen(function* () {
256 const eventSink = yield* EventSink.EventSinkV2;261 const eventSink = yield* EventSink.EventSinkV2;
257 const projections = yield* ProjectionStore.ProjectionStoreV2;262 const projections = yield* ProjectionStore.ProjectionStoreV2;
258 const idAllocator = yield* IdAllocator.IdAllocatorV2;263 const idAllocator = yield* IdAllocator.IdAllocatorV2;
264 const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor;
259 const analytics = yield* ProviderTurnAnalytics;265 const analytics = yield* ProviderTurnAnalytics;
260 const completedTurnAnalytics = new Set<string>();266 const completedTurnAnalytics = new Set<string>();
261267
...@@ -338,6 +344,48 @@ export const layer: Layer.Layer<...@@ -338,6 +344,48 @@ export const layer: Layer.Layer<
338 },344 },
339 );345 );
340346
347 /**
348 * A native subagent's thread starts on the parent's model when the
349 * provider names the real one later (a Claude agent file's model arrives
350 * with the subagent's first reply). Clients read the thread's model, so
351 * move the thread to the reported one. Thread commands rewrite the whole
352 * thread row under the thread's lock, so this read and write take it too.
353 */
354 const syncSubagentThreadModel = Effect.fn("ProviderEventIngestor.syncSubagentThreadModel")(
355 function* (input: ProviderEventIngestInput, subagent: OrchestrationV2Subagent) {
356 const { childThreadId, model } = subagent;
357 if (subagent.origin !== "provider_native" || childThreadId === null || model === null) {
358 return [];
359 }
360 const staleThread = projections.getThread(childThreadId).pipe(
361 Effect.map((thread) => (thread.modelSelection.model === model ? null : thread)),
362 Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }),
363 );
364 // Nearly every update already matches; only a mismatch takes the lock.
365 if ((yield* staleThread) === null) return [];
366 return yield* threadCommands.withLock(
367 childThreadId,
368 Effect.gen(function* () {
369 const thread = yield* staleThread;
370 if (thread === null) return [];
371 const now = yield* DateTime.now;
372 const event = yield* makeDomainEvent(input, {
373 type: "thread.model-selection-updated",
374 threadId: thread.id,
375 // The parent's options belong to the parent's model.
376 payload: {
377 ...thread,
378 modelSelection: { instanceId: thread.modelSelection.instanceId, model },
379 updatedAt: now,
380 },
381 occurredAt: now,
382 });
383 return yield* eventSink.write({ events: [event] });
384 }),
385 );
386 },
387 );
388
341 const normalize: ProviderEventIngestorV2Shape["normalize"] = (input) =>389 const normalize: ProviderEventIngestorV2Shape["normalize"] = (input) =>
342 Effect.gen(function* () {390 Effect.gen(function* () {
343 switch (input.event.type) {391 switch (input.event.type) {
...@@ -543,6 +591,21 @@ export const layer: Layer.Layer<...@@ -543,6 +591,21 @@ export const layer: Layer.Layer<
543 .pipe(Effect.mapError(mapWriteError));591 .pipe(Effect.mapError(mapWriteError));
544 return result.storedEvents;592 return result.storedEvents;
545 }).pipe(593 }).pipe(
594 Effect.flatMap((storedEvents) =>
595 storedEvents.length === 0 || input.event.type !== "subagent.updated"
596 ? Effect.succeed(storedEvents)
597 : syncSubagentThreadModel(input, input.event.subagent).pipe(
598 Effect.map((synced) => [...storedEvents, ...synced]),
599 Effect.mapError(
600 (cause) =>
601 new ProviderEventPublishError({
602 providerSessionId: input.providerSessionId,
603 eventCount: 1,
604 cause,
605 }),
606 ),
607 ),
608 ),
546 Effect.tap((storedEvents) =>609 Effect.tap((storedEvents) =>
547 Effect.gen(function* () {610 Effect.gen(function* () {
548 if (storedEvents.length === 0 || input.event.type !== "provider_turn.updated") return;611 if (storedEvents.length === 0 || input.event.type !== "provider_turn.updated") return;
apps/server/src/orchestration-v2/ProviderSessionManager.test.ts+9-1
...@@ -52,6 +52,7 @@ import {...@@ -52,6 +52,7 @@ import {
52} from "./ProviderAdapter.ts";52} from "./ProviderAdapter.ts";
53import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";53import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
54import * as ProviderEventIngestor from "./ProviderEventIngestor.ts";54import * as ProviderEventIngestor from "./ProviderEventIngestor.ts";
55import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
55import * as ProviderSessionManager from "./ProviderSessionManager.ts";56import * as ProviderSessionManager from "./ProviderSessionManager.ts";
5657
57const TestDatabaseLayer = SqlitePersistenceMemory;58const TestDatabaseLayer = SqlitePersistenceMemory;
...@@ -383,7 +384,14 @@ function makeTestLayer(input: {...@@ -383,7 +384,14 @@ function makeTestLayer(input: {
383 }),384 }),
384 );385 );
385 const providerEventIngestorTestLayer = ProviderEventIngestor.layer.pipe(386 const providerEventIngestorTestLayer = ProviderEventIngestor.layer.pipe(
386 Layer.provide(Layer.mergeAll(configuredEventSinkLayer, IdAllocator.layer, TestStoresLayer)),387 Layer.provide(
388 Layer.mergeAll(
389 configuredEventSinkLayer,
390 IdAllocator.layer,
391 TestStoresLayer,
392 ThreadCommandExecutor.layer,
393 ),
394 ),
387 );395 );
388 return Layer.mergeAll(396 return Layer.mergeAll(
389 TestStoresLayer,397 TestStoresLayer,
apps/server/src/orchestration-v2/runtimeLayer.ts+9-1
...@@ -35,6 +35,7 @@ import { layer as providerContinuationRequestsLayer } from "./ProviderContinuati...@@ -35,6 +35,7 @@ import { layer as providerContinuationRequestsLayer } from "./ProviderContinuati
35import { workerLive as providerContinuationWorkerLive } from "./ProviderContinuationService.ts";35import { workerLive as providerContinuationWorkerLive } from "./ProviderContinuationService.ts";
36import { layer as threadTitleRegenerationServiceLayer } from "./ThreadTitleRegenerationService.ts";36import { layer as threadTitleRegenerationServiceLayer } from "./ThreadTitleRegenerationService.ts";
37import { layer as providerEventIngestorLayer } from "./ProviderEventIngestor.ts";37import { layer as providerEventIngestorLayer } from "./ProviderEventIngestor.ts";
38import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
38import { layer as providerSessionManagerLayer } from "./ProviderSessionManager.ts";39import { layer as providerSessionManagerLayer } from "./ProviderSessionManager.ts";
39import { layer as providerRuntimeRecoveryLayer } from "./ProviderRuntimeRecoveryService.ts";40import { layer as providerRuntimeRecoveryLayer } from "./ProviderRuntimeRecoveryService.ts";
40import { layer as providerSwitchServiceLayer } from "./ProviderSwitchService.ts";41import { layer as providerSwitchServiceLayer } from "./ProviderSwitchService.ts";
...@@ -98,7 +99,14 @@ export const ProjectServiceLayerLive = projectServiceLayer.pipe(...@@ -98,7 +99,14 @@ export const ProjectServiceLayerLive = projectServiceLayer.pipe(
98);99);
99100
100const providerEventIngestorProvided = providerEventIngestorLayer.pipe(101const providerEventIngestorProvided = providerEventIngestorLayer.pipe(
101 Layer.provide(Layer.mergeAll(eventSinkProvided, idAllocatorLayer, projectionStoreLayer)),102 Layer.provide(
103 Layer.mergeAll(
104 eventSinkProvided,
105 idAllocatorLayer,
106 projectionStoreLayer,
107 ThreadCommandExecutor.layer,
108 ),
109 ),
102);110);
103111
104const checkpointServiceProvided = checkpointServiceLayer.pipe(Layer.provide(idAllocatorLayer));112const checkpointServiceProvided = checkpointServiceLayer.pipe(Layer.provide(idAllocatorLayer));
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts+9-1
...@@ -50,6 +50,7 @@ import * as ThreadTitleRegenerationService from "../ThreadTitleRegenerationServi...@@ -50,6 +50,7 @@ import * as ThreadTitleRegenerationService from "../ThreadTitleRegenerationServi
50import * as RuntimePolicy from "../RuntimePolicy.ts";50import * as RuntimePolicy from "../RuntimePolicy.ts";
51import * as TurnItemPositionStore from "../TurnItemPositionStore.ts";51import * as TurnItemPositionStore from "../TurnItemPositionStore.ts";
52import * as RuntimeRequestService from "../RuntimeRequestService.ts";52import * as RuntimeRequestService from "../RuntimeRequestService.ts";
53import * as ThreadCommandExecutor from "../ThreadCommandExecutor.ts";
53import * as ThreadForkService from "../ThreadForkService.ts";54import * as ThreadForkService from "../ThreadForkService.ts";
54import {55import {
55 runOrchestratorV2Scenario,56 runOrchestratorV2Scenario,
...@@ -301,7 +302,14 @@ export function makeOrchestratorV2ReplayLayerWithRegistry<Error>(...@@ -301,7 +302,14 @@ export function makeOrchestratorV2ReplayLayerWithRegistry<Error>(
301 );302 );
302 const commandReceiptStoreProvided = CommandReceiptStore.layer.pipe(Layer.provide(databaseLayer));303 const commandReceiptStoreProvided = CommandReceiptStore.layer.pipe(Layer.provide(databaseLayer));
303 const providerEventIngestorProvided = ProviderEventIngestor.layer.pipe(304 const providerEventIngestorProvided = ProviderEventIngestor.layer.pipe(
304 Layer.provide(Layer.mergeAll(storesLayer, eventSinkProvided, IdAllocator.layer)),305 Layer.provide(
306 Layer.mergeAll(
307 storesLayer,
308 eventSinkProvided,
309 IdAllocator.layer,
310 ThreadCommandExecutor.layer,
311 ),
312 ),
305 );313 );
306 const vcsDriverRegistryLayer = VcsDriverRegistry.layer.pipe(314 const vcsDriverRegistryLayer = VcsDriverRegistry.layer.pipe(
307 Layer.provide(VcsProcess.layer),315 Layer.provide(VcsProcess.layer),
apps/web/src/components/ChatView.tsx+10-1
...@@ -116,6 +116,7 @@ import {...@@ -116,6 +116,7 @@ import {
116 createModelSelection,116 createModelSelection,
117 formatModelSlugName,117 formatModelSlugName,
118 resolvePromptInjectedEffort,118 resolvePromptInjectedEffort,
119 resolveSelectableModel,
119} from "@t3tools/shared/model";120} from "@t3tools/shared/model";
120import {121import {
121 projectScriptCwd,122 projectScriptCwd,
...@@ -4055,8 +4056,16 @@ export default function ChatView(props: ChatViewProps) {...@@ -4055,8 +4056,16 @@ export default function ChatView(props: ChatViewProps) {
4055 const showProviderSubagentBar = isProviderSubagent;4056 const showProviderSubagentBar = isProviderSubagent;
4056 const composerMounted = !showProviderSubagentBar;4057 const composerMounted = !showProviderSubagentBar;
4057 const providerSubagentModels = selectedProviderEntry?.models ?? EMPTY_PROVIDER_MODELS;4058 const providerSubagentModels = selectedProviderEntry?.models ?? EMPTY_PROVIDER_MODELS;
4059 // Providers can report a dated id or alias (claude-haiku-4-5-20251001).
4060 const providerSubagentModelSlug = selectedProviderEntry
4061 ? resolveSelectableModel(
4062 selectedProviderEntry.driverKind,
4063 activeThread?.modelSelection.model,
4064 providerSubagentModels,
4065 )
4066 : null;
4058 const providerSubagentCatalogModel = providerSubagentModels.find(4067 const providerSubagentCatalogModel = providerSubagentModels.find(
4059 (model) => model.slug === activeThread?.modelSelection.model,4068 (model) => model.slug === providerSubagentModelSlug,
4060 );4069 );
4061 const providerSubagentModelLabel = providerSubagentCatalogModel4070 const providerSubagentModelLabel = providerSubagentCatalogModel
4062 ? getTriggerDisplayModelName(providerSubagentCatalogModel)4071 ? getTriggerDisplayModelName(providerSubagentCatalogModel)