diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 3b8826b2024d..59940e4c9fe2 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1449,6 +1449,36 @@ function useMarkdownStyles( ]); } +function AgentMessageAttribution(props: { + readonly environmentId: EnvironmentId; + readonly senderThreadId?: ThreadId; +}) { + const navigation = useNavigation(); + const senderThreadId = props.senderThreadId; + const label = ( + + Sent by another agent + + ); + return senderThreadId ? ( + + navigation.navigate("Thread", { + environmentId: String(props.environmentId), + threadId: String(senderThreadId), + }) + } + > + {label} + + ) : ( + label + ); +} + function renderFeedEntry( info: { item: PendingThreadFeedEntry; index: number }, props: Pick< @@ -1619,10 +1649,15 @@ function renderFeedEntry( className="mb-5 items-end" {...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})} > - {presentation.isAutomation || message.createdBy === "agent" ? ( + {presentation.isAutomation ? ( - {presentation.isAutomation ? "Sent by automation" : "Sent by another agent"} + Sent by automation + ) : message.createdBy === "agent" ? ( + ) : null} { }); }); + it("keeps the sender of an agent message distinct from its timeline source", () => { + const feed = buildThreadFeed([ + projected( + { + ...userMessage(), + createdBy: "agent", + creationSource: "mcp", + senderThreadId: sourceThreadId, + }, + 0, + ), + ]); + const messageEntry = feed.find((entry) => entry.type === "message"); + expect(messageEntry?.message.senderThreadId).toBe(sourceThreadId); + expect(messageEntry?.message.sourceThreadId).toBe(threadId); + }); + it("adds local feedback messages to an otherwise server-authored feed", () => { const feed = buildThreadFeed([], { localMessages: [ diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index c974860b1bdf..cce72a78402b 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -109,6 +109,7 @@ export interface ThreadFeedMessage { readonly createdBy?: OrchestrationV2Actor; readonly creationSource?: OrchestrationV2CreationSource; readonly scheduledTaskId?: ScheduledTaskId; + readonly senderThreadId?: ThreadId; readonly visibility: OrchestrationV2ProjectedTurnItem["visibility"]; readonly sourceThreadId: ThreadId; readonly createdAt: string; @@ -1559,6 +1560,7 @@ export function buildThreadFeed( createdBy: item.createdBy, creationSource: item.creationSource, ...(item.scheduledTaskId ? { scheduledTaskId: item.scheduledTaskId } : {}), + ...(item.senderThreadId ? { senderThreadId: item.senderThreadId } : {}), } : {}), visibility: row.visibility, diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 61af58a5774c..0309a51715d7 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1640,6 +1640,7 @@ const make = Effect.gen(function* () { index, }), threadId, + senderThreadId: scope.threadId, messageId: stableMessageId({ scope, requestKey: key, @@ -1840,6 +1841,7 @@ const make = Effect.gen(function* () { operation: "thread-send", }), threadId: input.threadId, + senderThreadId: scope.threadId, messageId, text: input.message, attachments: [], diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 3a64a87a7152..ed32fb6d89b7 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -1453,6 +1453,17 @@ describe("orchestrator MCP toolkit", () => { const delegated = yield* decodeDelegateTaskResult(delegatedCall.structuredContent).pipe( Effect.orDie, ); + const delegatedSource = yield* orchestrator.getThreadProjection( + delegated.childThreadId, + ); + expect(delegatedSource.messages[0]).toMatchObject({ + senderThreadId: parentThreadId, + }); + expect( + delegatedSource.turnItems.find((item) => item.type === "user_message"), + ).toMatchObject({ + senderThreadId: parentThreadId, + }); expect(delegated.status).toBe("completed"); expect(delegated.summary).toBe(delegatedResult); expect(delegated.providerInstanceId).toBe(claudeInstanceId); @@ -1847,6 +1858,15 @@ describe("orchestrator MCP toolkit", () => { providerInstanceId: claudeInstanceId, model: claudeModel, }); + const createdSource = yield* orchestrator.getThreadProjection(promptedThread.threadId); + expect(createdSource.messages[0]).toMatchObject({ + senderThreadId: parentThreadId, + }); + expect( + createdSource.turnItems.find((item) => item.type === "user_message"), + ).toMatchObject({ + senderThreadId: parentThreadId, + }); const emptyProjection = yield* orchestrator.getThreadProjection(emptyThread.threadId); expect(emptyProjection.thread.lineage).toEqual({ parentThreadId: null, @@ -2097,6 +2117,19 @@ describe("orchestrator MCP toolkit", () => { const sent = yield* decodeThreadSendResult(sendCall.structuredContent).pipe( Effect.orDie, ); + const sentSource = yield* orchestrator.getThreadProjection(emptyThread.threadId); + expect( + sentSource.messages.find((message) => message.id === sent.messageId), + ).toMatchObject({ + senderThreadId: parentThreadId, + }); + expect( + sentSource.turnItems.find( + (item) => item.type === "user_message" && item.messageId === sent.messageId, + ), + ).toMatchObject({ + senderThreadId: parentThreadId, + }); expect(sent.delivery).toBe("started"); const waitCall = yield* invoke("t3_thread_wait", { threadId: emptyThread.threadId, @@ -2181,6 +2214,19 @@ describe("orchestrator MCP toolkit", () => { runId: activeRun.id, delivery: "steered", }); + const steeredSource = yield* orchestrator.getThreadProjection(activeThread.threadId); + expect( + steeredSource.messages.find((message) => message.id === steered.messageId), + ).toMatchObject({ + senderThreadId: parentThreadId, + }); + expect( + steeredSource.turnItems.find( + (item) => item.type === "user_message" && item.messageId === steered.messageId, + ), + ).toMatchObject({ + senderThreadId: parentThreadId, + }); const interruptCall = yield* invoke("t3_thread_interrupt", { threadId: activeThread.threadId, reason: "The orchestration loop has enough evidence.", diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 7277abed4e27..bd4fa52f912d 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -2531,6 +2531,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV }); const promptNativeItemId = `${nativeTaskId}:prompt`; const promptArtifacts = makeSubagentConversationArtifacts({ + senderThreadId: context.input.threadId, messageId: providerMessageId(promptNativeItemId), turnItemId: providerTurnItemId(promptNativeItemId), threadId: childThreadId, @@ -6565,6 +6566,9 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV ...(turnInput.message.scheduledTaskId === undefined ? {} : { scheduledTaskId: turnInput.message.scheduledTaskId }), + ...(turnInput.message.senderThreadId === undefined + ? {} + : { senderThreadId: turnInput.message.senderThreadId }), id: turnInput.message.messageId, threadId: turnInput.threadId, runId: turnInput.runId, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index d62db9ac3308..b121f999f8a7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3533,6 +3533,7 @@ export function makeClaudeAdapterV2( if (existingSubagent === undefined) { const promptNativeItemId = `${nativeItemId}:prompt`; const promptArtifacts = makeSubagentConversationArtifacts({ + senderThreadId: input.context.input.threadId, messageId: idAllocator.derive.messageFromProviderItem({ driver: CLAUDE_PROVIDER, nativeItemId: promptNativeItemId, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 7146de4f1d77..75d670fcf99f 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -2420,6 +2420,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi if (input.emitInitialPrompt && input.prompt.length > 0) { const promptNativeItemId = `${input.nativeItemId}:prompt`; const promptArtifacts = makeSubagentConversationArtifacts({ + senderThreadId: input.context.projectionThreadId, messageId: idAllocator.derive.messageFromProviderItem({ driver: CODEX_PROVIDER, nativeItemId: promptNativeItemId, @@ -2872,6 +2873,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi const now = yield* DateTime.now; const ordinal = yield* resolveItemOrdinal(context, item.id); const artifacts = makeSubagentConversationArtifacts({ + senderThreadId: context.subagent.parentContext.projectionThreadId, messageId: idAllocator.derive.messageFromProviderItem({ driver: CODEX_PROVIDER, nativeItemId: item.id, diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts index cbbad9d8d0dc..275389a958ed 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts @@ -1604,6 +1604,7 @@ export function makeCursorAdapterV2( }); const promptNativeId = `${nativeItemId}:prompt`; const promptArtifacts = makeSubagentConversationArtifacts({ + senderThreadId: input.context.input.threadId, messageId: idAllocator.derive.messageFromProviderItem({ driver: CURSOR_PROVIDER, nativeItemId: promptNativeId, diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index 1c8864ea6f48..e78c44cedc7f 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -247,6 +247,9 @@ export const executorLayer: Layer.Layer< ...(message.scheduledTaskId === undefined ? {} : { scheduledTaskId: message.scheduledTaskId }), + ...(message.senderThreadId === undefined + ? {} + : { senderThreadId: message.senderThreadId }), }); }), ), diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index b5af88703fb3..f2a3e21489b2 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1410,6 +1410,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(queuedMessage.scheduledTaskId === undefined ? {} : { scheduledTaskId: queuedMessage.scheduledTaskId }), + ...(queuedMessage.senderThreadId === undefined + ? {} + : { senderThreadId: queuedMessage.senderThreadId }), }), inputIntent: "queued_turn", startedAt: now, @@ -3285,6 +3288,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly createdBy: OrchestrationV2ConversationMessage["createdBy"]; readonly creationSource: OrchestrationV2ConversationMessage["creationSource"]; readonly scheduledTaskId?: OrchestrationV2ConversationMessage["scheduledTaskId"]; + readonly senderThreadId?: OrchestrationV2ConversationMessage["senderThreadId"]; readonly delegatedCompletion?: OrchestrationV2ConversationMessage["delegatedCompletion"]; readonly forceRestart: boolean; }) => @@ -3430,6 +3434,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(input.scheduledTaskId === undefined ? {} : { scheduledTaskId: input.scheduledTaskId }), + ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), id: input.messageId, threadId: input.command.threadId, runId: messageInput.runId, @@ -3448,6 +3453,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(input.scheduledTaskId === undefined ? {} : { scheduledTaskId: input.scheduledTaskId }), + ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), id: idAllocator.derive.userTurnItem({ messageId: input.messageId }), threadId: input.command.threadId, runId: messageInput.runId, @@ -4288,6 +4294,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.scheduledTaskId === undefined ? {} : { scheduledTaskId: command.scheduledTaskId }), + ...(command.senderThreadId === undefined + ? {} + : { senderThreadId: command.senderThreadId }), forceRestart: dispatchMode.type === "restart_active", }); return; @@ -4469,6 +4478,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.scheduledTaskId === undefined ? {} : { scheduledTaskId: command.scheduledTaskId }), + ...(command.senderThreadId === undefined + ? {} + : { senderThreadId: command.senderThreadId }), id: command.messageId, threadId: command.threadId, runId, @@ -4805,6 +4817,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.scheduledTaskId === undefined ? {} : { scheduledTaskId: command.scheduledTaskId }), + ...(command.senderThreadId === undefined + ? {} + : { senderThreadId: command.senderThreadId }), id: command.messageId, threadId: command.threadId, runId, @@ -4825,6 +4840,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.scheduledTaskId === undefined ? {} : { scheduledTaskId: command.scheduledTaskId }), + ...(command.senderThreadId === undefined + ? {} + : { senderThreadId: command.senderThreadId }), id: idAllocator.derive.userTurnItem({ messageId: command.messageId }), threadId: command.threadId, runId, @@ -5491,6 +5509,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.scheduledTaskId === undefined ? {} : { scheduledTaskId: command.scheduledTaskId }), + ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), id: command.messageId, threadId: command.threadId, runId, @@ -5511,6 +5530,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.scheduledTaskId === undefined ? {} : { scheduledTaskId: command.scheduledTaskId }), + ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), id: idAllocator.derive.userTurnItem({ messageId: command.messageId }), threadId: command.threadId, runId, @@ -6110,6 +6130,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio creationSource: command.creationSource, commandId: command.commandId, threadId: childThreadId, + senderThreadId: command.parentThreadId, messageId: childMessageId, text: command.task, attachments: [], @@ -6795,6 +6816,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(queuedMessage.scheduledTaskId === undefined ? {} : { scheduledTaskId: queuedMessage.scheduledTaskId }), + ...(queuedMessage.senderThreadId === undefined + ? {} + : { senderThreadId: queuedMessage.senderThreadId }), forceRestart: false, }); }); diff --git a/apps/server/src/orchestration-v2/ProviderAdapter.ts b/apps/server/src/orchestration-v2/ProviderAdapter.ts index 478150bd59f6..2d34ff547206 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapter.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapter.ts @@ -61,6 +61,7 @@ export const ProviderAdapterV2TurnMessage = Schema.Struct({ createdBy: OrchestrationV2ConversationMessage.fields.createdBy, creationSource: OrchestrationV2ConversationMessage.fields.creationSource, scheduledTaskId: OrchestrationV2ConversationMessage.fields.scheduledTaskId, + senderThreadId: OrchestrationV2ConversationMessage.fields.senderThreadId, }); export type ProviderAdapterV2TurnMessage = typeof ProviderAdapterV2TurnMessage.Type; diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts index 87130fd5be4d..2506d2830a06 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts @@ -313,6 +313,9 @@ export const layer: Layer.Layer< ...(message.scheduledTaskId === undefined ? {} : { scheduledTaskId: message.scheduledTaskId }), + ...(message.senderThreadId === undefined + ? {} + : { senderThreadId: message.senderThreadId }), }, }) .pipe( diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 6937eee167ca..4d24dcb43372 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -1151,6 +1151,9 @@ export const layer: Layer.Layer< ...(message.scheduledTaskId === undefined ? {} : { scheduledTaskId: message.scheduledTaskId }), + ...(message.senderThreadId === undefined + ? {} + : { senderThreadId: message.senderThreadId }), }, modelSelection: run.modelSelection, runtimePolicy: resolvedRuntimePolicy, diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index 0e33187efe6a..d05a492f15ab 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -114,6 +114,30 @@ it("keeps a subagent child awake when its parent thread is snoozed", () => { }); }); +it("attributes native subagent prompts to their parent thread", () => { + for (const role of ["user", "assistant"] as const) { + const artifacts = makeSubagentConversationArtifacts({ + messageId: MessageId.make(`native-${role}`), + turnItemId: TurnItemId.make(`native-${role}`), + threadId: childThreadId, + senderThreadId: parentThreadId, + rootNodeId: NodeId.make("child-root"), + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + role, + text: role === "user" ? "Review the changes" : "Review complete", + ordinal: 100, + now: childCreatedAt, + }); + assert.equal(artifacts.message.threadId, childThreadId); + assert.equal(artifacts.message.senderThreadId, role === "user" ? parentThreadId : undefined); + if (artifacts.turnItem.type === "user_message") { + assert.equal(artifacts.turnItem.senderThreadId, parentThreadId); + } + } +}); + function taskFixture() { const projection = emptyProjection({ type: "thread.created", diff --git a/apps/server/src/orchestration-v2/SubagentProjection.ts b/apps/server/src/orchestration-v2/SubagentProjection.ts index bec0c94be760..35dc073f8ca0 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.ts @@ -82,6 +82,7 @@ export function makeSubagentChildThread(input: { export function makeSubagentConversationArtifacts(input: { readonly messageId: MessageId; + readonly senderThreadId?: ThreadId; readonly turnItemId: TurnItemId; readonly threadId: ThreadId; readonly rootNodeId: NodeId; @@ -104,6 +105,9 @@ export function makeSubagentConversationArtifacts(input: { runId: null, nodeId: input.rootNodeId, role: input.role, + ...(input.role === "user" && input.senderThreadId !== undefined + ? { senderThreadId: input.senderThreadId } + : {}), text: input.text, attachments: [], streaming: false, @@ -135,6 +139,7 @@ export function makeSubagentConversationArtifacts(input: { createdBy: "agent", creationSource: "provider", type: "user_message", + ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), inputIntent: "turn_start", attachments: [], } diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 76b1b6b04c0c..f2365689177a 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -320,7 +320,7 @@ for (const target of ["new", "existing"] as const) { } } -it.effect("retains automation attribution while a message waits in the queue", () => { +it.effect("retains automation and sender attribution while a message waits in the queue", () => { const harness = makeHarness({ runSetup: () => Effect.never }); return Effect.gen(function* () { const launches = yield* ThreadLaunch.ThreadLaunchService; @@ -333,12 +333,14 @@ it.effect("retains automation attribution while a message waits in the queue", ( }), ); const scheduledTaskId = ScheduledTaskId.make("scheduled-task:queued"); + const senderThreadId = ThreadId.make("thread:agent-sender"); const queued = yield* threads.sendToThread({ projectId, commandId: CommandId.make("command:automation:queued"), threadId: launched.threadId, messageId: MessageId.make("message:automation:queued"), scheduledTaskId, + senderThreadId, text: "Run the audit", attachments: [], mode: "queue", @@ -349,6 +351,7 @@ it.effect("retains automation attribution while a message waits in the queue", ( const projection = yield* threads.getThreadProjection(launched.threadId); const message = projection.messages.find((item) => item.id === queued.message.id); assert.equal(message?.scheduledTaskId, scheduledTaskId); + assert.equal(message?.senderThreadId, senderThreadId); assert.equal(message?.text, "Run the audit"); }).pipe(Effect.provide(harness.layer)); }); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index cd2fa7cfe316..beb23a3f7da5 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -59,6 +59,7 @@ export type ThreadLaunchWorkspaceStrategy = export interface ThreadLaunchInitialMessage { readonly messageId?: MessageId; readonly scheduledTaskId?: ScheduledTaskId; + readonly senderThreadId?: ThreadId; readonly text: string; readonly attachments: ReadonlyArray; readonly context?: import("@t3tools/contracts").OrchestrationMessageContext | undefined; @@ -722,6 +723,9 @@ const make = Effect.gen(function* () { ...(input.initialMessage.scheduledTaskId === undefined ? {} : { scheduledTaskId: input.initialMessage.scheduledTaskId }), + ...(input.initialMessage.senderThreadId === undefined + ? {} + : { senderThreadId: input.initialMessage.senderThreadId }), attachments: input.initialMessage.attachments, ...(input.initialMessage.context ? { context: input.initialMessage.context } : {}), ...(input.generateTitle === true ? { titleSeed: input.title } : {}), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 1bf902928eb6..e3d6a863544e 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -109,6 +109,7 @@ export interface ThreadManagementSendInput { readonly threadId: ThreadId; readonly messageId: MessageId; readonly scheduledTaskId?: ScheduledTaskId; + readonly senderThreadId?: ThreadId; readonly text: string; readonly attachments: ReadonlyArray; readonly modelSelection?: ModelSelection; @@ -565,6 +566,7 @@ const make = Effect.gen(function* () { threadId: input.threadId, messageId: input.messageId, ...(input.scheduledTaskId === undefined ? {} : { scheduledTaskId: input.scheduledTaskId }), + ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), text: input.text, attachments: input.attachments, ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }), diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e1e130c095c0..c7503219508c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -141,7 +141,7 @@ import type { ComposerContextRecord, KnownComposerContextRecord, } from "@t3tools/contracts"; -import { Button } from "../ui/button"; +import { Button, InlineButton } from "../ui/button"; import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; @@ -1891,6 +1891,7 @@ function MessageAuthorHeading({ children }: { children: string }) { function UserTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const { onImageExpand, onFileOpen } = ctx; + const senderThreadId = row.message.senderThreadId; const resources = useMemo( () => selectMessageImageResources(row.message.attachments), [row.message.attachments], @@ -2072,7 +2073,17 @@ function UserTimelineRow({ row }: { row: Extract - Sent by another agent + {senderThreadId ? ( + ctx.onOpenThread(senderThreadId)} + underline + aria-label="Open sending thread" + > + Sent by another agent + + ) : ( + "Sent by another agent" + )}

) : null} {row.message.inputIntent && row.message.inputIntent !== "turn_start" ? ( diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index cfa0787cd275..886f1d5ae35b 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -591,6 +591,7 @@ describe("V2 session presentation", () => { createdBy: "agent" as const, creationSource: "mcp" as const, scheduledTaskId: ScheduledTaskId.make("task-queued"), + senderThreadId: ThreadId.make("thread-agent-sender"), } satisfies OrchestrationV2TurnItem; const promotedEntries = deriveTimelineEntriesFromVisibleTurnItems({ visibleTurnItems: [ @@ -609,6 +610,7 @@ describe("V2 session presentation", () => { if (promotedEntries[0]?.kind === "message") { expect(promotedEntries[0].message.inputIntent).toBe("turn_start"); expect(promotedEntries[0].message.scheduledTaskId).toBe("task-queued"); + expect(promotedEntries[0].message.senderThreadId).toBe("thread-agent-sender"); } }); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 059710305382..97d6edc16ea1 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -624,6 +624,7 @@ export function deriveTimelineEntriesFromVisibleTurnItems( ? { createdBy: item.createdBy, creationSource: item.creationSource, + ...(item.senderThreadId !== undefined ? { senderThreadId: item.senderThreadId } : {}), ...(item.scheduledTaskId !== undefined ? { scheduledTaskId: item.scheduledTaskId } : {}), diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index ac9b5a9b987a..ac7ecaf3834f 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -14,6 +14,7 @@ import type { RunId, RuntimeMode, ScheduledTaskId, + ThreadId, } from "@t3tools/contracts"; import type { EnvironmentProject, @@ -98,6 +99,7 @@ export interface ChatMessage { readonly createdBy?: OrchestrationV2Actor; readonly creationSource?: OrchestrationV2CreationSource; readonly scheduledTaskId?: ScheduledTaskId; + readonly senderThreadId?: ThreadId; readonly createdAt: string; readonly updatedAt: string; readonly inputIntent?: OrchestrationV2UserMessageInputIntent | undefined; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index f6218f1899b1..ce061d96a26e 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -840,6 +840,8 @@ export const OrchestrationV2ConversationMessage = Schema.Struct({ notification: Schema.optional(OrchestrationV2Notification), ...OrchestrationV2CreationFields, scheduledTaskId: Schema.optional(ScheduledTaskId), + // The sending agent's thread in this environment, separate from the receiving thread. + senderThreadId: Schema.optional(ThreadId), id: MessageId, threadId: ThreadId, runId: Schema.NullOr(RunId), @@ -1082,6 +1084,7 @@ export const OrchestrationV2TurnItem = Schema.Union([ type: Schema.Literal("user_message"), messageId: MessageId, scheduledTaskId: Schema.optional(ScheduledTaskId), + senderThreadId: Schema.optional(ThreadId), inputIntent: OrchestrationV2UserMessageInputIntent, text: Schema.String, context: Schema.optional(OrchestrationMessageContext), @@ -1796,6 +1799,7 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ type: Schema.Literal("user_message"), messageId: MessageId, scheduledTaskId: Schema.optional(ScheduledTaskId), + senderThreadId: Schema.optional(ThreadId), inputIntent: OrchestrationV2UserMessageInputIntent, text: Schema.String, context: Schema.optional(OrchestrationMessageContext), @@ -2435,6 +2439,7 @@ export const OrchestrationV2Command = Schema.Union([ notification: Schema.optional(OrchestrationV2Notification), ...OrchestrationV2CreationFields, scheduledTaskId: Schema.optional(ScheduledTaskId), + senderThreadId: Schema.optional(ThreadId), commandId: CommandId, threadId: ThreadId, messageId: MessageId,