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
26 changes: 23 additions & 3 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ export interface ThreadDetailScreenProps {
readonly onNativePasteText: (paste: ComposerTextPaste) => Promise<void>;
readonly onRemoveDraftImage: (imageId: string) => void;
readonly onStopThread: () => void;
readonly onResumeGoal?: () => Promise<void>;
/** Present while the thread's queue is held. */
readonly onResumeQueue: (() => Promise<void>) | null;
readonly offersSideChat: boolean;
Expand Down Expand Up @@ -840,8 +841,27 @@ function ThreadDetailBody(
if (props.isCompacting && contentPresentationKind === "ready") {
return { kind: "compacting" };
}
const goal =
props.selectedThread.goal === null
? null
: presentProviderGoal(props.selectedThread.goal, props.activeWorkStartedAt !== null);
const onResumeGoal = props.onResumeGoal;
const goalAction =
goal?.canResume && onResumeGoal !== undefined
? {
label: props.activeWorkStartedAt !== null ? "Resume after turn" : "Resume",
accessibilityLabel:
props.activeWorkStartedAt !== null ? "Resume goal after turn" : "Resume goal",
disabled: !props.canOperateThread,
onPress: () => void onResumeGoal(),
}
: null;
if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") {
return { kind: "working", startedAt: props.activeWorkStartedAt };
return {
kind: "working",
startedAt: props.activeWorkStartedAt,
action: goalAction,
};
}
if (props.selectedThread.groupDiscussionOpen && contentPresentationKind === "ready") {
return { kind: "preparing", label: "Group discussion" };
Expand Down Expand Up @@ -897,12 +917,12 @@ function ThreadDetailBody(
},
};
}
if (props.selectedThread.goal !== null && contentPresentationKind === "ready") {
const goal = presentProviderGoal(props.selectedThread.goal, false);
if (goal !== null && contentPresentationKind === "ready") {
return {
kind: "goal",
label: goal.title,
accessibilityLabel: `${goal.title}: ${goal.objective}`,
action: goalAction,
};
}
if (extensionStatus !== null && contentPresentationKind === "ready") {
Expand Down
23 changes: 22 additions & 1 deletion apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ import {
threadRouteOpensDraft,
} from "./thread-route-hydration";
import { ThreadHeader } from "./ThreadHeader";
import { resumeThread } from "./resume-thread";
import { continueThread, resumeThread } from "./resume-thread";
import {
useAndroidWorkspaceBack,
useWorkspaceBackNavigation,
Expand Down Expand Up @@ -707,6 +707,26 @@ function ThreadRouteContent(
}
};

const handleResumeGoal = async () => {
if (!selectedThread) return;
const result = await continueThread({
thread: selectedThread,
continuation: { kind: "goal" },
runtimeMode: composer.runtimeMode ?? selectedThread.runtimeMode,
interactionMode: composer.interactionMode ?? selectedThread.interactionMode,
modelSelection: composer.modelSelection,
messageId: MessageId.make(uuidv4()),
createdAt: new Date().toISOString(),
setRuntimeMode: setThreadRuntimeMode,
setInteractionMode: setThreadInteractionMode,
startTurn: continueThreadRun,
});
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
Dialog.alert("Could not resume goal", error instanceof Error ? error.message : "Try again.");
}
};

const handleOpenTerminal = (nextTerminalId?: string | null) => {
if (
!selectedThread ||
Expand Down Expand Up @@ -1090,6 +1110,7 @@ function ThreadRouteContent(
onRemoveDraftImage={composer.onRemoveDraftImage}
serverConfig={serverConfig}
onStopThread={awaitingBootstrapTurn ? handleCancelWorktreeSetup : handleStopThread}
onResumeGoal={handleResumeGoal}
onResumeQueue={
composer.queueHeld || composer.resumableRunId !== null ? handleResumeQueue : null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,11 @@ export function FloatingWorkingControl(props: {
const extensionOnly = props.status?.kind === "extension";
const useGlass = NATIVE_LIQUID_GLASS_SUPPORTED && !extensionOnly;
const action =
props.status?.kind === "working" ||
props.status?.kind === "background" ||
props.status?.kind === "lifecycle" ||
props.status?.kind === "stop"
props.status?.kind === "stop" ||
props.status?.kind === "goal"
? props.status.action
: null;
const [agentsWidth, setAgentsWidth] = useState(0);
Expand Down
13 changes: 11 additions & 2 deletions apps/mobile/src/features/threads/floating-working-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ export interface FloatingStatusAction {
}

export type FloatingWorkingStatus =
| { readonly kind: "working"; readonly startedAt: string }
| {
readonly kind: "working";
readonly startedAt: string;
readonly action: FloatingStatusAction | null;
}
| { readonly kind: "syncing"; readonly label: string }
| { readonly kind: "compacting" }
| {
Expand Down Expand Up @@ -37,7 +41,12 @@ export type FloatingWorkingStatus =
readonly unresponsive: boolean;
readonly action: FloatingStatusAction | null;
}
| { readonly kind: "goal"; readonly label: string; readonly accessibilityLabel: string }
| {
readonly kind: "goal";
readonly label: string;
readonly accessibilityLabel: string;
readonly action: FloatingStatusAction | null;
}
| { readonly kind: "extension"; readonly label: string; readonly onPress: () => void }
| { readonly kind: "preparing"; readonly label: string }
| {
Expand Down
29 changes: 24 additions & 5 deletions apps/mobile/src/features/threads/resume-thread.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/reactivity";
import { describe, expect, it } from "vite-plus/test";

import { resumeThread } from "./resume-thread";
import { continueThread, resumeThread } from "./resume-thread";

const runId = RunId.make("run-limited");
const failure = AsyncResult.failure(Cause.fail(new Error("not connected")));
Expand Down Expand Up @@ -64,21 +64,24 @@ describe("resumeThread", () => {
expect(calls.map((call) => call.command)).toEqual(["startTurn"]);
});

it("saves changed modes to the thread before continuing", async () => {
it.each(["run", "goal"] as const)("saves changed modes before continuing a %s", async (kind) => {
const { calls, base } = setup();
await resumeThread({
await continueThread({
...base,
runtimeMode: "approval-required",
interactionMode: "plan",
resumableRunId: runId,
queueHeld: false,
continuation: kind === "run" ? { kind, runId } : { kind },
});

expect(calls.map((call) => call.command)).toEqual([
"setRuntimeMode",
"setInteractionMode",
"startTurn",
]);
expect(calls[2]?.input).toMatchObject({
dispatchMode: kind === "goal" ? "queue" : "start",
message: { text: kind === "goal" ? "/goal resume" : "Continue where you left off." },
});
});

it("resumes a held queue directly when no run needs continuing", async () => {
Expand All @@ -87,4 +90,20 @@ describe("resumeThread", () => {

expect(calls.map((call) => call.command)).toEqual(["resumeQueue"]);
});

it.each(["run", "goal"] as const)(
"does not continue a %s when saving modes fails",
async (kind) => {
const { calls, base } = setup();
const result = await continueThread({
...base,
continuation: kind === "run" ? { kind, runId } : { kind },
runtimeMode: "approval-required",
setRuntimeMode: async () => failure,
});

expect(result).toBe(failure);
expect(calls).toEqual([]);
},
);
});
99 changes: 60 additions & 39 deletions apps/mobile/src/features/threads/resume-thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,13 @@ type Command<Input, E> = (request: {
readonly input: Input;
}) => Promise<AtomCommandResult<unknown, E>>;

/** Resolves to the first failed command's result, or the last successful one. */
export async function resumeThread<E>(input: {
interface ThreadContinuationInput<E> {
readonly thread: {
readonly environmentId: EnvironmentId;
readonly id: ThreadId;
readonly runtimeMode: RuntimeMode;
readonly interactionMode: ProviderInteractionMode;
};
readonly resumableRunId: RunId | null;
readonly queueHeld: boolean;
readonly runtimeMode: RuntimeMode;
readonly interactionMode: ProviderInteractionMode;
readonly modelSelection: ModelSelection | null;
Expand All @@ -38,46 +35,70 @@ export async function resumeThread<E>(input: {
readonly setRuntimeMode: Command<SetThreadRuntimeModeInput, E>;
readonly setInteractionMode: Command<SetThreadInteractionModeInput, E>;
readonly startTurn: Command<StartThreadTurnInput, E>;
readonly resumeQueue: Command<ResumeThreadQueueInput, E>;
}): Promise<AtomCommandResult<unknown, E>> {
}

export async function continueThread<E>(
input: ThreadContinuationInput<E> & {
readonly continuation:
| { readonly kind: "goal" }
| { readonly kind: "run"; readonly runId: RunId };
},
): Promise<AtomCommandResult<unknown, E>> {
const { thread, runtimeMode, interactionMode, createdAt } = input;
const environmentId = thread.environmentId;
// The server holds the queue behind a stopped or usage-limited run until that run is continued.
if (input.resumableRunId !== null) {
// A turn on an existing thread runs with the thread's saved modes, not the turn's.
if (runtimeMode !== thread.runtimeMode) {
const saved = await input.setRuntimeMode({
environmentId,
input: { threadId: thread.id, runtimeMode, createdAt },
});
if (saved._tag === "Failure") return saved;
}
if (interactionMode !== thread.interactionMode) {
const saved = await input.setInteractionMode({
environmentId,
input: { threadId: thread.id, interactionMode, createdAt },
});
if (saved._tag === "Failure") return saved;
}
const continued = await input.startTurn({
if (runtimeMode !== thread.runtimeMode) {
const saved = await input.setRuntimeMode({
environmentId,
input: { threadId: thread.id, runtimeMode, createdAt },
});
if (saved._tag === "Failure") return saved;
}
if (interactionMode !== thread.interactionMode) {
const saved = await input.setInteractionMode({
environmentId,
input: {
creationSource: "mobile",
threadId: thread.id,
manualContinuationOfRunId: input.resumableRunId,
...(input.modelSelection ? { modelSelection: input.modelSelection } : {}),
message: {
messageId: input.messageId,
role: "user",
text: "Continue where you left off.",
attachments: [],
},
runtimeMode,
interactionMode,
dispatchMode: "start",
input: { threadId: thread.id, interactionMode, createdAt },
});
if (saved._tag === "Failure") return saved;
}
return input.startTurn({
environmentId,
input: {
creationSource: "mobile",
threadId: thread.id,
...(input.continuation.kind === "run"
? { manualContinuationOfRunId: input.continuation.runId }
: {}),
...(input.modelSelection ? { modelSelection: input.modelSelection } : {}),
message: {
messageId: input.messageId,
role: "user",
text: input.continuation.kind === "goal" ? "/goal resume" : "Continue where you left off.",
attachments: [],
},
runtimeMode,
interactionMode,
dispatchMode: input.continuation.kind === "goal" ? "queue" : "start",
createdAt,
},
});
}

export async function resumeThread<E>(
input: ThreadContinuationInput<E> & {
readonly resumableRunId: RunId | null;
readonly queueHeld: boolean;
readonly resumeQueue: Command<ResumeThreadQueueInput, E>;
},
): Promise<AtomCommandResult<unknown, E>> {
if (input.resumableRunId !== null) {
const continued = await continueThread({
...input,
continuation: { kind: "run", runId: input.resumableRunId },
});
if (continued._tag === "Failure" || !input.queueHeld) return continued;
}
return input.resumeQueue({ environmentId, input: { threadId: thread.id } });
return input.resumeQueue({
environmentId: input.thread.environmentId,
input: { threadId: input.thread.id },
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -2203,38 +2203,46 @@ it.effect("queues a goal clear without stopping work when no goal drives it", ()
),
);

it.effect("promotes a queued goal by stopping the running turn", () =>
Effect.scoped(
Effect.gen(function* () {
const { started, steered, layer, startFirstTurn, send, read } =
yield* goalHarness("goal-promote");
yield* Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
const threadId = yield* startFirstTurn;
yield* send("queued", "queued", "queue");
yield* send("goal", "/goal ship it", "queue");
const goalRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find(
(run) => run.userMessageId === MessageId.make("message:goal"),
)!;
yield* orchestrator.dispatch({
type: "queued-message.promote-to-steer",
commandId: CommandId.make("promote"),
threadId,
queuedRunId: goalRun.id,
targetRunId: started[0]!.runId,
});
yield* worker.drain();
it.effect.each(["/goal ship it", "/goal resume"])(
"queues %s until promoted by stopping the running turn",
(text) =>
Effect.scoped(
Effect.gen(function* () {
const { started, steered, layer, startFirstTurn, send, read } =
yield* goalHarness("goal-promote");
yield* Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
const threadId = yield* startFirstTurn;
yield* send("queued", "queued", "queue");
yield* send("goal", text, "queue");
yield* worker.drain();
assert.deepEqual(yield* read(threadId), {
queue: ["queued", text],
started: ["first"],
stopRequested: false,
});
const goalRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find(
(run) => run.userMessageId === MessageId.make("message:goal"),
)!;
yield* orchestrator.dispatch({
type: "queued-message.promote-to-steer",
commandId: CommandId.make("promote"),
threadId,
queuedRunId: goalRun.id,
targetRunId: started[0]!.runId,
});
yield* worker.drain();

assert.deepEqual(steered, []);
assert.deepEqual(yield* read(threadId), {
queue: ["queued"],
started: ["first", "/goal ship it"],
stopRequested: true,
});
}).pipe(Effect.provide(layer));
}),
),
assert.deepEqual(steered, []);
assert.deepEqual(yield* read(threadId), {
queue: ["queued"],
started: ["first", text],
stopRequested: true,
});
}).pipe(Effect.provide(layer));
}),
),
);

// A fresh message skips compaction as its own turn, which steering a running turn would not.
Expand Down
Loading
Loading