Skip to content
Closed
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
6 changes: 5 additions & 1 deletion apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1470,7 +1470,11 @@ function renderFeedEntry(
<View className="h-px flex-1 bg-subtle" />
<View className="shrink-0 flex-row items-center gap-1.5">
<SymbolView
name="arrow.down.right.and.arrow.up.left"
name={
entry.activities[0]!.workEntry.sourceActivityKind === "provider.handoff"
? "arrow.right.circle"
: "arrow.down.right.and.arrow.up.left"
}
size={12}
tintColor={iconSubtleColor}
type="monochrome"
Expand Down
9 changes: 7 additions & 2 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,12 +270,17 @@ const activityRunsCache = new WeakMap<
}
>();

/** Activities that render as a labeled rule across the feed. */
function isDividerActivityKind(kind: string | undefined): boolean {
return kind === "context-compaction" || kind === "provider.handoff";
}

export function isContextCompactionActivityGroup(
entry: Extract<ThreadFeedEntry, { readonly type: "activity-group" }>,
): boolean {
return (
entry.activities.length === 1 &&
entry.activities[0]?.workEntry.sourceActivityKind === "context-compaction"
isDividerActivityKind(entry.activities[0]?.workEntry.sourceActivityKind)
);
}

Expand Down Expand Up @@ -1583,7 +1588,7 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th
}

const isStandalone =
entry.activity.workEntry.sourceActivityKind === "context-compaction" ||
isDividerActivityKind(entry.activity.workEntry.sourceActivityKind) ||
entry.activity.workEntry.questionAnswer !== undefined;
if (isStandalone || firstActivityEntry?.turnId !== entry.turnId) {
flushGroup();
Expand Down
188 changes: 124 additions & 64 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,10 +350,15 @@ describe("ProviderCommandReactor", () => {
const providerSnapshots = [
{
instanceId: modelSelection.instanceId,
models: [{ slug: modelSelection.model, name: "GPT-5 Codex" }],
...(input?.requiresNewThreadForModelChange === true
? { requiresNewThreadForModelChange: true }
: {}),
},
{
instanceId: ProviderInstanceId.make("claudeAgent"),
models: [{ slug: "claude-opus-4-6", name: "Claude Opus 4.6" }],
},
];

const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
Expand Down Expand Up @@ -3538,7 +3543,7 @@ describe("ProviderCommandReactor", () => {
expect(thread?.session?.runtimeMode).toBe("full-access");
});

it("rejects provider changes after a thread is already bound to a session provider", async () => {
it("hands a started thread off to another driver with a fresh session and transcript", async () => {
const harness = await createHarness();
const now = "2026-01-01T00:00:00.000Z";

Expand Down Expand Up @@ -3583,75 +3588,140 @@ describe("ProviderCommandReactor", () => {
}),
);

await waitFor(async () => {
const readModel = await harness.readModel();
const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
return (
thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ??
false
);
await waitFor(() => harness.sendTurn.mock.calls.length === 2);

expect(harness.stopSession.mock.calls.length).toBe(1);
expect(harness.startSession.mock.calls.length).toBe(2);
const handoffStart = harness.startSession.mock.calls[1]?.[1];
expect(handoffStart).toMatchObject({
provider: "claudeAgent",
providerInstanceId: "claudeAgent",
});
expect(handoffStart).not.toHaveProperty("resumeCursor");

expect(harness.startSession.mock.calls.length).toBe(1);
expect(harness.sendTurn.mock.calls.length).toBe(1);
expect(harness.stopSession.mock.calls.length).toBe(0);
const handoffTurn = harness.sendTurn.mock.calls[1]?.[0] as { input?: string } | undefined;
expect(handoffTurn?.input).toMatch(/^<t3_provider_handoff>/);
expect(handoffTurn?.input).toContain("### User\nfirst");
expect(handoffTurn?.input).not.toContain("### User\nsecond");
expect(handoffTurn?.input?.endsWith("\n\nsecond")).toBe(true);

const readModel = await harness.readModel();
const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
expect(thread?.session?.threadId).toBe("thread-1");
expect(thread?.session?.providerName).toBe("codex");
expect(thread?.session?.runtimeMode).toBe("approval-required");
expect(thread?.session?.providerName).toBe("claudeAgent");
expect(
thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"),
thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed"),
).toBe(false);
expect(
thread?.activities.find((activity) => activity.kind === "provider.handoff"),
).toMatchObject({
payload: {
detail: expect.stringContaining("cannot switch to 'claudeAgent'"),
},
summary: "Switched from GPT-5 Codex to Claude Opus 4.6, context handed off",
payload: { fromInstanceId: "codex", toInstanceId: "claudeAgent", includedMessages: 1 },
});
});

it("rejects cross-driver provider changes after the existing thread session has stopped", async () => {
it("hands off to another driver after the existing thread session has stopped", async () => {
const harness = await createHarness();
const now = "2026-01-01T00:00:00.000Z";

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.session.set",
commandId: CommandId.make("cmd-session-set-stopped-provider-switch"),
threadId: ThreadId.make("thread-1"),
session: {
harness.engine
.dispatch({
type: "thread.session.set",
commandId: CommandId.make("cmd-session-set-stopped-provider-switch"),
threadId: ThreadId.make("thread-1"),
status: "stopped",
providerName: "codex",
providerInstanceId: ProviderInstanceId.make("codex"),
runtimeMode: "approval-required",
activeTurnId: null,
lastError: null,
updatedAt: now,
},
createdAt: now,
}),
session: {
threadId: ThreadId.make("thread-1"),
status: "stopped",
providerName: "codex",
providerInstanceId: ProviderInstanceId.make("codex"),
runtimeMode: "approval-required",
activeTurnId: null,
lastError: null,
updatedAt: now,
},
createdAt: now,
})
.pipe(
Effect.andThen(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-turn-start-stopped-provider-switch"),
threadId: ThreadId.make("thread-1"),
message: {
messageId: asMessageId("user-message-stopped-provider-switch"),
role: "user",
text: "continue with claude",
attachments: [],
},
modelSelection: {
instanceId: ProviderInstanceId.make("claudeAgent"),
model: "claude-opus-4-6",
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
),
),
);

await waitFor(() => harness.sendTurn.mock.calls.length === 1);

expect(harness.stopSession.mock.calls.length).toBe(0);
expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
providerInstanceId: "claudeAgent",
});
expect(harness.startSession.mock.calls[0]?.[1]).not.toHaveProperty("resumeCursor");
const turn = harness.sendTurn.mock.calls[0]?.[0] as { input?: string } | undefined;
expect(turn?.input).toMatch(/^<t3_provider_handoff>/);
expect(turn?.input?.endsWith("\n\ncontinue with claude")).toBe(true);
});

it("keeps rejecting incompatible provider changes for manual compaction", async () => {
const harness = await createHarness();
const now = "2026-01-01T00:00:00.000Z";

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-turn-start-stopped-provider-switch"),
threadId: ThreadId.make("thread-1"),
message: {
messageId: asMessageId("user-message-stopped-provider-switch"),
role: "user",
text: "continue with claude",
attachments: [],
},
modelSelection: {
instanceId: ProviderInstanceId.make("claudeAgent"),
model: "claude-opus-4-6",
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
harness.engine
.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-turn-start-compact-switch-1"),
threadId: ThreadId.make("thread-1"),
message: {
messageId: asMessageId("user-message-compact-switch-1"),
role: "user",
text: "first",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
})
.pipe(
Effect.andThen(
Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)),
),
Effect.andThen(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-turn-start-compact-switch-2"),
threadId: ThreadId.make("thread-1"),
message: {
messageId: asMessageId("user-message-compact-switch-2"),
role: "user",
text: "/compact",
attachments: [],
},
modelSelection: {
instanceId: ProviderInstanceId.make("claudeAgent"),
model: "claude-opus-4-6",
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
),
),
);

await waitFor(async () => {
Expand All @@ -3662,18 +3732,8 @@ describe("ProviderCommandReactor", () => {
false
);
});

expect(harness.startSession.mock.calls.length).toBe(0);
expect(harness.sendTurn.mock.calls.length).toBe(0);
const readModel = await harness.readModel();
const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
expect(
thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"),
).toMatchObject({
payload: {
detail: expect.stringContaining("cannot switch to 'claudeAgent'"),
},
});
expect(harness.startSession.mock.calls.length).toBe(1);
expect(harness.compactThread.mock.calls.length).toBe(0);
});

it("reacts to thread.turn.interrupt-requested by calling provider interrupt", async () => {
Expand Down
Loading
Loading