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
14 changes: 11 additions & 3 deletions apps/mobile/src/features/threads/thread-handoff-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,24 @@ function HandoffEndpoint(props: {
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint="Show handoff model"
className="size-6 items-center justify-center"
accessibilityHint="Show provider account"
className="min-h-6 max-w-full flex-row items-center justify-center gap-1"
hitSlop={{ top: 8, bottom: 8 }}
onPress={() => Alert.alert(label)}
onPress={() =>
Alert.alert(
label,
provider ? resolveProviderInstanceDisplayName(provider) : props.instanceId,
)
}
>
<ProviderIcon
provider={provider?.driver ?? props.instanceId}
iconUrl={provider?.iconUrl}
size={12}
/>
<Text numberOfLines={1} className="shrink text-xs text-foreground-muted">
{label}
</Text>
</Pressable>
);
}
28 changes: 28 additions & 0 deletions apps/mobile/src/features/threads/thread-provider-switching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,34 @@ describe("threadAllowsProviderSwitch", () => {
expect(threadAllowsProviderSwitch({ thread: unstartedThread, projection: null })).toBe(true);
});

it("allows an imported thread to hand off before opening a provider session", () => {
expect(
threadAllowsProviderSwitch({
thread: startedThread,
projection: {
thread: { id: "thread", activeProviderThreadId: null, historyOrigin: "v1_import" },
runs: [],
providerThreads: [],
providerSessions: [],
} as unknown as OrchestrationV2ThreadProjection,
}),
).toBe(true);
});

it("keeps a preparing turn bound before its provider session appears", () => {
expect(
threadAllowsProviderSwitch({
thread: startedThread,
projection: {
thread: { id: "thread", activeProviderThreadId: null, historyOrigin: "v1_import" },
runs: [{ status: "preparing", providerThreadId: "provider-thread" }],
providerThreads: [],
providerSessions: [],
} as unknown as OrchestrationV2ThreadProjection,
}),
).toBe(false);
});

it("keeps a started thread bound until its projection resolves a session", () => {
expect(threadAllowsProviderSwitch({ thread: startedThread, projection: null })).toBe(false);
});
Expand Down
10 changes: 4 additions & 6 deletions apps/mobile/src/features/threads/thread-provider-switching.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { resolveThreadProviderSession } from "@t3tools/client-runtime/state/thread-workflows";
import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows";
import type { OrchestrationV2ThreadProjection } from "@t3tools/contracts";

type ThreadStartMarkers = Pick<
Expand All @@ -24,9 +24,7 @@ export function threadAllowsProviderSwitch(input: {
readonly thread: ThreadStartMarkers | null | undefined;
readonly projection: OrchestrationV2ThreadProjection | null | undefined;
}): boolean {
const session = input.projection ? resolveThreadProviderSession(input.projection) : null;
if (session !== null) {
return session.capabilities.sessions.supportsProviderSwitchingViaHandoff;
}
return !threadShellHasStarted(input.thread);
return input.projection != null
? threadSupportsProviderHandoff(input.projection)
: !threadShellHasStarted(input.thread);
}
15 changes: 15 additions & 0 deletions apps/server/src/orchestration-v2/CommandPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,21 @@ it("resolves automatic message delivery from authoritative provider capabilities
);
});

it.each(["preparing", "starting"] as const)(
"queues an automatic message while the handoff run is %s",
(status) => {
const projection = dispatchProjection(baseCapabilities);
assert.deepEqual(
resolveMessageDispatchIntent(
{ ...projection, runs: projection.runs.map((run) => ({ ...run, status })) },
{ type: "start_immediately" },
"auto",
),
{ type: "queue_after_active" },
);
},
);

it("targets the latest active run for explicit steer and restart intent", () => {
const projection = dispatchProjection(baseCapabilities);
assert.deepEqual(
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/orchestration-v2/CommandPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ export function resolveMessageDispatchIntent(
if (deliveryIntent === "restart") {
return { type: "restart_active", targetRunId: activeRun.id };
}
if (activeRun.status === "preparing" || activeRun.status === "starting") {
return { type: "queue_after_active" };
}

const providerThread = projection.providerThreads.find(
(candidate) => candidate.id === activeRun.providerThreadId,
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/orchestration-v2/ProviderFailure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
RunId,
ThreadId,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";

Expand Down Expand Up @@ -66,6 +67,17 @@ it("does not split a surrogate pair at the truncation boundary", () => {
assert.notMatch(failure.message.slice(0, -1), /[\uD800-\uDBFF]$/u);
});

it("does not expose error or Effect cause messages", () => {
const cause = new Error("private command output", { cause: new Error("private nested output") });
for (const value of [cause, Cause.fail(cause), "private string", { message: "private object" }]) {
assert.equal(makeProviderFailure({ cause: value }).message, "Provider turn failed.");
assert.equal(
makeProviderFailure({ cause: value, message: "Provider connection closed." }).message,
"Provider connection closed.",
);
}
});

it("does not serialize arbitrary provider causes", () => {
const failure = makeProviderFailure({
cause: {
Expand Down
5 changes: 1 addition & 4 deletions apps/server/src/orchestration-v2/ProviderFailure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,7 @@ export function makeProviderFailure(input: {
readonly class?: OrchestrationV2ProviderFailureClass;
readonly retryable?: boolean | null;
}): OrchestrationV2ProviderFailure {
const rawMessage =
input.message ??
(input.cause instanceof Error ? input.cause.message : stringField(input.cause, "message")) ??
DEFAULT_PROVIDER_FAILURE_MESSAGE;
const rawMessage = input.message ?? DEFAULT_PROVIDER_FAILURE_MESSAGE;
const message = boundedText(rawMessage, MAX_PROVIDER_FAILURE_MESSAGE_LENGTH);
const rawCode = input.code ?? stringField(input.cause, "code") ?? null;
const code =
Expand Down
16 changes: 5 additions & 11 deletions apps/server/src/orchestration-v2/RunExecutionService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2939,6 +2939,8 @@ it.effect("refreshes pull requests after a provider stream exits with an error",
written.map((item) => item.type),
["error"],
);
const error = written.find((item) => item.type === "error");
assert.equal(error?.failure.message, "Provider turn failed.");
}),
);

Expand Down Expand Up @@ -3003,19 +3005,11 @@ function captureRootRunTermination(input: {
driver,
status: "running",
});
const writtenItems = yield* Ref.make<
ReadonlyArray<{ readonly type: string; readonly parentItemId: string | null }>
>([]);
const writtenItems = yield* Ref.make<ReadonlyArray<OrchestrationV2TurnItem>>([]);
const observed = yield* Ref.make<ReadonlyArray<string>>([]);
const ingestionDone = yield* Deferred.make<void>();
const captureTurnItem = (payload: {
readonly type: string;
readonly parentItemId: string | null;
}) =>
Ref.update(writtenItems, (current) => [
...current,
{ type: payload.type, parentItemId: payload.parentItemId },
]);
const captureTurnItem = (payload: OrchestrationV2TurnItem) =>
Ref.update(writtenItems, (current) => [...current, payload]);
const testLayer = runExecutionServiceLayer.pipe(
Layer.provide(
Layer.mergeAll(
Expand Down
14 changes: 9 additions & 5 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ import {
deriveLatestThreadRun,
deriveThreadRuntime,
} from "@t3tools/client-runtime/state/thread-execution";
import { resolveThreadProviderSession } from "@t3tools/client-runtime/state/thread-workflows";
import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows";
import {
codexFeedbackMessage,
parseCodexFeedbackCommand,
Expand Down Expand Up @@ -1997,12 +1997,10 @@ export default function ChatView(props: ChatViewProps) {
() => (serverProjection === null ? null : deriveThreadRuntime(serverProjection)),
[serverProjection],
);
const activeProviderSession = useMemo(
() => (serverProjection === null ? null : resolveThreadProviderSession(serverProjection)),
const supportsProviderSwitchingViaHandoff = useMemo(
() => threadSupportsProviderHandoff(serverProjection),
[serverProjection],
);
const supportsProviderSwitchingViaHandoff =
activeProviderSession?.capabilities.sessions.supportsProviderSwitchingViaHandoff === true;
const activeLatestRun = isServerThread ? serverLatestRun : (activeThread?.latestRun ?? null);
const activeActivityRun = isServerThread ? serverActivityRun : (activeThread?.latestRun ?? null);
const activeRuntime = isServerThread ? serverRuntime : (activeThread?.runtime ?? null);
Expand Down Expand Up @@ -10247,6 +10245,12 @@ export default function ChatView(props: ChatViewProps) {
isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree}
listRef={legendListRef}
timelineEntries={displayedTimeline.entries}
providerStatuses={
environmentById.get(
displayedThreadRef?.environmentId ?? activeThread.environmentId,
)?.serverConfig?.providers ?? EMPTY_PROVIDERS
}
runs={paintOnlyDisplayedTimeline ? [] : (serverProjection?.runs ?? [])}
latestRun={paintOnlyDisplayedTimeline ? null : activeActivityRun}
runningRunId={paintOnlyDisplayedTimeline ? null : activeRunningTurnId}
turnDiffSummaries={
Expand Down
8 changes: 1 addition & 7 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -322,12 +322,6 @@ function terminalProcessLabel(count: number): string {
return `${count} terminal ${count === 1 ? "process" : "processes"} running`;
}

// Trailing provider glyphs for a row. A thread that has been handed off
// between providers draws its earlier owners behind the current one, so the
// list shows where the thread has been without widening the row. Rendered
// back to front so DOM order matches visual layering. No separator ring: row
// surfaces vary (active, selected, draft, hover), so earlier glyphs are
// shrunk and dimmed instead, which reads as depth on any background.
function SidebarProviderStack(props: {
thread: SidebarThreadSummary;
providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
Expand Down Expand Up @@ -357,7 +351,7 @@ function SidebarProviderStack(props: {
return <span className="inline-flex shrink-0 items-center">{current}</span>;
}
return (
<span className="inline-flex shrink-0 items-center -space-x-1">
<span className="inline-flex shrink-0 items-center gap-1.5">
{stack.slice(0, -1).map((instanceId) => {
const entry = props.providerEntryByInstanceId.get(instanceId);
if (entry === undefined) return null;
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ function buildProps() {
listRef: createRef<LegendListRef | null>(),
latestRun: null,
turnDiffSummaries: [],
providerStatuses: [],
runs: [],
routeThreadKey: "environment-local:thread-1",
onOpenTurnDiff: () => {},
onOpenThread: () => {},
Expand Down
9 changes: 4 additions & 5 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,6 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END = {
layout: true,
},
} as const satisfies MaintainScrollAtEndOptions;
const EMPTY_TIMELINE_PROVIDERS: ReadonlyArray<ServerProvider> = [];
const EMPTY_TIMELINE_RUNS: ReadonlyArray<HandoffTimelineRun> = [];
// Streamed text lands a paragraph at a time. A smooth scroll to the end
// turns each landing into a short glide instead of a jump. Thread switches
Expand Down Expand Up @@ -429,8 +428,8 @@ interface MessagesTimelineProps {
timestampFormat: TimestampFormat;
workspaceRoot: string | undefined;
skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
providerStatuses?: ReadonlyArray<ServerProvider>;
runs?: ReadonlyArray<HandoffTimelineRun>;
providerStatuses: ReadonlyArray<ServerProvider>;
runs: ReadonlyArray<HandoffTimelineRun>;
anchorMessageId: MessageId | null;
onAnchorReady: (messageId: MessageId, anchorIndex: number) => void;
onAnchorSizeChanged: (messageId: MessageId, size: number) => void;
Expand Down Expand Up @@ -500,8 +499,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
timestampFormat,
workspaceRoot,
skills = EMPTY_TIMELINE_SKILLS,
providerStatuses = EMPTY_TIMELINE_PROVIDERS,
runs: runsProp = EMPTY_TIMELINE_RUNS,
providerStatuses,
runs: runsProp,
anchorMessageId,
onAnchorReady,
onAnchorSizeChanged,
Expand Down
11 changes: 9 additions & 2 deletions apps/web/src/components/chat/TimelineSystemDivider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ export function TimelineSystemDivider(props: {
{Icon ? <Icon className="size-3 shrink-0" /> : null}
<span className="font-medium">{props.label}</span>
{props.detail ? (
<span className="inline-flex min-w-0 max-w-80 items-center truncate opacity-70">
<span
className={cn(
"inline-flex min-w-0 max-w-80 items-center opacity-70",
typeof props.detail === "string" && "truncate",
)}
>
{props.showDetailSeparator === false ? null : "·\u00a0"}
{props.detail}
</span>
Expand Down Expand Up @@ -52,7 +57,9 @@ export function TimelineSystemDivider(props: {
<TooltipPopup side="top">{props.actionLabel}</TooltipPopup>
</Tooltip>
) : (
<span className="flex min-w-0 items-center gap-1.5 rounded-full px-2 py-1">{content}</span>
<span className="flex min-w-0 flex-wrap items-center justify-center gap-1.5 rounded-full px-2 py-1">
{content}
</span>
)}
<span aria-hidden="true" className="h-px flex-1 bg-border/70" />
</div>
Expand Down
12 changes: 6 additions & 6 deletions apps/web/src/components/chat/V2LifecycleRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export function V2LifecycleRow(props: {
showDetailSeparator={false}
tone={item.status === "failed" ? "danger" : "neutral"}
detail={
<span className="inline-flex min-w-0 items-center gap-1.5">
<span className="inline-flex min-w-0 flex-wrap items-center justify-center gap-1.5">
{fromEndpoints.map((endpoint, index) => (
<Fragment key={`${endpoint.instanceId}:${endpoint.model ?? ""}`}>
{index > 0 ? (
Expand Down Expand Up @@ -289,7 +289,6 @@ function SubagentTimelineLink(props: {
);
}

/** Provider icon with the resolved handoff model available on hover or focus. */
function HandoffEndpoint(props: {
readonly providers: ReadonlyArray<ServerProvider>;
readonly instanceId: ProviderInstanceId;
Expand All @@ -313,9 +312,7 @@ function HandoffEndpoint(props: {
render={
<span
tabIndex={0}
role="img"
aria-label={label}
className="inline-flex shrink-0 items-center justify-center rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="inline-flex min-w-0 items-center gap-1 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ProviderInstanceIcon
driverKind={entry?.driverKind ?? ProviderDriverKind.make(props.instanceId)}
Expand All @@ -324,10 +321,13 @@ function HandoffEndpoint(props: {
acpRegistryIconUrl={entry?.acpRegistryIconUrl}
iconClassName="size-3"
/>
<span className="truncate">{label}</span>
</span>
}
/>
<TooltipPopup>{label}</TooltipPopup>
<TooltipPopup>
{entry?.displayName ?? props.instanceId} · {label}
</TooltipPopup>
</Tooltip>
);
}
14 changes: 13 additions & 1 deletion packages/client-runtime/src/state/threadWorkflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function resolveLatestMergeBackRun(projection: Projection): Run | null {
return hasNewerActiveRun ? null : latestProviderFinishedRun;
}

export function resolveThreadProviderSession(projection: Projection): ProviderSession | null {
function resolveThreadProviderSession(projection: Projection): ProviderSession | null {
const activeRun = resolveActiveThreadRun(projection);
const providerThreadId = activeRun?.providerThreadId ?? projection.thread.activeProviderThreadId;
const activeProviderThread =
Expand All @@ -86,6 +86,18 @@ export function resolveThreadProviderSession(projection: Projection): ProviderSe
);
}

export function threadSupportsProviderHandoff(projection: Projection | null | undefined): boolean {
if (projection == null) return false;
const session = resolveThreadProviderSession(projection);
if (session !== null) {
return session.capabilities.sessions.supportsProviderSwitchingViaHandoff;
}
return (
resolveActiveThreadRun(projection) === null &&
(projection.thread.historyOrigin === "v1_import" || projection.runs.length === 0)
);
}

/** Automatic completion/notification runs are not messages in the user's queue. */
export function getUserQueuedThreadRuns(
projection: Pick<Projection, "runs" | "messages">,
Expand Down
Loading