Skip to content

Commit 2a07cc0

Browse files
committed
feat(cloud): expose steering controls for cloud sessions
1 parent a03ac79 commit 2a07cc0

4 files changed

Lines changed: 96 additions & 41 deletions

File tree

packages/core/src/sessions/sessionService.ts

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2901,7 +2901,11 @@ export class SessionService {
29012901
isNotification(msg.method, POSTHOG_NOTIFICATIONS.RUN_STARTED)
29022902
) {
29032903
const session = this.d.store.getSessions()[taskRunId];
2904-
const params = (msg as { params?: { agentVersion?: unknown } }).params;
2904+
const params = (
2905+
msg as {
2906+
params?: { agentVersion?: unknown; steering?: unknown };
2907+
}
2908+
).params;
29052909
const agentVersion =
29062910
typeof params?.agentVersion === "string"
29072911
? params.agentVersion
@@ -2910,6 +2914,12 @@ export class SessionService {
29102914
if (agentVersion && session?.agentVersion !== agentVersion) {
29112915
updates.agentVersion = agentVersion;
29122916
}
2917+
if (
2918+
typeof params?.steering === "string" &&
2919+
session?.steering !== params.steering
2920+
) {
2921+
updates.steering = params.steering;
2922+
}
29132923
if (session?.isCloud && session.status !== "connected") {
29142924
updates.status = "connected";
29152925
}
@@ -3384,22 +3394,27 @@ export class SessionService {
33843394
// Steer: the user sent a message mid-turn and asked to fold it into the
33853395
// running turn rather than queue it. Adapters that negotiated
33863396
// `steering: "native"` (Claude, codex) inject at the next tool boundary;
3387-
// unknown adapters cancel and resend. Cloud has no real mid-turn steer
3388-
// (the backend only delivers messages between turns), so it falls through
3389-
// to the queue; compaction too.
3390-
if (
3391-
options?.steer &&
3392-
!session.isCloud &&
3393-
session.isPromptPending &&
3394-
!session.isCompacting
3395-
) {
3397+
// unknown local adapters cancel and resend. Cloud sessions only enter this
3398+
// path after the sandbox advertises native steering; compaction still queues.
3399+
if (options?.steer && session.isPromptPending && !session.isCompacting) {
33963400
if (sessionSupportsNativeSteer(session)) {
3397-
return this.sendSteerPrompt(session, prompt);
3401+
if (session.isCloud) {
3402+
if (session.status === "connected") {
3403+
return this.sendCloudPrompt(session, prompt, {
3404+
skipQueueGuard: true,
3405+
steer: true,
3406+
});
3407+
}
3408+
} else {
3409+
return this.sendSteerPrompt(session, prompt);
3410+
}
33983411
}
3399-
await this.cancelPrompt(taskId);
3400-
const refreshed = this.d.store.getSessionByTaskId(taskId);
3401-
if (refreshed) {
3402-
session = refreshed;
3412+
if (!session.isCloud) {
3413+
await this.cancelPrompt(taskId);
3414+
const refreshed = this.d.store.getSessionByTaskId(taskId);
3415+
if (refreshed) {
3416+
session = refreshed;
3417+
}
34033418
}
34043419
}
34053420

@@ -3931,7 +3946,7 @@ export class SessionService {
39313946
private async sendCloudPrompt(
39323947
session: AgentSession,
39333948
prompt: string | ContentBlock[],
3934-
options?: { skipQueueGuard?: boolean },
3949+
options?: { skipQueueGuard?: boolean; steer?: boolean },
39353950
): Promise<{ stopReason: string }> {
39363951
const normalizedPrompt = await this.resolveCloudPrompt(prompt);
39373952
const transport = this.d.h.getCloudPromptTransport(normalizedPrompt);
@@ -4063,19 +4078,24 @@ export class SessionService {
40634078
if (artifactIds.length > 0) {
40644079
params.artifact_ids = artifactIds;
40654080
}
4081+
if (options?.steer) {
4082+
params.steer = true;
4083+
}
40664084

40674085
const currentSessionBeforeSend =
40684086
this.getSessionByRunId(session.taskRunId) ?? session;
40694087
const idleEvidenceBeforeSend = this.cloudRunIdleTracker.capture(
40704088
currentSessionBeforeSend,
40714089
);
4072-
this.d.store.updateSession(session.taskRunId, {
4073-
isPromptPending: true,
4074-
promptStartedAt: Date.now(),
4075-
pausedDurationMs: 0,
4076-
agentIdleForRunId: undefined,
4077-
});
4078-
this.cloudRunIdleTracker.markBusy(currentSessionBeforeSend);
4090+
if (!options?.steer) {
4091+
this.d.store.updateSession(session.taskRunId, {
4092+
isPromptPending: true,
4093+
promptStartedAt: Date.now(),
4094+
pausedDurationMs: 0,
4095+
agentIdleForRunId: undefined,
4096+
});
4097+
this.cloudRunIdleTracker.markBusy(currentSessionBeforeSend);
4098+
}
40794099
this.d.store.appendOptimisticItem(session.taskRunId, {
40804100
type: "user_message",
40814101
content: transport.promptText,
@@ -4088,6 +4108,7 @@ export class SessionService {
40884108
is_initial: session.events.length === 0,
40894109
execution_type: "cloud",
40904110
prompt_length_chars: transport.promptText.length,
4111+
...(options?.steer ? { is_steer: true } : {}),
40914112
});
40924113

40934114
try {
@@ -4105,23 +4126,25 @@ export class SessionService {
41054126
}
41064127

41074128
const commandResult = result.result as
4108-
| { queued?: boolean; stopReason?: string }
4129+
| { queued?: boolean; steered?: boolean; stopReason?: string }
41094130
| undefined;
41104131
const stopReason = commandResult?.queued
41114132
? "queued"
41124133
: (commandResult?.stopReason ?? "end_turn");
41134134

41144135
return { stopReason };
41154136
} catch (error) {
4116-
this.d.store.updateSession(session.taskRunId, {
4117-
isPromptPending: false,
4118-
promptStartedAt: null,
4119-
});
4137+
if (!options?.steer) {
4138+
this.d.store.updateSession(session.taskRunId, {
4139+
isPromptPending: false,
4140+
promptStartedAt: null,
4141+
});
4142+
}
41204143
this.d.store.clearTailOptimisticItems(session.taskRunId);
41214144
const currentSessionAfterFailure = this.getSessionByRunId(
41224145
session.taskRunId,
41234146
);
4124-
if (currentSessionAfterFailure) {
4147+
if (currentSessionAfterFailure && !options?.steer) {
41254148
const restoreResult = this.cloudRunIdleTracker.restoreAfterFailedSend(
41264149
idleEvidenceBeforeSend,
41274150
currentSessionAfterFailure,

packages/ui/src/features/sessions/components/SessionView.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -704,13 +704,11 @@ export function SessionView({
704704
) : null
705705
}
706706
messagingModeToggle={
707-
taskId && !isCloudRun ? (
707+
taskId ? (
708708
<SteerQueueToggle taskId={taskId} />
709709
) : undefined
710710
}
711-
onToggleMessagingMode={
712-
isCloudRun ? undefined : toggleMessagingMode
713-
}
711+
onToggleMessagingMode={toggleMessagingMode}
714712
onPromptRecall={handlePromptRecall}
715713
onBeforeSubmit={handleBeforeSubmit}
716714
onSubmit={handleSubmit}

packages/ui/src/features/sessions/hooks/useMessagingMode.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ export function useMessagingMode(taskId: string | undefined): MessagingMode {
1919
* Whether the task's session steers natively (folds a mid-turn message into the
2020
* running turn) versus falling back to interrupt-and-resend. Driven by the
2121
* adapter's negotiated `steering` capability — same decision as the host's
22-
* sendPrompt gate — so Claude and codex steer, while cloud
23-
* resend. Drives the steer label/tooltip, not whether steer is allowed.
22+
* sendPrompt gate, including capability-advertising cloud sandboxes. Drives
23+
* the steer label/tooltip, not whether steer is allowed.
2424
*/
2525
export function useSupportsNativeSteer(taskId: string | undefined): boolean {
2626
return useSessionStore((s) => {

packages/ui/src/features/sessions/sessionServiceHost.test.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3226,7 +3226,7 @@ describe("SessionService", () => {
32263226
);
32273227
});
32283228

3229-
it("captures agentVersion from run_started params onto the session", async () => {
3229+
it("captures agent capabilities from run_started params onto the session", async () => {
32303230
const service = getSessionService();
32313231
const hydratedSession = createMockSession({
32323232
taskRunId: "run-123",
@@ -3256,6 +3256,7 @@ describe("SessionService", () => {
32563256
runId: "run-123",
32573257
taskId: "task-123",
32583258
agentVersion: "0.42.3",
3259+
steering: "native",
32593260
},
32603261
},
32613262
};
@@ -3275,6 +3276,7 @@ describe("SessionService", () => {
32753276
"run-123",
32763277
expect.objectContaining({
32773278
agentVersion: "0.42.3",
3279+
steering: "native",
32783280
status: "connected",
32793281
}),
32803282
);
@@ -5472,18 +5474,50 @@ describe("SessionService", () => {
54725474
expect(mockTrpcCloudTask.sendCommand.mutate).not.toHaveBeenCalled();
54735475
});
54745476

5475-
it("queues a cloud steer instead of interrupting the running turn", async () => {
5476-
// Regression: cloud has no native mid-turn steer, so steering used to
5477-
// fall back to cancel-then-resend — which surfaced as a jarring user
5478-
// interruption. Cloud steer must now queue like a normal message and
5479-
// never cancel the running turn.
5477+
it("sends a native cloud steer immediately", async () => {
54805478
const service = getSessionService();
54815479
mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(
54825480
createMockSession({
54835481
isCloud: true,
54845482
cloudStatus: "in_progress",
54855483
status: "connected",
54865484
isPromptPending: true,
5485+
steering: "native",
5486+
}),
5487+
);
5488+
mockTrpcCloudTask.sendCommand.mutate.mockResolvedValue({
5489+
success: true,
5490+
result: { stopReason: "steered", steered: true },
5491+
});
5492+
5493+
const prompt: ContentBlock[] = [{ type: "text", text: "steer me" }];
5494+
const result = await service.sendPrompt("task-123", prompt, {
5495+
steer: true,
5496+
});
5497+
5498+
expect(result.stopReason).toBe("steered");
5499+
expect(mockSessionStoreSetters.enqueueMessage).not.toHaveBeenCalled();
5500+
expect(mockTrpcCloudTask.sendCommand.mutate).toHaveBeenCalledWith(
5501+
expect.objectContaining({
5502+
method: "user_message",
5503+
params: { content: "steer me", steer: true },
5504+
}),
5505+
);
5506+
expect(mockSessionStoreSetters.updateSession).not.toHaveBeenCalledWith(
5507+
"run-123",
5508+
expect.objectContaining({ isPromptPending: false }),
5509+
);
5510+
});
5511+
5512+
it("queues a cloud steer when the sandbox lacks the capability", async () => {
5513+
const service = getSessionService();
5514+
mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(
5515+
createMockSession({
5516+
isCloud: true,
5517+
cloudStatus: "in_progress",
5518+
status: "connected",
5519+
isPromptPending: true,
5520+
steering: undefined,
54875521
}),
54885522
);
54895523

0 commit comments

Comments
 (0)