Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1719,11 +1719,15 @@ function renderFeedEntry(
className="mb-5 items-end"
{...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})}
>
{presentation.isAutomation ? (
{presentation.attribution === "automation" ? (
<Text className="mb-1 pr-1 font-t3-medium text-2xs text-foreground-muted opacity-60">
Sent by automation
</Text>
) : message.createdBy === "agent" ? (
) : presentation.attribution === "t3code" ? (
<Text className="mb-1 pr-1 font-t3-medium text-2xs text-foreground-muted opacity-60">
Sent by T3 Code
</Text>
) : presentation.attribution === "agent" ? (
<AgentMessageAttribution
environmentId={props.environmentId}
senderThreadId={message.senderThreadId}
Expand Down
20 changes: 19 additions & 1 deletion apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,24 @@ it("shows only the structured path in expanded mobile read details", () => {
expect(withoutPath?.canExpand).toBe(false);
});

it("expands a T3 Code notice to the prompt the agent got", () => {
const item = {
...base("restart-notice", "2026-06-20T00:00:03.000Z", 2),
type: "notification",
source: { kind: "system" },
outcome: "updated",
summary: "T3 Code restarted and resumed this turn",
detail: "Continue where you left off.",
} as OrchestrationV2TurnItem;
const [activity] = buildThreadFeed([projected(item, 0)]).flatMap((entry) =>
entry.type === "activity-group" ? entry.activities : [],
);

expect(activity?.summary).toBe("T3 Code restarted and resumed this turn");
expect(activity?.canExpand).toBe(true);
expect(activity?.getFullDetail()).toBe("Continue where you left off.");
});

it("labels file searches with the adapter title and its search target", () => {
const item: OrchestrationV2TurnItem = {
...base("file-search", "2026-06-20T00:00:03.000Z", 2),
Expand Down Expand Up @@ -317,7 +335,7 @@ describe("buildThreadFeed", () => {
expect(messageEntry).toBeDefined();
expect(resolveUserMessagePresentation(messageEntry!.message)).toMatchObject({
text: "Run checks",
isAutomation: true,
attribution: "automation",
});
});

Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] {
case "monitor":
return "eye";
case "background_task":
case "system":
return "zap";
default:
source satisfies never;
Expand Down Expand Up @@ -814,7 +815,11 @@ function toFeedActivity(
? collectToolFilePaths(item)
: null;
const getFullDetail = memoizeValue(() =>
readPaths ? readPaths.join("\n") || null : formatItemFullDetail(row, item),
readPaths
? readPaths.join("\n") || null
: item.type === "notification"
? item.detail?.trim() || null
: formatItemFullDetail(row, item),
);
const getCopyText = memoizeValue(() =>
[summary, detail, getFullDetail()]
Expand Down
8 changes: 7 additions & 1 deletion apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4667,11 +4667,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
dispatchMode = { type: "start_immediately" };
}
}
// A restart continuation resumes the interrupted turn, so it starts
// immediately rather than queueing like other wakes.
if (
command.notification !== undefined &&
(command.createdBy !== "agent" ||
(command.creationSource !== "server" && command.creationSource !== "provider") ||
dispatchMode.type !== "queue_after_active")
(dispatchMode.type !== "queue_after_active" &&
!(
command.restartContinuationOfRunId !== undefined &&
dispatchMode.type === "start_immediately"
)))
) {
return yield* new OrchestratorDispatchError({
commandId: command.commandId,
Expand Down
26 changes: 22 additions & 4 deletions apps/server/src/orchestration-v2/RestartContinuation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
RunAttemptId,
RunId,
ThreadId,
type OrchestrationV2Command,
type OrchestrationV2ThreadProjection,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
Expand Down Expand Up @@ -606,27 +607,32 @@ it.effect("does not cancel or resume a run that completes while shutdown intent
}),
);

const continuationTexts = (projection: OrchestrationV2ThreadProjection) =>
const continuationDispatches = (projection: OrchestrationV2ThreadProjection) =>
Effect.gen(function* () {
const texts: Array<string> = [];
const dispatches: Array<Extract<OrchestrationV2Command, { type: "message.dispatch" }>> = [];
yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe(
Effect.provide(
Layer.merge(
Layer.mock(ThreadManagementService.ThreadManagementService)({
getThreadRecords: () => Effect.succeed(projection),
recoverDelegatedTask: () => Effect.void,
dispatch: (command) => {
if (command.type === "message.dispatch") texts.push(command.text);
if (command.type === "message.dispatch") dispatches.push(command);
return Effect.succeed({} as never);
},
}),
ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }),
),
),
);
return texts;
return dispatches;
});

const continuationTexts = (projection: OrchestrationV2ThreadProjection) =>
continuationDispatches(projection).pipe(
Effect.map((dispatches) => dispatches.map((command) => command.text)),
);

const cutMidTurn = (extra: Record<string, unknown> = {}) => {
const base = makeProjection();
return {
Expand Down Expand Up @@ -740,6 +746,18 @@ it.effect("tells a turn cut mid-way about the background work it lost", () =>
}),
);

it.effect("shows a continuation as a T3 Code notice whose detail is the prompt", () =>
Effect.gen(function* () {
const [resumed] = yield* continuationDispatches(cutMidTurn());
assert.deepEqual(resumed?.notification, {
source: { kind: "system" },
outcome: "updated",
summary: "T3 Code restarted and resumed this turn",
detail: "Continue where you left off.",
});
}),
);

it.effect(
"carries the note forward when its continuation was cut before reaching the provider",
() =>
Expand Down
22 changes: 16 additions & 6 deletions apps/server/src/orchestration-v2/RestartContinuation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,17 +153,27 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta
);
const noteText =
note.work.length === 0 ? undefined : restartCancelledBackgroundWorkNote(note.work);
const text =
noteText === undefined
? CONTINUE_PROMPT
: note.settled
? noteText
: `${noteText}\n\n${CONTINUE_PROMPT}`;
yield* threads.dispatch({
type: "message.dispatch",
commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`),
threadId: input.threadId,
messageId,
text:
noteText === undefined
? CONTINUE_PROMPT
: note.settled
? noteText
: `${noteText}\n\n${CONTINUE_PROMPT}`,
text,
// Shown as a work log row; the prompt the agent got is its detail.
notification: {
source: { kind: "system" },
outcome: "updated",
summary: note.settled
? "T3 Code restarted and stopped background work"
: "T3 Code restarted and resumed this turn",
detail: text,
},
attachments: [],
modelSelection: source.modelSelection,
dispatchMode: { type: "start_immediately" },
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/orchestration-v2/runtimeLayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3211,6 +3211,12 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
threadId,
messageId: MessageId.make("restart-automatic-message"),
text: "Continue where you left off.",
notification: {
source: { kind: "system" as const },
outcome: "updated" as const,
summary: "T3 Code restarted and resumed this turn",
detail: "Continue where you left off.",
},
attachments: [],
modelSelection,
dispatchMode: { type: "start_immediately" as const },
Expand All @@ -3221,6 +3227,17 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
const admitted = yield* orchestrator.getThreadProjection(threadId);
assert.lengthOf(admitted.runs, 2);
assert.equal(admitted.runs[1]?.restartContinuationOfRunId, original.id);
// The timeline shows a work log row; the prompt stays in its detail.
const continuationItems = admitted.turnItems.filter(
(item) => item.runId === admitted.runs[1]?.id,
);
assert.isFalse(continuationItems.some((item) => item.type === "user_message"));
assert.deepInclude(
continuationItems.flatMap((item) =>
item.type === "notification" ? [[item.summary, item.detail]] : [],
),
["T3 Code restarted and resumed this turn", "Continue where you left off."],
);
// A differently identified stale delivery still must not create another run.
yield* orchestrator.dispatch({
...command,
Expand Down
13 changes: 11 additions & 2 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2357,7 +2357,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"

return (
<div className="group flex flex-col items-end gap-1">
{userMessage.isAutomation ? (
{userMessage.attribution === "automation" ? (
<p
className="me-1 text-2xs text-muted-foreground/70"
data-user-message-attribution="automation"
Expand All @@ -2377,7 +2377,14 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
"Sent by automation"
)}
</p>
) : row.message.createdBy === "agent" ? (
) : userMessage.attribution === "t3code" ? (
<p
className="me-1 text-2xs text-muted-foreground/70"
data-user-message-attribution="t3code"
>
Sent by T3 Code
</p>
) : userMessage.attribution === "agent" ? (
<p className="me-1 text-2xs text-muted-foreground/70" data-user-message-attribution="agent">
{senderThreadId ? (
<InlineButton
Expand Down Expand Up @@ -5276,6 +5283,8 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
return "eye";
case "background_task":
return "zap";
case "system":
return "t3-code";
default:
source satisfies never;
return "zap";
Expand Down
1 change: 1 addition & 0 deletions packages/client-runtime/src/state/threadExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ export function notificationChildThreadId(
case "command":
case "monitor":
case "background_task":
case "system":
return undefined;
default:
source satisfies never;
Expand Down
31 changes: 26 additions & 5 deletions packages/client-runtime/src/userMessage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ describe("resolveUserMessagePresentation", () => {
it("removes attribution from legacy scheduled prompts", () => {
expect(
resolveUserMessagePresentation({ role: "user", createdBy: "agent", text: legacyText }),
).toEqual({ text: "Check for crashes.\n", isAutomation: true, scheduledTaskId: undefined });
).toEqual({
text: "Check for crashes.\n",
attribution: "automation",
scheduledTaskId: undefined,
});
});

it("uses task metadata without changing the prompt", () => {
Expand All @@ -20,7 +24,7 @@ describe("resolveUserMessagePresentation", () => {
scheduledTaskId: ScheduledTaskId.make("task-1"),
text: legacyText,
}),
).toEqual({ text: legacyText, isAutomation: true, scheduledTaskId: "task-1" });
).toEqual({ text: legacyText, attribution: "automation", scheduledTaskId: "task-1" });
});

it("preserves user-written and assistant-quoted schedule headers", () => {
Expand All @@ -31,7 +35,7 @@ describe("resolveUserMessagePresentation", () => {
]) {
expect(resolveUserMessagePresentation({ ...message, text: legacyText })).toEqual({
text: legacyText,
isAutomation: false,
attribution: null,
scheduledTaskId: undefined,
});
}
Expand All @@ -45,12 +49,29 @@ describe("resolveUserMessagePresentation", () => {
]) {
expect(resolveUserMessagePresentation({ role: "user", createdBy: "agent", text })).toEqual({
text,
isAutomation: false,
attribution: "agent",
scheduledTaskId: undefined,
});
}
});

it("attributes older server-sent restart continuations to T3 Code, not another agent", () => {
const text = "Note: the T3 server restarted.";
expect(
resolveUserMessagePresentation({
role: "user",
createdBy: "agent",
creationSource: "server",
text,
}),
).toMatchObject({ attribution: "t3code" });
for (const creationSource of ["mcp", "provider"] as const) {
expect(
resolveUserMessagePresentation({ role: "user", createdBy: "agent", creationSource, text }),
).toMatchObject({ attribution: "agent" });
}
});

it("recovers the triggering automation from older scheduler message ids", () => {
for (const trigger of ["scheduled", "manual"]) {
expect(
Expand All @@ -62,7 +83,7 @@ describe("resolveUserMessagePresentation", () => {
}),
).toEqual({
text: "Check for crashes.\n",
isAutomation: true,
attribution: "automation",
scheduledTaskId: "task:daily-audit",
});
}
Expand Down
Loading
Loading