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 {
3535 formatModelSelectionEffort,
3636 type ProviderSubagentStatus,
3737} from "@t3tools/client-runtime/state/thread-execution";
38import { formatModelSlugName } from "@t3tools/shared/model";
38import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model";
3939import { isProviderNativeSubagentThread } from "@t3tools/contracts";
4040import type { QueuedRunEdit } from "../../state/queued-run-edit";
4141import type { FollowUpBehavior } from "../../lib/followUpBehavior";
......@@ -760,8 +760,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
760760 const providerSubagentProvider = props.serverConfig?.providers.find(
761761 (provider) => provider.instanceId === props.selectedThread.modelSelection.instanceId,
762762 );
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;
763771 const providerSubagentCatalogModel = providerSubagentProvider?.models.find(
764 (model) => model.slug === props.selectedThread.modelSelection.model,
772 (model) => model.slug === providerSubagentModelSlug,
765773 );
766774 const workspaceContentWidth = useWorkspaceContentWidth();
767775 // 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";
3333import * as IdAllocator from "./IdAllocator.ts";
3434import * as ProjectionStore from "./ProjectionStore.ts";
3535import * as ProviderEventIngestor from "./ProviderEventIngestor.ts";
36import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
3637import { makeProviderFailure } from "./ProviderFailure.ts";
3738import {
3839 makeProviderEventRoutingState,
......@@ -54,8 +55,16 @@ const TestLayer = Layer.mergeAll(
5455 TestStoresLayer,
5556 TestEventSinkLayer,
5657 IdAllocator.layer,
58 ThreadCommandExecutor.layer,
5759 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 ),
5968 ),
6069);
6170const modelSelection = {
......@@ -1234,4 +1243,99 @@ layer("ProviderEventIngestorV2", (it) => {
12341243 assert.equal(messageEvents[0]?.threadId, childThreadId);
12351244 }),
12361245 );
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 );
12371341});
apps/server/src/orchestration-v2/ProviderEventIngestor.ts+64-1
......@@ -6,6 +6,7 @@ import {
66 type OrchestrationV2PlanArtifact,
77 type OrchestrationV2Run,
88 type OrchestrationV2ProviderTurn,
9 type OrchestrationV2Subagent,
910 type ModelSelection,
1011 type RuntimeMode,
1112 type ProviderInteractionMode,
......@@ -31,6 +32,7 @@ import * as ProjectionStore from "./ProjectionStore.ts";
3132import * as IdAllocator from "./IdAllocator.ts";
3233import { ProviderAdapterV2Event } from "./ProviderAdapter.ts";
3334import { makeProviderFailureTurnItem } from "./ProviderFailure.ts";
35import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
3436
3537export class ProviderEventNormalizeError extends Schema.TaggedError<ProviderEventNormalizeError>()(
3638 "ProviderEventNormalizeError",
......@@ -249,13 +251,17 @@ const decodeDomainEvent = Schema.decodeUnknownEffect(OrchestrationV2DomainEvent)
249251export const layer: Layer.Layer<
250252 ProviderEventIngestorV2,
251253 never,
252 EventSink.EventSinkV2 | IdAllocator.IdAllocatorV2 | ProjectionStore.ProjectionStoreV2
254 | EventSink.EventSinkV2
255 | IdAllocator.IdAllocatorV2
256 | ProjectionStore.ProjectionStoreV2
257 | ThreadCommandExecutor.ThreadCommandExecutor
253258> = Layer.effect(
254259 ProviderEventIngestorV2,
255260 Effect.gen(function* () {
256261 const eventSink = yield* EventSink.EventSinkV2;
257262 const projections = yield* ProjectionStore.ProjectionStoreV2;
258263 const idAllocator = yield* IdAllocator.IdAllocatorV2;
264 const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor;
259265 const analytics = yield* ProviderTurnAnalytics;
260266 const completedTurnAnalytics = new Set<string>();
261267
......@@ -338,6 +344,48 @@ export const layer: Layer.Layer<
338344 },
339345 );
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
341389 const normalize: ProviderEventIngestorV2Shape["normalize"] = (input) =>
342390 Effect.gen(function* () {
343391 switch (input.event.type) {
......@@ -543,6 +591,21 @@ export const layer: Layer.Layer<
543591 .pipe(Effect.mapError(mapWriteError));
544592 return result.storedEvents;
545593 }).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 ),
546609 Effect.tap((storedEvents) =>
547610 Effect.gen(function* () {
548611 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 {
5252} from "./ProviderAdapter.ts";
5353import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
5454import * as ProviderEventIngestor from "./ProviderEventIngestor.ts";
55import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
5556import * as ProviderSessionManager from "./ProviderSessionManager.ts";
5657
5758const TestDatabaseLayer = SqlitePersistenceMemory;
......@@ -383,7 +384,14 @@ function makeTestLayer(input: {
383384 }),
384385 );
385386 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 ),
387395 );
388396 return Layer.mergeAll(
389397 TestStoresLayer,
apps/server/src/orchestration-v2/runtimeLayer.ts+9-1
......@@ -35,6 +35,7 @@ import { layer as providerContinuationRequestsLayer } from "./ProviderContinuati
3535import { workerLive as providerContinuationWorkerLive } from "./ProviderContinuationService.ts";
3636import { layer as threadTitleRegenerationServiceLayer } from "./ThreadTitleRegenerationService.ts";
3737import { layer as providerEventIngestorLayer } from "./ProviderEventIngestor.ts";
38import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
3839import { layer as providerSessionManagerLayer } from "./ProviderSessionManager.ts";
3940import { layer as providerRuntimeRecoveryLayer } from "./ProviderRuntimeRecoveryService.ts";
4041import { layer as providerSwitchServiceLayer } from "./ProviderSwitchService.ts";
......@@ -98,7 +99,14 @@ export const ProjectServiceLayerLive = projectServiceLayer.pipe(
9899);
99100
100101const 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 ),
102110);
103111
104112const 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
5050import * as RuntimePolicy from "../RuntimePolicy.ts";
5151import * as TurnItemPositionStore from "../TurnItemPositionStore.ts";
5252import * as RuntimeRequestService from "../RuntimeRequestService.ts";
53import * as ThreadCommandExecutor from "../ThreadCommandExecutor.ts";
5354import * as ThreadForkService from "../ThreadForkService.ts";
5455import {
5556 runOrchestratorV2Scenario,
......@@ -301,7 +302,14 @@ export function makeOrchestratorV2ReplayLayerWithRegistry<Error>(
301302 );
302303 const commandReceiptStoreProvided = CommandReceiptStore.layer.pipe(Layer.provide(databaseLayer));
303304 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 ),
305313 );
306314 const vcsDriverRegistryLayer = VcsDriverRegistry.layer.pipe(
307315 Layer.provide(VcsProcess.layer),
apps/web/src/components/ChatView.tsx+10-1
......@@ -116,6 +116,7 @@ import {
116116 createModelSelection,
117117 formatModelSlugName,
118118 resolvePromptInjectedEffort,
119 resolveSelectableModel,
119120} from "@t3tools/shared/model";
120121import {
121122 projectScriptCwd,
......@@ -4055,8 +4056,16 @@ export default function ChatView(props: ChatViewProps) {
40554056 const showProviderSubagentBar = isProviderSubagent;
40564057 const composerMounted = !showProviderSubagentBar;
40574058 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;
40584067 const providerSubagentCatalogModel = providerSubagentModels.find(
4059 (model) => model.slug === activeThread?.modelSelection.model,
4068 (model) => model.slug === providerSubagentModelSlug,
40604069 );
40614070 const providerSubagentModelLabel = providerSubagentCatalogModel
40624071 ? getTriggerDisplayModelName(providerSubagentCatalogModel)