| author | |
| committer | |
| log | a7223f3969428170cc13bd7859aa59098b2f94b1 |
| tree | 4f8ceeb3626f25899c0ee918e037d6a6237ccfcb |
| parent | 9cc57680cb5f762f553acd1fa5f3efcf17d8fc67 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>12 files changed, 171 insertions(+), 32 deletions(-)
apps/mobile/src/features/threads/ThreadFeed.tsx+6-2| ... | @@ -1719,11 +1719,15 @@ function renderFeedEntry( | ... | @@ -1719,11 +1719,15 @@ function renderFeedEntry( |
| 1719 | className="mb-5 items-end" | 1719 | className="mb-5 items-end" |
| 1720 | {...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})} | 1720 | {...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})} |
| 1721 | > | 1721 | > |
| 1722 | {presentation.isAutomation ? ( | 1722 | {presentation.attribution === "automation" ? ( |
| 1723 | <Text className="mb-1 pr-1 font-t3-medium text-2xs text-foreground-muted opacity-60"> | 1723 | <Text className="mb-1 pr-1 font-t3-medium text-2xs text-foreground-muted opacity-60"> |
| 1724 | Sent by automation | 1724 | Sent by automation |
| 1725 | </Text> | 1725 | </Text> |
| 1726 | ) : message.createdBy === "agent" ? ( | 1726 | ) : presentation.attribution === "t3code" ? ( |
| 1727 | <Text className="mb-1 pr-1 font-t3-medium text-2xs text-foreground-muted opacity-60"> | ||
| 1728 | Sent by T3 Code | ||
| 1729 | </Text> | ||
| 1730 | ) : presentation.attribution === "agent" ? ( | ||
| 1727 | <AgentMessageAttribution | 1731 | <AgentMessageAttribution |
| 1728 | environmentId={props.environmentId} | 1732 | environmentId={props.environmentId} |
| 1729 | senderThreadId={message.senderThreadId} | 1733 | senderThreadId={message.senderThreadId} |
apps/mobile/src/lib/threadActivity.test.ts+19-1| ... | @@ -85,6 +85,24 @@ it("shows only the structured path in expanded mobile read details", () => { | ... | @@ -85,6 +85,24 @@ it("shows only the structured path in expanded mobile read details", () => { |
| 85 | expect(withoutPath?.canExpand).toBe(false); | 85 | expect(withoutPath?.canExpand).toBe(false); |
| 86 | }); | 86 | }); |
| 87 | 87 | ||
| 88 | it("expands a T3 Code notice to the prompt the agent got", () => { | ||
| 89 | const item = { | ||
| 90 | ...base("restart-notice", "2026-06-20T00:00:03.000Z", 2), | ||
| 91 | type: "notification", | ||
| 92 | source: { kind: "system" }, | ||
| 93 | outcome: "updated", | ||
| 94 | summary: "T3 Code restarted and resumed this turn", | ||
| 95 | detail: "Continue where you left off.", | ||
| 96 | } as OrchestrationV2TurnItem; | ||
| 97 | const [activity] = buildThreadFeed([projected(item, 0)]).flatMap((entry) => | ||
| 98 | entry.type === "activity-group" ? entry.activities : [], | ||
| 99 | ); | ||
| 100 | |||
| 101 | expect(activity?.summary).toBe("T3 Code restarted and resumed this turn"); | ||
| 102 | expect(activity?.canExpand).toBe(true); | ||
| 103 | expect(activity?.getFullDetail()).toBe("Continue where you left off."); | ||
| 104 | }); | ||
| 105 | |||
| 88 | it("labels file searches with the adapter title and its search target", () => { | 106 | it("labels file searches with the adapter title and its search target", () => { |
| 89 | const item: OrchestrationV2TurnItem = { | 107 | const item: OrchestrationV2TurnItem = { |
| 90 | ...base("file-search", "2026-06-20T00:00:03.000Z", 2), | 108 | ...base("file-search", "2026-06-20T00:00:03.000Z", 2), |
| ... | @@ -317,7 +335,7 @@ describe("buildThreadFeed", () => { | ... | @@ -317,7 +335,7 @@ describe("buildThreadFeed", () => { |
| 317 | expect(messageEntry).toBeDefined(); | 335 | expect(messageEntry).toBeDefined(); |
| 318 | expect(resolveUserMessagePresentation(messageEntry!.message)).toMatchObject({ | 336 | expect(resolveUserMessagePresentation(messageEntry!.message)).toMatchObject({ |
| 319 | text: "Run checks", | 337 | text: "Run checks", |
| 320 | isAutomation: true, | 338 | attribution: "automation", |
| 321 | }); | 339 | }); |
| 322 | }); | 340 | }); |
| 323 | 341 |
apps/mobile/src/lib/threadActivity.ts+6-1| ... | @@ -521,6 +521,7 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] { | ... | @@ -521,6 +521,7 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] { |
| 521 | case "monitor": | 521 | case "monitor": |
| 522 | return "eye"; | 522 | return "eye"; |
| 523 | case "background_task": | 523 | case "background_task": |
| 524 | case "system": | ||
| 524 | return "zap"; | 525 | return "zap"; |
| 525 | default: | 526 | default: |
| 526 | source satisfies never; | 527 | source satisfies never; |
| ... | @@ -814,7 +815,11 @@ function toFeedActivity( | ... | @@ -814,7 +815,11 @@ function toFeedActivity( |
| 814 | ? collectToolFilePaths(item) | 815 | ? collectToolFilePaths(item) |
| 815 | : null; | 816 | : null; |
| 816 | const getFullDetail = memoizeValue(() => | 817 | const getFullDetail = memoizeValue(() => |
| 817 | readPaths ? readPaths.join("\n") || null : formatItemFullDetail(row, item), | 818 | readPaths |
| 819 | ? readPaths.join("\n") || null | ||
| 820 | : item.type === "notification" | ||
| 821 | ? item.detail?.trim() || null | ||
| 822 | : formatItemFullDetail(row, item), | ||
| 818 | ); | 823 | ); |
| 819 | const getCopyText = memoizeValue(() => | 824 | const getCopyText = memoizeValue(() => |
| 820 | [summary, detail, getFullDetail()] | 825 | [summary, detail, getFullDetail()] |
apps/server/src/orchestration-v2/Orchestrator.ts+7-1| ... | @@ -4674,11 +4674,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -4674,11 +4674,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 4674 | dispatchMode = { type: "start_immediately" }; | 4674 | dispatchMode = { type: "start_immediately" }; |
| 4675 | } | 4675 | } |
| 4676 | } | 4676 | } |
| 4677 | // A restart continuation resumes the interrupted turn, so it starts | ||
| 4678 | // immediately rather than queueing like other wakes. | ||
| 4677 | if ( | 4679 | if ( |
| 4678 | command.notification !== undefined && | 4680 | command.notification !== undefined && |
| 4679 | (command.createdBy !== "agent" || | 4681 | (command.createdBy !== "agent" || |
| 4680 | (command.creationSource !== "server" && command.creationSource !== "provider") || | 4682 | (command.creationSource !== "server" && command.creationSource !== "provider") || |
| 4681 | dispatchMode.type !== "queue_after_active") | 4683 | (dispatchMode.type !== "queue_after_active" && |
| 4684 | !( | ||
| 4685 | command.restartContinuationOfRunId !== undefined && | ||
| 4686 | dispatchMode.type === "start_immediately" | ||
| 4687 | ))) | ||
| 4682 | ) { | 4688 | ) { |
| 4683 | return yield* new OrchestratorDispatchError({ | 4689 | return yield* new OrchestratorDispatchError({ |
| 4684 | commandId: command.commandId, | 4690 | commandId: command.commandId, |
apps/server/src/orchestration-v2/RestartContinuation.test.ts+22-4| ... | @@ -10,6 +10,7 @@ import { | ... | @@ -10,6 +10,7 @@ import { |
| 10 | RunAttemptId, | 10 | RunAttemptId, |
| 11 | RunId, | 11 | RunId, |
| 12 | ThreadId, | 12 | ThreadId, |
| 13 | type OrchestrationV2Command, | ||
| 13 | type OrchestrationV2ThreadProjection, | 14 | type OrchestrationV2ThreadProjection, |
| 14 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| 15 | import * as DateTime from "effect/DateTime"; | 16 | import * as DateTime from "effect/DateTime"; |
| ... | @@ -606,9 +607,9 @@ it.effect("does not cancel or resume a run that completes while shutdown intent | ... | @@ -606,9 +607,9 @@ it.effect("does not cancel or resume a run that completes while shutdown intent |
| 606 | }), | 607 | }), |
| 607 | ); | 608 | ); |
| 608 | 609 | ||
| 609 | const continuationTexts = (projection: OrchestrationV2ThreadProjection) => | 610 | const continuationDispatches = (projection: OrchestrationV2ThreadProjection) => |
| 610 | Effect.gen(function* () { | 611 | Effect.gen(function* () { |
| 611 | const texts: Array<string> = []; | 612 | const dispatches: Array<Extract<OrchestrationV2Command, { type: "message.dispatch" }>> = []; |
| 612 | yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( | 613 | yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( |
| 613 | Effect.provide( | 614 | Effect.provide( |
| 614 | Layer.merge( | 615 | Layer.merge( |
| ... | @@ -616,7 +617,7 @@ const continuationTexts = (projection: OrchestrationV2ThreadProjection) => | ... | @@ -616,7 +617,7 @@ const continuationTexts = (projection: OrchestrationV2ThreadProjection) => |
| 616 | getThreadRecords: () => Effect.succeed(projection), | 617 | getThreadRecords: () => Effect.succeed(projection), |
| 617 | recoverDelegatedTask: () => Effect.void, | 618 | recoverDelegatedTask: () => Effect.void, |
| 618 | dispatch: (command) => { | 619 | dispatch: (command) => { |
| 619 | if (command.type === "message.dispatch") texts.push(command.text); | 620 | if (command.type === "message.dispatch") dispatches.push(command); |
| 620 | return Effect.succeed({} as never); | 621 | return Effect.succeed({} as never); |
| 621 | }, | 622 | }, |
| 622 | }), | 623 | }), |
| ... | @@ -624,9 +625,14 @@ const continuationTexts = (projection: OrchestrationV2ThreadProjection) => | ... | @@ -624,9 +625,14 @@ const continuationTexts = (projection: OrchestrationV2ThreadProjection) => |
| 624 | ), | 625 | ), |
| 625 | ), | 626 | ), |
| 626 | ); | 627 | ); |
| 627 | return texts; | 628 | return dispatches; |
| 628 | }); | 629 | }); |
| 629 | 630 | ||
| 631 | const continuationTexts = (projection: OrchestrationV2ThreadProjection) => | ||
| 632 | continuationDispatches(projection).pipe( | ||
| 633 | Effect.map((dispatches) => dispatches.map((command) => command.text)), | ||
| 634 | ); | ||
| 635 | |||
| 630 | const cutMidTurn = (extra: Record<string, unknown> = {}) => { | 636 | const cutMidTurn = (extra: Record<string, unknown> = {}) => { |
| 631 | const base = makeProjection(); | 637 | const base = makeProjection(); |
| 632 | return { | 638 | return { |
| ... | @@ -740,6 +746,18 @@ it.effect("tells a turn cut mid-way about the background work it lost", () => | ... | @@ -740,6 +746,18 @@ it.effect("tells a turn cut mid-way about the background work it lost", () => |
| 740 | }), | 746 | }), |
| 741 | ); | 747 | ); |
| 742 | 748 | ||
| 749 | it.effect("shows a continuation as a T3 Code notice whose detail is the prompt", () => | ||
| 750 | Effect.gen(function* () { | ||
| 751 | const [resumed] = yield* continuationDispatches(cutMidTurn()); | ||
| 752 | assert.deepEqual(resumed?.notification, { | ||
| 753 | source: { kind: "system" }, | ||
| 754 | outcome: "updated", | ||
| 755 | summary: "T3 Code restarted and resumed this turn", | ||
| 756 | detail: "Continue where you left off.", | ||
| 757 | }); | ||
| 758 | }), | ||
| 759 | ); | ||
| 760 | |||
| 743 | it.effect( | 761 | it.effect( |
| 744 | "carries the note forward when its continuation was cut before reaching the provider", | 762 | "carries the note forward when its continuation was cut before reaching the provider", |
| 745 | () => | 763 | () => |
apps/server/src/orchestration-v2/RestartContinuation.ts+16-6| ... | @@ -153,17 +153,27 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta | ... | @@ -153,17 +153,27 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta |
| 153 | ); | 153 | ); |
| 154 | const noteText = | 154 | const noteText = |
| 155 | note.work.length === 0 ? undefined : restartCancelledBackgroundWorkNote(note.work); | 155 | note.work.length === 0 ? undefined : restartCancelledBackgroundWorkNote(note.work); |
| 156 | const text = | ||
| 157 | noteText === undefined | ||
| 158 | ? CONTINUE_PROMPT | ||
| 159 | : note.settled | ||
| 160 | ? noteText | ||
| 161 | : `${noteText}\n\n${CONTINUE_PROMPT}`; | ||
| 156 | yield* threads.dispatch({ | 162 | yield* threads.dispatch({ |
| 157 | type: "message.dispatch", | 163 | type: "message.dispatch", |
| 158 | commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`), | 164 | commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`), |
| 159 | threadId: input.threadId, | 165 | threadId: input.threadId, |
| 160 | messageId, | 166 | messageId, |
| 161 | text: | 167 | text, |
| 162 | noteText === undefined | 168 | // Shown as a work log row; the prompt the agent got is its detail. |
| 163 | ? CONTINUE_PROMPT | 169 | notification: { |
| 164 | : note.settled | 170 | source: { kind: "system" }, |
| 165 | ? noteText | 171 | outcome: "updated", |
| 166 | : `${noteText}\n\n${CONTINUE_PROMPT}`, | 172 | summary: note.settled |
| 173 | ? "T3 Code restarted and stopped background work" | ||
| 174 | : "T3 Code restarted and resumed this turn", | ||
| 175 | detail: text, | ||
| 176 | }, | ||
| 167 | attachments: [], | 177 | attachments: [], |
| 168 | modelSelection: source.modelSelection, | 178 | modelSelection: source.modelSelection, |
| 169 | dispatchMode: { type: "start_immediately" }, | 179 | dispatchMode: { type: "start_immediately" }, |
apps/server/src/orchestration-v2/runtimeLayer.test.ts+17| ... | @@ -3211,6 +3211,12 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { | ... | @@ -3211,6 +3211,12 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 3211 | threadId, | 3211 | threadId, |
| 3212 | messageId: MessageId.make("restart-automatic-message"), | 3212 | messageId: MessageId.make("restart-automatic-message"), |
| 3213 | text: "Continue where you left off.", | 3213 | text: "Continue where you left off.", |
| 3214 | notification: { | ||
| 3215 | source: { kind: "system" as const }, | ||
| 3216 | outcome: "updated" as const, | ||
| 3217 | summary: "T3 Code restarted and resumed this turn", | ||
| 3218 | detail: "Continue where you left off.", | ||
| 3219 | }, | ||
| 3214 | attachments: [], | 3220 | attachments: [], |
| 3215 | modelSelection, | 3221 | modelSelection, |
| 3216 | dispatchMode: { type: "start_immediately" as const }, | 3222 | dispatchMode: { type: "start_immediately" as const }, |
| ... | @@ -3221,6 +3227,17 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { | ... | @@ -3221,6 +3227,17 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 3221 | const admitted = yield* orchestrator.getThreadProjection(threadId); | 3227 | const admitted = yield* orchestrator.getThreadProjection(threadId); |
| 3222 | assert.lengthOf(admitted.runs, 2); | 3228 | assert.lengthOf(admitted.runs, 2); |
| 3223 | assert.equal(admitted.runs[1]?.restartContinuationOfRunId, original.id); | 3229 | assert.equal(admitted.runs[1]?.restartContinuationOfRunId, original.id); |
| 3230 | // The timeline shows a work log row; the prompt stays in its detail. | ||
| 3231 | const continuationItems = admitted.turnItems.filter( | ||
| 3232 | (item) => item.runId === admitted.runs[1]?.id, | ||
| 3233 | ); | ||
| 3234 | assert.isFalse(continuationItems.some((item) => item.type === "user_message")); | ||
| 3235 | assert.deepInclude( | ||
| 3236 | continuationItems.flatMap((item) => | ||
| 3237 | item.type === "notification" ? [[item.summary, item.detail]] : [], | ||
| 3238 | ), | ||
| 3239 | ["T3 Code restarted and resumed this turn", "Continue where you left off."], | ||
| 3240 | ); | ||
| 3224 | // A differently identified stale delivery still must not create another run. | 3241 | // A differently identified stale delivery still must not create another run. |
| 3225 | yield* orchestrator.dispatch({ | 3242 | yield* orchestrator.dispatch({ |
| 3226 | ...command, | 3243 | ...command, |
apps/web/src/components/chat/MessagesTimeline.tsx+11-2| ... | @@ -2357,7 +2357,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" | ... | @@ -2357,7 +2357,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 2357 | 2357 | ||
| 2358 | return ( | 2358 | return ( |
| 2359 | <div className="group flex flex-col items-end gap-1"> | 2359 | <div className="group flex flex-col items-end gap-1"> |
| 2360 | {userMessage.isAutomation ? ( | 2360 | {userMessage.attribution === "automation" ? ( |
| 2361 | <p | 2361 | <p |
| 2362 | className="me-1 text-2xs text-muted-foreground/70" | 2362 | className="me-1 text-2xs text-muted-foreground/70" |
| 2363 | data-user-message-attribution="automation" | 2363 | data-user-message-attribution="automation" |
| ... | @@ -2377,7 +2377,14 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" | ... | @@ -2377,7 +2377,14 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 2377 | "Sent by automation" | 2377 | "Sent by automation" |
| 2378 | )} | 2378 | )} |
| 2379 | </p> | 2379 | </p> |
| 2380 | ) : row.message.createdBy === "agent" ? ( | 2380 | ) : userMessage.attribution === "t3code" ? ( |
| 2381 | <p | ||
| 2382 | className="me-1 text-2xs text-muted-foreground/70" | ||
| 2383 | data-user-message-attribution="t3code" | ||
| 2384 | > | ||
| 2385 | Sent by T3 Code | ||
| 2386 | </p> | ||
| 2387 | ) : userMessage.attribution === "agent" ? ( | ||
| 2381 | <p className="me-1 text-2xs text-muted-foreground/70" data-user-message-attribution="agent"> | 2388 | <p className="me-1 text-2xs text-muted-foreground/70" data-user-message-attribution="agent"> |
| 2382 | {senderThreadId ? ( | 2389 | {senderThreadId ? ( |
| 2383 | <InlineButton | 2390 | <InlineButton |
| ... | @@ -5276,6 +5283,8 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { | ... | @@ -5276,6 +5283,8 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { |
| 5276 | return "eye"; | 5283 | return "eye"; |
| 5277 | case "background_task": | 5284 | case "background_task": |
| 5278 | return "zap"; | 5285 | return "zap"; |
| 5286 | case "system": | ||
| 5287 | return "t3-code"; | ||
| 5279 | default: | 5288 | default: |
| 5280 | source satisfies never; | 5289 | source satisfies never; |
| 5281 | return "zap"; | 5290 | return "zap"; |
packages/client-runtime/src/state/threadExecution.ts+1| ... | @@ -444,6 +444,7 @@ export function notificationChildThreadId( | ... | @@ -444,6 +444,7 @@ export function notificationChildThreadId( |
| 444 | case "command": | 444 | case "command": |
| 445 | case "monitor": | 445 | case "monitor": |
| 446 | case "background_task": | 446 | case "background_task": |
| 447 | case "system": | ||
| 447 | return undefined; | 448 | return undefined; |
| 448 | default: | 449 | default: |
| 449 | source satisfies never; | 450 | source satisfies never; |
packages/client-runtime/src/userMessage.test.ts+26-5| ... | @@ -9,7 +9,11 @@ describe("resolveUserMessagePresentation", () => { | ... | @@ -9,7 +9,11 @@ describe("resolveUserMessagePresentation", () => { |
| 9 | it("removes attribution from legacy scheduled prompts", () => { | 9 | it("removes attribution from legacy scheduled prompts", () => { |
| 10 | expect( | 10 | expect( |
| 11 | resolveUserMessagePresentation({ role: "user", createdBy: "agent", text: legacyText }), | 11 | resolveUserMessagePresentation({ role: "user", createdBy: "agent", text: legacyText }), |
| 12 | ).toEqual({ text: "Check for crashes.\n", isAutomation: true, scheduledTaskId: undefined }); | 12 | ).toEqual({ |
| 13 | text: "Check for crashes.\n", | ||
| 14 | attribution: "automation", | ||
| 15 | scheduledTaskId: undefined, | ||
| 16 | }); | ||
| 13 | }); | 17 | }); |
| 14 | 18 | ||
| 15 | it("uses task metadata without changing the prompt", () => { | 19 | it("uses task metadata without changing the prompt", () => { |
| ... | @@ -20,7 +24,7 @@ describe("resolveUserMessagePresentation", () => { | ... | @@ -20,7 +24,7 @@ describe("resolveUserMessagePresentation", () => { |
| 20 | scheduledTaskId: ScheduledTaskId.make("task-1"), | 24 | scheduledTaskId: ScheduledTaskId.make("task-1"), |
| 21 | text: legacyText, | 25 | text: legacyText, |
| 22 | }), | 26 | }), |
| 23 | ).toEqual({ text: legacyText, isAutomation: true, scheduledTaskId: "task-1" }); | 27 | ).toEqual({ text: legacyText, attribution: "automation", scheduledTaskId: "task-1" }); |
| 24 | }); | 28 | }); |
| 25 | 29 | ||
| 26 | it("preserves user-written and assistant-quoted schedule headers", () => { | 30 | it("preserves user-written and assistant-quoted schedule headers", () => { |
| ... | @@ -31,7 +35,7 @@ describe("resolveUserMessagePresentation", () => { | ... | @@ -31,7 +35,7 @@ describe("resolveUserMessagePresentation", () => { |
| 31 | ]) { | 35 | ]) { |
| 32 | expect(resolveUserMessagePresentation({ ...message, text: legacyText })).toEqual({ | 36 | expect(resolveUserMessagePresentation({ ...message, text: legacyText })).toEqual({ |
| 33 | text: legacyText, | 37 | text: legacyText, |
| 34 | isAutomation: false, | 38 | attribution: null, |
| 35 | scheduledTaskId: undefined, | 39 | scheduledTaskId: undefined, |
| 36 | }); | 40 | }); |
| 37 | } | 41 | } |
| ... | @@ -45,12 +49,29 @@ describe("resolveUserMessagePresentation", () => { | ... | @@ -45,12 +49,29 @@ describe("resolveUserMessagePresentation", () => { |
| 45 | ]) { | 49 | ]) { |
| 46 | expect(resolveUserMessagePresentation({ role: "user", createdBy: "agent", text })).toEqual({ | 50 | expect(resolveUserMessagePresentation({ role: "user", createdBy: "agent", text })).toEqual({ |
| 47 | text, | 51 | text, |
| 48 | isAutomation: false, | 52 | attribution: "agent", |
| 49 | scheduledTaskId: undefined, | 53 | scheduledTaskId: undefined, |
| 50 | }); | 54 | }); |
| 51 | } | 55 | } |
| 52 | }); | 56 | }); |
| 53 | 57 | ||
| 58 | it("attributes older server-sent restart continuations to T3 Code, not another agent", () => { | ||
| 59 | const text = "Note: the T3 server restarted."; | ||
| 60 | expect( | ||
| 61 | resolveUserMessagePresentation({ | ||
| 62 | role: "user", | ||
| 63 | createdBy: "agent", | ||
| 64 | creationSource: "server", | ||
| 65 | text, | ||
| 66 | }), | ||
| 67 | ).toMatchObject({ attribution: "t3code" }); | ||
| 68 | for (const creationSource of ["mcp", "provider"] as const) { | ||
| 69 | expect( | ||
| 70 | resolveUserMessagePresentation({ role: "user", createdBy: "agent", creationSource, text }), | ||
| 71 | ).toMatchObject({ attribution: "agent" }); | ||
| 72 | } | ||
| 73 | }); | ||
| 74 | |||
| 54 | it("recovers the triggering automation from older scheduler message ids", () => { | 75 | it("recovers the triggering automation from older scheduler message ids", () => { |
| 55 | for (const trigger of ["scheduled", "manual"]) { | 76 | for (const trigger of ["scheduled", "manual"]) { |
| 56 | expect( | 77 | expect( |
| ... | @@ -62,7 +83,7 @@ describe("resolveUserMessagePresentation", () => { | ... | @@ -62,7 +83,7 @@ describe("resolveUserMessagePresentation", () => { |
| 62 | }), | 83 | }), |
| 63 | ).toEqual({ | 84 | ).toEqual({ |
| 64 | text: "Check for crashes.\n", | 85 | text: "Check for crashes.\n", |
| 65 | isAutomation: true, | 86 | attribution: "automation", |
| 66 | scheduledTaskId: "task:daily-audit", | 87 | scheduledTaskId: "task:daily-audit", |
| 67 | }); | 88 | }); |
| 68 | } | 89 | } |
packages/client-runtime/src/userMessage.ts+38-10| ... | @@ -1,31 +1,59 @@ | ... | @@ -1,31 +1,59 @@ |
| 1 | import { type OrchestrationV2Actor, ScheduledTaskId } from "@t3tools/contracts"; | 1 | import { |
| 2 | type OrchestrationV2Actor, | ||
| 3 | type OrchestrationV2CreationSource, | ||
| 4 | ScheduledTaskId, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | 6 | ||
| 3 | const LEGACY_AUTOMATION_PREFIX = /^\[Triggered by schedule task: [^\r\n]+\]\r?\n\r?\n/; | 7 | const LEGACY_AUTOMATION_PREFIX = /^\[Triggered by schedule task: [^\r\n]+\]\r?\n\r?\n/; |
| 4 | const LEGACY_AUTOMATION_MESSAGE_ID = /^scheduled-task-message:(.+):\d+:(?:scheduled|manual)$/; | 8 | const LEGACY_AUTOMATION_MESSAGE_ID = /^scheduled-task-message:(.+):\d+:(?:scheduled|manual)$/; |
| 5 | 9 | ||
| 6 | /** Older scheduled messages stored their attribution in the prompt itself. */ | 10 | /** |
| 11 | * Text and sender label for a user-role message. Older scheduled messages | ||
| 12 | * stored their attribution in the prompt itself. | ||
| 13 | */ | ||
| 7 | export function resolveUserMessagePresentation(message: { | 14 | export function resolveUserMessagePresentation(message: { |
| 8 | readonly id?: string; | 15 | readonly id?: string; |
| 9 | readonly role: string; | 16 | readonly role: string; |
| 10 | readonly text: string; | 17 | readonly text: string; |
| 11 | readonly createdBy?: OrchestrationV2Actor; | 18 | readonly createdBy?: OrchestrationV2Actor; |
| 19 | readonly creationSource?: OrchestrationV2CreationSource; | ||
| 12 | readonly scheduledTaskId?: ScheduledTaskId; | 20 | readonly scheduledTaskId?: ScheduledTaskId; |
| 13 | }) { | 21 | }): { |
| 22 | readonly text: string; | ||
| 23 | /** Who sent a user-role message the user did not type. */ | ||
| 24 | readonly attribution: "automation" | "agent" | "t3code" | null; | ||
| 25 | readonly scheduledTaskId: ScheduledTaskId | undefined; | ||
| 26 | } { | ||
| 14 | if (message.role !== "user") { | 27 | if (message.role !== "user") { |
| 15 | return { text: message.text, isAutomation: false, scheduledTaskId: undefined }; | 28 | return { text: message.text, attribution: null, scheduledTaskId: undefined }; |
| 16 | } | 29 | } |
| 17 | if (message.scheduledTaskId !== undefined) { | 30 | if (message.scheduledTaskId !== undefined) { |
| 18 | return { text: message.text, isAutomation: true, scheduledTaskId: message.scheduledTaskId }; | 31 | return { |
| 32 | text: message.text, | ||
| 33 | attribution: "automation", | ||
| 34 | scheduledTaskId: message.scheduledTaskId, | ||
| 35 | }; | ||
| 19 | } | 36 | } |
| 20 | const legacyPrefix = LEGACY_AUTOMATION_PREFIX.exec(message.text); | 37 | const legacyPrefix = LEGACY_AUTOMATION_PREFIX.exec(message.text); |
| 21 | const legacyTaskId = legacyPrefix | 38 | const legacyTaskId = legacyPrefix |
| 22 | ? LEGACY_AUTOMATION_MESSAGE_ID.exec(message.id ?? "")?.[1] | 39 | ? LEGACY_AUTOMATION_MESSAGE_ID.exec(message.id ?? "")?.[1] |
| 23 | : undefined; | 40 | : undefined; |
| 24 | const isAutomation = | 41 | if (legacyPrefix !== null && (legacyTaskId !== undefined || message.createdBy === "agent")) { |
| 25 | legacyPrefix !== null && (legacyTaskId !== undefined || message.createdBy === "agent"); | 42 | return { |
| 43 | text: message.text.slice(legacyPrefix[0].length), | ||
| 44 | attribution: "automation", | ||
| 45 | scheduledTaskId: legacyTaskId === undefined ? undefined : ScheduledTaskId.make(legacyTaskId), | ||
| 46 | }; | ||
| 47 | } | ||
| 26 | return { | 48 | return { |
| 27 | text: isAutomation ? message.text.slice(legacyPrefix[0].length) : message.text, | 49 | text: message.text, |
| 28 | isAutomation, | 50 | // Restart continuations were sent as the agent before they became notices. |
| 29 | scheduledTaskId: legacyTaskId === undefined ? undefined : ScheduledTaskId.make(legacyTaskId), | 51 | attribution: |
| 52 | message.createdBy !== "agent" | ||
| 53 | ? null | ||
| 54 | : message.creationSource === "server" | ||
| 55 | ? "t3code" | ||
| 56 | : "agent", | ||
| 57 | scheduledTaskId: undefined, | ||
| 30 | }; | 58 | }; |
| 31 | } | 59 | } |
packages/contracts/src/orchestrationV2.ts+2| ... | @@ -1097,6 +1097,8 @@ export const OrchestrationV2NotificationSource = kindUnionWithFallback( | ... | @@ -1097,6 +1097,8 @@ export const OrchestrationV2NotificationSource = kindUnionWithFallback( |
| 1097 | CommandNotificationSource, | 1097 | CommandNotificationSource, |
| 1098 | Schema.Struct({ kind: Schema.Literal("monitor") }), | 1098 | Schema.Struct({ kind: Schema.Literal("monitor") }), |
| 1099 | Schema.Struct({ kind: Schema.Literal("background_task") }), | 1099 | Schema.Struct({ kind: Schema.Literal("background_task") }), |
| 1100 | /** T3 Code itself, such as a restart continuing an interrupted turn. */ | ||
| 1101 | Schema.Struct({ kind: Schema.Literal("system") }), | ||
| 1100 | ], | 1102 | ], |
| 1101 | (kind) => Schema.Struct({ kind }), | 1103 | (kind) => Schema.Struct({ kind }), |
| 1102 | () => ({ kind: "background_task" }), | 1104 | () => ({ kind: "background_task" }), |