| author | |
| committer | |
| log | 4f7760e6a0037b06917adaae1b8a220e3ee6e5cc |
| tree | 8ee96f13f8b13dd55fe58be845358fa0d6c8c18b |
| parent | 858b98b8fa618cf9b9528b5415493fcf0572ac88 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 35 | formatModelSelectionEffort, |
| 36 | 36 | type ProviderSubagentStatus, |
| 37 | 37 | } from "@t3tools/client-runtime/state/thread-execution"; |
| 38 | import { formatModelSlugName } from "@t3tools/shared/model"; | |
| 38 | import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; | |
| 39 | 39 | import { isProviderNativeSubagentThread } from "@t3tools/contracts"; |
| 40 | 40 | import type { QueuedRunEdit } from "../../state/queued-run-edit"; |
| 41 | 41 | import type { FollowUpBehavior } from "../../lib/followUpBehavior"; |
| ... | ... | @@ -760,8 +760,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 760 | 760 | const providerSubagentProvider = props.serverConfig?.providers.find( |
| 761 | 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 | 771 | const providerSubagentCatalogModel = providerSubagentProvider?.models.find( |
| 764 | (model) => model.slug === props.selectedThread.modelSelection.model, | |
| 772 | (model) => model.slug === providerSubagentModelSlug, | |
| 765 | 773 | ); |
| 766 | 774 | const workspaceContentWidth = useWorkspaceContentWidth(); |
| 767 | 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 | 33 | import * as IdAllocator from "./IdAllocator.ts"; |
| 34 | 34 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| 35 | 35 | import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; |
| 36 | import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; | |
| 36 | 37 | import { makeProviderFailure } from "./ProviderFailure.ts"; |
| 37 | 38 | import { |
| 38 | 39 | makeProviderEventRoutingState, |
| ... | ... | @@ -54,8 +55,16 @@ const TestLayer = Layer.mergeAll( |
| 54 | 55 | TestStoresLayer, |
| 55 | 56 | TestEventSinkLayer, |
| 56 | 57 | IdAllocator.layer, |
| 58 | ThreadCommandExecutor.layer, | |
| 57 | 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 | ); |
| 61 | 70 | const modelSelection = { |
| ... | ... | @@ -1234,4 +1243,99 @@ layer("ProviderEventIngestorV2", (it) => { |
| 1234 | 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 | type OrchestrationV2PlanArtifact, |
| 7 | 7 | type OrchestrationV2Run, |
| 8 | 8 | type OrchestrationV2ProviderTurn, |
| 9 | type OrchestrationV2Subagent, | |
| 9 | 10 | type ModelSelection, |
| 10 | 11 | type RuntimeMode, |
| 11 | 12 | type ProviderInteractionMode, |
| ... | ... | @@ -31,6 +32,7 @@ import * as ProjectionStore from "./ProjectionStore.ts"; |
| 31 | 32 | import * as IdAllocator from "./IdAllocator.ts"; |
| 32 | 33 | import { ProviderAdapterV2Event } from "./ProviderAdapter.ts"; |
| 33 | 34 | import { makeProviderFailureTurnItem } from "./ProviderFailure.ts"; |
| 35 | import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; | |
| 34 | 36 | |
| 35 | 37 | export class ProviderEventNormalizeError extends Schema.TaggedError<ProviderEventNormalizeError>()( |
| 36 | 38 | "ProviderEventNormalizeError", |
| ... | ... | @@ -249,13 +251,17 @@ const decodeDomainEvent = Schema.decodeUnknownEffect(OrchestrationV2DomainEvent) |
| 249 | 251 | export const layer: Layer.Layer< |
| 250 | 252 | ProviderEventIngestorV2, |
| 251 | 253 | never, |
| 252 | EventSink.EventSinkV2 | IdAllocator.IdAllocatorV2 | ProjectionStore.ProjectionStoreV2 | |
| 254 | | EventSink.EventSinkV2 | |
| 255 | | IdAllocator.IdAllocatorV2 | |
| 256 | | ProjectionStore.ProjectionStoreV2 | |
| 257 | | ThreadCommandExecutor.ThreadCommandExecutor | |
| 253 | 258 | > = Layer.effect( |
| 254 | 259 | ProviderEventIngestorV2, |
| 255 | 260 | Effect.gen(function* () { |
| 256 | 261 | const eventSink = yield* EventSink.EventSinkV2; |
| 257 | 262 | const projections = yield* ProjectionStore.ProjectionStoreV2; |
| 258 | 263 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 264 | const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; | |
| 259 | 265 | const analytics = yield* ProviderTurnAnalytics; |
| 260 | 266 | const completedTurnAnalytics = new Set<string>(); |
| 261 | 267 | |
| ... | ... | @@ -338,6 +344,48 @@ export const layer: Layer.Layer< |
| 338 | 344 | }, |
| 339 | 345 | ); |
| 340 | 346 | |
| 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 | 389 | const normalize: ProviderEventIngestorV2Shape["normalize"] = (input) => |
| 342 | 390 | Effect.gen(function* () { |
| 343 | 391 | switch (input.event.type) { |
| ... | ... | @@ -543,6 +591,21 @@ export const layer: Layer.Layer< |
| 543 | 591 | .pipe(Effect.mapError(mapWriteError)); |
| 544 | 592 | return result.storedEvents; |
| 545 | 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 | 609 | Effect.tap((storedEvents) => |
| 547 | 610 | Effect.gen(function* () { |
| 548 | 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 | 52 | } from "./ProviderAdapter.ts"; |
| 53 | 53 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; |
| 54 | 54 | import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; |
| 55 | import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; | |
| 55 | 56 | import * as ProviderSessionManager from "./ProviderSessionManager.ts"; |
| 56 | 57 | |
| 57 | 58 | const TestDatabaseLayer = SqlitePersistenceMemory; |
| ... | ... | @@ -383,7 +384,14 @@ function makeTestLayer(input: { |
| 383 | 384 | }), |
| 384 | 385 | ); |
| 385 | 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 | 396 | return Layer.mergeAll( |
| 389 | 397 | TestStoresLayer, |
apps/server/src/orchestration-v2/runtimeLayer.ts+9-1| ... | ... | @@ -35,6 +35,7 @@ import { layer as providerContinuationRequestsLayer } from "./ProviderContinuati |
| 35 | 35 | import { workerLive as providerContinuationWorkerLive } from "./ProviderContinuationService.ts"; |
| 36 | 36 | import { layer as threadTitleRegenerationServiceLayer } from "./ThreadTitleRegenerationService.ts"; |
| 37 | 37 | import { layer as providerEventIngestorLayer } from "./ProviderEventIngestor.ts"; |
| 38 | import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; | |
| 38 | 39 | import { layer as providerSessionManagerLayer } from "./ProviderSessionManager.ts"; |
| 39 | 40 | import { layer as providerRuntimeRecoveryLayer } from "./ProviderRuntimeRecoveryService.ts"; |
| 40 | 41 | import { layer as providerSwitchServiceLayer } from "./ProviderSwitchService.ts"; |
| ... | ... | @@ -98,7 +99,14 @@ export const ProjectServiceLayerLive = projectServiceLayer.pipe( |
| 98 | 99 | ); |
| 99 | 100 | |
| 100 | 101 | const 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 | ); |
| 103 | 111 | |
| 104 | 112 | const 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 | 50 | import * as RuntimePolicy from "../RuntimePolicy.ts"; |
| 51 | 51 | import * as TurnItemPositionStore from "../TurnItemPositionStore.ts"; |
| 52 | 52 | import * as RuntimeRequestService from "../RuntimeRequestService.ts"; |
| 53 | import * as ThreadCommandExecutor from "../ThreadCommandExecutor.ts"; | |
| 53 | 54 | import * as ThreadForkService from "../ThreadForkService.ts"; |
| 54 | 55 | import { |
| 55 | 56 | runOrchestratorV2Scenario, |
| ... | ... | @@ -301,7 +302,14 @@ export function makeOrchestratorV2ReplayLayerWithRegistry<Error>( |
| 301 | 302 | ); |
| 302 | 303 | const commandReceiptStoreProvided = CommandReceiptStore.layer.pipe(Layer.provide(databaseLayer)); |
| 303 | 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 | 314 | const vcsDriverRegistryLayer = VcsDriverRegistry.layer.pipe( |
| 307 | 315 | Layer.provide(VcsProcess.layer), |
apps/web/src/components/ChatView.tsx+10-1| ... | ... | @@ -116,6 +116,7 @@ import { |
| 116 | 116 | createModelSelection, |
| 117 | 117 | formatModelSlugName, |
| 118 | 118 | resolvePromptInjectedEffort, |
| 119 | resolveSelectableModel, | |
| 119 | 120 | } from "@t3tools/shared/model"; |
| 120 | 121 | import { |
| 121 | 122 | projectScriptCwd, |
| ... | ... | @@ -4055,8 +4056,16 @@ export default function ChatView(props: ChatViewProps) { |
| 4055 | 4056 | const showProviderSubagentBar = isProviderSubagent; |
| 4056 | 4057 | const composerMounted = !showProviderSubagentBar; |
| 4057 | 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 | 4067 | const providerSubagentCatalogModel = providerSubagentModels.find( |
| 4059 | (model) => model.slug === activeThread?.modelSelection.model, | |
| 4068 | (model) => model.slug === providerSubagentModelSlug, | |
| 4060 | 4069 | ); |
| 4061 | 4070 | const providerSubagentModelLabel = providerSubagentCatalogModel |
| 4062 | 4071 | ? getTriggerDisplayModelName(providerSubagentCatalogModel) |