Skip to content

Commit 0984ed0

Browse files
Merge remote-tracking branch 'origin/t3/thread-task-evals' into t3/background-subagent-fix-b
2 parents b9aa2ca + a367d12 commit 0984ed0

26 files changed

Lines changed: 1052 additions & 406 deletions

‎apps/mobile/src/lib/threadActivity.test.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -846,7 +846,7 @@ describe("buildThreadFeed", () => {
846846
expect(collapsed[1]).toMatchObject({
847847
type: "run-fold",
848848
createdAt: "2026-06-20T00:00:01.500Z",
849-
label: "Worked for 2.0s",
849+
label: "Worked for 2s",
850850
});
851851

852852
const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId]));
@@ -1100,10 +1100,10 @@ describe("buildThreadFeed", () => {
11001100
const settled = deriveThreadFeedPresentation(feed(false), null, new Set());
11011101
expect(shape(settled)).toEqual([
11021102
"user:launch",
1103-
"fold:Worked for 8.0s",
1103+
"fold:Worked for 8s",
11041104
"assistant:launch-answer",
11051105
"user:resume",
1106-
"fold:Worked for 8.0s",
1106+
"fold:Worked for 8s",
11071107
"assistant:resume-answer",
11081108
]);
11091109
const launchFold = settled.find((entry) => entry.type === "run-fold");
@@ -1112,11 +1112,11 @@ describe("buildThreadFeed", () => {
11121112
shape(deriveThreadFeedPresentation(feed(false), null, new Set([launchFold.runId]))),
11131113
).toEqual([
11141114
"user:launch",
1115-
"fold:Worked for 8.0s",
1115+
"fold:Worked for 8s",
11161116
"activity-group",
11171117
"assistant:launch-answer",
11181118
"user:resume",
1119-
"fold:Worked for 8.0s",
1119+
"fold:Worked for 8s",
11201120
"assistant:resume-answer",
11211121
]);
11221122

@@ -1133,7 +1133,7 @@ describe("buildThreadFeed", () => {
11331133
),
11341134
).toEqual([
11351135
"user:launch",
1136-
"fold:Worked for 8.0s",
1136+
"fold:Worked for 8s",
11371137
"assistant:launch-answer",
11381138
"user:resume",
11391139
"activity-group",

‎apps/server/src/cli/trace.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export function makeTraceSpanSummary(sinceMs = -Infinity) {
100100
}
101101

102102
const formatMs = (ms: number) =>
103-
ms < 1_000 ? `${Math.round(ms)}ms` : `${(ms / 1_000).toFixed(1)}s`;
103+
ms < 1_000 ? `${Math.round(ms)}ms` : `${Math.round(ms / 100) / 10}s`;
104104

105105
function formatTraceSummary(
106106
summary: ReturnType<ReturnType<typeof makeTraceSpanSummary>["finish"]>,
@@ -127,7 +127,7 @@ function formatTraceSummary(
127127
const widths = header.map((_, column) => Math.max(...table.map((row) => row[column]!.length)));
128128
const formatIso = (ms: number) => DateTime.formatIso(DateTime.makeUnsafe(ms));
129129
return [
130-
`${summary.spanCount} spans ended from ${formatIso(summary.firstEndMs)} to ${formatIso(summary.lastEndMs)} (${summary.minutes.toFixed(1)} min).`,
130+
`${summary.spanCount} spans ended from ${formatIso(summary.firstEndMs)} to ${formatIso(summary.lastEndMs)} (${summary.minutes.toFixed(1).replace(/\.0+$/, "")} min).`,
131131
...(summary.skippedLineCount > 0
132132
? [`Skipped ${summary.skippedLineCount} lines that are not spans.`]
133133
: []),

‎apps/server/src/mcp/toolkits/core.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,48 @@ it.effect("records only failed calls of tools that hand off no work", () => {
350350
);
351351
});
352352

353+
it.effect("rejects an unknown organize target instead of archiving the calling thread", () => {
354+
const dispatched: Array<unknown> = [];
355+
return Effect.gen(function* () {
356+
const server = yield* McpServer.McpServer;
357+
const outcome = yield* server
358+
.callTool({
359+
name: "t3_thread_organize",
360+
arguments: { action: "archive", threadIds: ["other-thread"] },
361+
})
362+
.pipe(
363+
Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
364+
Effect.provideService(McpSchema.McpServerClient, client),
365+
Effect.result,
366+
);
367+
expect(dispatched).toEqual([]);
368+
expect(outcome).toMatchObject({
369+
_tag: "Failure",
370+
failure: { message: expect.stringContaining("Expected no excess property") },
371+
});
372+
}).pipe(
373+
Effect.provide(
374+
layerThreadToolkit.pipe(
375+
Layer.provideMerge(McpServer.McpServer.layer),
376+
Layer.provide(NodeCrypto.layer),
377+
Layer.provide(
378+
Layer.mock(ThreadManagement.ThreadManagementService)({
379+
getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)),
380+
getProjectThreadRecords: ({ threadId: id }) =>
381+
Effect.succeed(
382+
McpToolAccessTestkit.idleThreadProjection(McpToolAccessTestkit.liveThreadShell(id)),
383+
),
384+
dispatch: (command) => {
385+
dispatched.push(command);
386+
return Effect.succeed({ sequence: 1 } as never);
387+
},
388+
}),
389+
),
390+
),
391+
),
392+
);
393+
});
394+
353395
it.effect("keeps unexpected handler defects private through the production registration", () =>
354396
Effect.gen(function* () {
355397
const server = yield* McpServer.McpServer;

‎apps/server/src/mcp/toolkits/thread/tools.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ const ThreadOrganizeTool = Tool.make("t3_thread_organize", {
6161
],
6262
})
6363
.annotate(Tool.Title, "Organize a thread")
64+
.annotate(Tool.Strict, true)
6465
.annotate(Tool.Destructive, true);
6566

6667
const commandTool = {

‎apps/server/src/orchestration-v2/Orchestrator.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11062,6 +11062,45 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
1106211062
occurredAt: now,
1106311063
payload: { ...run, status: "interrupted", completedAt: now },
1106411064
});
11065+
// Stop cancels a pending restart, so it must end the turn that restart could not stop.
11066+
const supersededTurn = projection.providerTurns.findLast(
11067+
(candidate) =>
11068+
candidate.status === "running" &&
11069+
projection.attempts.some(
11070+
(attempt) => attempt.id === candidate.runAttemptId && attempt.runId === run.id,
11071+
),
11072+
);
11073+
const supersededOwner =
11074+
supersededTurn === undefined
11075+
? undefined
11076+
: projection.providerThreads.find(
11077+
(candidate) => candidate.id === supersededTurn.providerThreadId,
11078+
);
11079+
if (
11080+
supersededTurn !== undefined &&
11081+
supersededOwner !== undefined &&
11082+
supersededOwner.providerSessionId !== null &&
11083+
Option.isSome(
11084+
yield* providerSessions
11085+
.get(supersededOwner.providerSessionId)
11086+
.pipe(Effect.orElseSucceed(() => Option.none())),
11087+
)
11088+
) {
11089+
const id = `effect:${command.commandId}:provider-turn.interrupt:${supersededTurn.id}`;
11090+
if (!otherProviderInterrupts.some((effect) => effect.id === id)) {
11091+
otherProviderInterrupts.push({
11092+
id,
11093+
commandId: command.commandId,
11094+
threadId: command.threadId,
11095+
request: {
11096+
type: "provider-turn.interrupt",
11097+
providerSessionId: supersededOwner.providerSessionId,
11098+
providerThreadId: supersededOwner.id,
11099+
providerTurnId: supersededTurn.id,
11100+
},
11101+
});
11102+
}
11103+
}
1106511104
yield* stopRemainingWork();
1106611105
yield* Ref.update(effects, (existing) => [...existing, ...otherProviderInterrupts]);
1106711106
return {

‎apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,118 @@ it.live("retries a failed selection restart session open before completing", ()
438438
),
439439
);
440440

441+
it.live("Stop during a restart interrupts the turn the restart could not stop", () =>
442+
Effect.scoped(
443+
Effect.gen(function* () {
444+
const cwd = yield* checkpointWorkspace("selection-restart-stop");
445+
const threadId = ThreadId.make("thread:selection-restart-stop");
446+
const state = yield* Ref.make<RestartAdapterState>({
447+
activeTurn: null,
448+
opened: [],
449+
started: [],
450+
closedSessionCount: 0,
451+
failedReplacementOpen: false,
452+
});
453+
const base = makeRestartAdapter(state);
454+
// A wedged provider ignores the restart's soft cancel; only Stop ends its turn.
455+
const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = {
456+
...base,
457+
openSession: (input) =>
458+
base.openSession(input).pipe(
459+
Effect.map((session) => ({
460+
...session,
461+
interruptTurn: (interrupt: ProviderAdapter.ProviderAdapterV2InterruptInput) =>
462+
interrupt.requestRuntimeRestart === true
463+
? session.interruptTurn(interrupt)
464+
: Effect.never,
465+
})),
466+
),
467+
};
468+
469+
const result = yield* Effect.gen(function* () {
470+
const orchestrator = yield* Orchestrator.OrchestratorV2;
471+
const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
472+
const running = yield* orchestrator.streamDomainEvents.pipe(
473+
Stream.filter(
474+
(event) => event.type === "provider-turn.updated" && event.payload.status === "running",
475+
),
476+
Stream.take(1),
477+
Stream.runDrain,
478+
Effect.forkScoped({ startImmediately: true }),
479+
);
480+
yield* orchestrator.dispatch({
481+
type: "thread.create",
482+
createdBy: "user",
483+
creationSource: "web",
484+
commandId: CommandId.make("command:selection-restart-stop:create"),
485+
threadId,
486+
projectId: ProjectId.make("project:selection-restart-stop"),
487+
title: "Stop during restart",
488+
modelSelection: initialSelection,
489+
runtimeMode: "full-access",
490+
interactionMode: "default",
491+
branch: null,
492+
worktreePath: cwd,
493+
});
494+
yield* orchestrator.dispatch({
495+
type: "message.dispatch",
496+
createdBy: "user",
497+
creationSource: "web",
498+
commandId: CommandId.make("command:selection-restart-stop:first"),
499+
threadId,
500+
messageId: MessageId.make("message:selection-restart-stop:first"),
501+
text: "first",
502+
attachments: [],
503+
modelSelection: initialSelection,
504+
dispatchMode: { type: "start_immediately" },
505+
});
506+
yield* worker.drain();
507+
yield* Fiber.join(running);
508+
const runId = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!.id;
509+
510+
yield* orchestrator.dispatch({
511+
type: "message.dispatch",
512+
createdBy: "user",
513+
creationSource: "web",
514+
commandId: CommandId.make("command:selection-restart-stop:steer"),
515+
threadId,
516+
messageId: MessageId.make("message:selection-restart-stop:steer"),
517+
text: "Are you still going?",
518+
attachments: [],
519+
modelSelection: initialSelection,
520+
dispatchMode: { type: "restart_active", targetRunId: runId },
521+
});
522+
yield* orchestrator.dispatch({
523+
type: "run.interrupt",
524+
commandId: CommandId.make("command:selection-restart-stop:stop"),
525+
threadId,
526+
runId,
527+
});
528+
yield* worker.drain();
529+
return {
530+
projection: yield* orchestrator.getThreadProjection(threadId),
531+
captured: yield* Ref.get(state),
532+
};
533+
}).pipe(
534+
Effect.provide(
535+
ProviderReplayHarness.layerWithRegistry(
536+
{ name: "selection-restart-stop" },
537+
ProviderAdapterRegistry.layerSingle(adapter),
538+
),
539+
),
540+
);
541+
542+
assert.isNull(result.captured.activeTurn);
543+
assert.lengthOf(result.captured.started, 1);
544+
assert.equal(result.projection.runs[0]?.status, "interrupted");
545+
assert.deepEqual(
546+
result.projection.providerTurns.map((turn) => turn.status),
547+
["interrupted"],
548+
);
549+
}),
550+
),
551+
);
552+
441553
it.live(
442554
"restarts the live session on a model change when a newer stopped session record exists",
443555
() =>

‎apps/web/src/components/chat/ChatComposer.tsx‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ import {
133133
import { attachmentUploadBlockReason } from "../../lib/attachmentUploadState";
134134
import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
135135
import { shortcutLabelForCommand } from "../../keybindings";
136+
import { persistClientSettingsUpdate } from "../../hooks/useSettings";
136137
import {
137138
type TerminalContextDraft,
138139
type TerminalContextSelection,
@@ -198,6 +199,7 @@ import {
198199
ComposerRestingImagePreviews,
199200
} from "./ComposerAttachmentStrip";
200201
import { ComposerPrimaryActions } from "./ComposerPrimaryActions";
202+
import { ComposerRichTextToggle } from "./ComposerRichTextToggle";
201203
import { ComposerFooterPrimaryActions } from "./ComposerFooterControls";
202204
import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
203205
import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";
@@ -304,6 +306,7 @@ export interface ChatComposerHandle {
304306
openModelPicker: () => void;
305307
stepReasoningEffort: (direction: 1 | -1) => void;
306308
toggleModelPicker: () => void;
309+
toggleRichText: () => void;
307310
openControl: (command: KeybindingCommand) => void;
308311
isModelPickerOpen: () => boolean;
309312
isCaretAtStart: () => boolean;
@@ -2852,6 +2855,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28522855
openModelPickerStable();
28532856
}
28542857
},
2858+
toggleRichText: () => {
2859+
if (isLiteralPendingAnswer) return;
2860+
persistClientSettingsUpdate((current) => ({
2861+
...current,
2862+
composerRichTextEnabled: !current.composerRichTextEnabled,
2863+
})).catch(console.error);
2864+
},
28552865
openControl: (command) => {
28562866
if (composerBlurFrameRef.current !== null) {
28572867
window.cancelAnimationFrame(composerBlurFrameRef.current);
@@ -3429,7 +3439,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
34293439
showMobilePendingAnswerActions && "hidden sm:flex",
34303440
)}
34313441
>
3432-
{composerAttachButton ? <div className="me-2">{composerAttachButton}</div> : null}
3442+
{composerAttachButton || !isLiteralPendingAnswer ? (
3443+
<div className="me-2 flex items-center gap-0.5">
3444+
{composerAttachButton}
3445+
{isLiteralPendingAnswer ? null : (
3446+
<ComposerRichTextToggle
3447+
richTextEnabled={settings.composerRichTextEnabled}
3448+
shortcutLabel={shortcutLabelForCommand(
3449+
keybindings,
3450+
"composer.toggleRichText",
3451+
{
3452+
context: { terminalFocus: false, terminalOpen, modelPickerOpen: false },
3453+
},
3454+
)}
3455+
/>
3456+
)}
3457+
</div>
3458+
) : null}
34333459
<div
34343460
data-chat-composer-actions="right"
34353461
data-chat-composer-transition-actions="true"
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { PilcrowIcon } from "lucide-react";
2+
3+
import { updateClientSettings } from "../../hooks/useSettings";
4+
import { Button } from "../ui/button";
5+
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
6+
7+
// Same setting as Settings → Chat → Rich text composer. Flipping it remounts the
8+
// editor; pointer-down is swallowed so a focused editor keeps its caret across that.
9+
export function ComposerRichTextToggle({
10+
richTextEnabled,
11+
shortcutLabel,
12+
}: {
13+
readonly richTextEnabled: boolean;
14+
readonly shortcutLabel: string | null;
15+
}) {
16+
const showingSource = !richTextEnabled;
17+
return (
18+
<Tooltip>
19+
<TooltipTrigger
20+
render={
21+
<Button
22+
type="button"
23+
variant="ghost"
24+
size="icon-sm"
25+
aria-label="Markdown source"
26+
aria-pressed={showingSource}
27+
data-pressed={showingSource ? "" : undefined}
28+
onPointerDown={(event) => event.preventDefault()}
29+
onClick={() => void updateClientSettings({ composerRichTextEnabled: showingSource })}
30+
/>
31+
}
32+
>
33+
<PilcrowIcon />
34+
</TooltipTrigger>
35+
<TooltipPopup>
36+
{showingSource ? "Show formatting" : "Show Markdown source"}
37+
{shortcutLabel ? ` · ${shortcutLabel}` : null}
38+
</TooltipPopup>
39+
</Tooltip>
40+
);
41+
}

0 commit comments

Comments
 (0)