Skip to content
Open
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
81 changes: 52 additions & 29 deletions packages/core/src/sessions/sessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2901,7 +2901,11 @@ export class SessionService {
isNotification(msg.method, POSTHOG_NOTIFICATIONS.RUN_STARTED)
) {
const session = this.d.store.getSessions()[taskRunId];
const params = (msg as { params?: { agentVersion?: unknown } }).params;
const params = (
msg as {
params?: { agentVersion?: unknown; steering?: unknown };
}
).params;
const agentVersion =
typeof params?.agentVersion === "string"
? params.agentVersion
Expand All @@ -2910,6 +2914,12 @@ export class SessionService {
if (agentVersion && session?.agentVersion !== agentVersion) {
updates.agentVersion = agentVersion;
}
if (
typeof params?.steering === "string" &&
session?.steering !== params.steering
) {
updates.steering = params.steering;
}
if (session?.isCloud && session.status !== "connected") {
updates.status = "connected";
}
Expand Down Expand Up @@ -3384,22 +3394,27 @@ export class SessionService {
// Steer: the user sent a message mid-turn and asked to fold it into the
// running turn rather than queue it. Adapters that negotiated
// `steering: "native"` (Claude, codex) inject at the next tool boundary;
// unknown adapters cancel and resend. Cloud has no real mid-turn steer
// (the backend only delivers messages between turns), so it falls through
// to the queue; compaction too.
if (
options?.steer &&
!session.isCloud &&
session.isPromptPending &&
!session.isCompacting
) {
// unknown local adapters cancel and resend. Cloud sessions only enter this
// path after the sandbox advertises native steering; compaction still queues.
if (options?.steer && session.isPromptPending && !session.isCompacting) {
if (sessionSupportsNativeSteer(session)) {
return this.sendSteerPrompt(session, prompt);
if (session.isCloud) {
if (session.status === "connected") {
return this.sendCloudPrompt(session, prompt, {
skipQueueGuard: true,
steer: true,
});
}
} else {
return this.sendSteerPrompt(session, prompt);
}
}
await this.cancelPrompt(taskId);
const refreshed = this.d.store.getSessionByTaskId(taskId);
if (refreshed) {
session = refreshed;
if (!session.isCloud) {
await this.cancelPrompt(taskId);
const refreshed = this.d.store.getSessionByTaskId(taskId);
if (refreshed) {
session = refreshed;
}
}
}

Expand Down Expand Up @@ -3931,7 +3946,7 @@ export class SessionService {
private async sendCloudPrompt(
session: AgentSession,
prompt: string | ContentBlock[],
options?: { skipQueueGuard?: boolean },
options?: { skipQueueGuard?: boolean; steer?: boolean },
): Promise<{ stopReason: string }> {
const normalizedPrompt = await this.resolveCloudPrompt(prompt);
const transport = this.d.h.getCloudPromptTransport(normalizedPrompt);
Expand Down Expand Up @@ -4063,19 +4078,24 @@ export class SessionService {
if (artifactIds.length > 0) {
params.artifact_ids = artifactIds;
}
if (options?.steer) {
params.steer = true;
}

const currentSessionBeforeSend =
this.getSessionByRunId(session.taskRunId) ?? session;
const idleEvidenceBeforeSend = this.cloudRunIdleTracker.capture(
currentSessionBeforeSend,
);
this.d.store.updateSession(session.taskRunId, {
isPromptPending: true,
promptStartedAt: Date.now(),
pausedDurationMs: 0,
agentIdleForRunId: undefined,
});
this.cloudRunIdleTracker.markBusy(currentSessionBeforeSend);
if (!options?.steer) {
this.d.store.updateSession(session.taskRunId, {
isPromptPending: true,
promptStartedAt: Date.now(),
pausedDurationMs: 0,
agentIdleForRunId: undefined,
});
this.cloudRunIdleTracker.markBusy(currentSessionBeforeSend);
}
this.d.store.appendOptimisticItem(session.taskRunId, {
type: "user_message",
content: transport.promptText,
Expand All @@ -4088,6 +4108,7 @@ export class SessionService {
is_initial: session.events.length === 0,
execution_type: "cloud",
prompt_length_chars: transport.promptText.length,
...(options?.steer ? { is_steer: true } : {}),
});

try {
Expand All @@ -4105,23 +4126,25 @@ export class SessionService {
}

const commandResult = result.result as
| { queued?: boolean; stopReason?: string }
| { queued?: boolean; steered?: boolean; stopReason?: string }
| undefined;
const stopReason = commandResult?.queued
? "queued"
: (commandResult?.stopReason ?? "end_turn");

return { stopReason };
} catch (error) {
this.d.store.updateSession(session.taskRunId, {
isPromptPending: false,
promptStartedAt: null,
});
if (!options?.steer) {
this.d.store.updateSession(session.taskRunId, {
isPromptPending: false,
promptStartedAt: null,
});
}
this.d.store.clearTailOptimisticItems(session.taskRunId);
const currentSessionAfterFailure = this.getSessionByRunId(
session.taskRunId,
);
if (currentSessionAfterFailure) {
if (currentSessionAfterFailure && !options?.steer) {
const restoreResult = this.cloudRunIdleTracker.restoreAfterFailedSend(
idleEvidenceBeforeSend,
currentSessionAfterFailure,
Expand Down
6 changes: 2 additions & 4 deletions packages/ui/src/features/sessions/components/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -704,13 +704,11 @@ export function SessionView({
) : null
}
messagingModeToggle={
taskId && !isCloudRun ? (
taskId ? (
<SteerQueueToggle taskId={taskId} />
) : undefined
}
onToggleMessagingMode={
isCloudRun ? undefined : toggleMessagingMode
}
onToggleMessagingMode={toggleMessagingMode}
onPromptRecall={handlePromptRecall}
onBeforeSubmit={handleBeforeSubmit}
onSubmit={handleSubmit}
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/features/sessions/hooks/useMessagingMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ export function useMessagingMode(taskId: string | undefined): MessagingMode {
* Whether the task's session steers natively (folds a mid-turn message into the
* running turn) versus falling back to interrupt-and-resend. Driven by the
* adapter's negotiated `steering` capability — same decision as the host's
* sendPrompt gate — so Claude and codex steer, while cloud
* resend. Drives the steer label/tooltip, not whether steer is allowed.
* sendPrompt gate, including capability-advertising cloud sandboxes. Drives
* the steer label/tooltip, not whether steer is allowed.
*/
export function useSupportsNativeSteer(taskId: string | undefined): boolean {
return useSessionStore((s) => {
Expand Down
46 changes: 40 additions & 6 deletions packages/ui/src/features/sessions/sessionServiceHost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3226,7 +3226,7 @@ describe("SessionService", () => {
);
});

it("captures agentVersion from run_started params onto the session", async () => {
it("captures agent capabilities from run_started params onto the session", async () => {
const service = getSessionService();
const hydratedSession = createMockSession({
taskRunId: "run-123",
Expand Down Expand Up @@ -3256,6 +3256,7 @@ describe("SessionService", () => {
runId: "run-123",
taskId: "task-123",
agentVersion: "0.42.3",
steering: "native",
},
},
};
Expand All @@ -3275,6 +3276,7 @@ describe("SessionService", () => {
"run-123",
expect.objectContaining({
agentVersion: "0.42.3",
steering: "native",
status: "connected",
}),
);
Expand Down Expand Up @@ -5472,18 +5474,50 @@ describe("SessionService", () => {
expect(mockTrpcCloudTask.sendCommand.mutate).not.toHaveBeenCalled();
});

it("queues a cloud steer instead of interrupting the running turn", async () => {
// Regression: cloud has no native mid-turn steer, so steering used to
// fall back to cancel-then-resend — which surfaced as a jarring user
// interruption. Cloud steer must now queue like a normal message and
// never cancel the running turn.
it("sends a native cloud steer immediately", async () => {
const service = getSessionService();
mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(
createMockSession({
isCloud: true,
cloudStatus: "in_progress",
status: "connected",
isPromptPending: true,
steering: "native",
}),
);
mockTrpcCloudTask.sendCommand.mutate.mockResolvedValue({
success: true,
result: { stopReason: "steered", steered: true },
});

const prompt: ContentBlock[] = [{ type: "text", text: "steer me" }];
const result = await service.sendPrompt("task-123", prompt, {
steer: true,
});

expect(result.stopReason).toBe("steered");
expect(mockSessionStoreSetters.enqueueMessage).not.toHaveBeenCalled();
expect(mockTrpcCloudTask.sendCommand.mutate).toHaveBeenCalledWith(
expect.objectContaining({
method: "user_message",
params: { content: "steer me", steer: true },
}),
);
expect(mockSessionStoreSetters.updateSession).not.toHaveBeenCalledWith(
"run-123",
expect.objectContaining({ isPromptPending: false }),
);
});

it("queues a cloud steer when the sandbox lacks the capability", async () => {
const service = getSessionService();
mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(
createMockSession({
isCloud: true,
cloudStatus: "in_progress",
status: "connected",
isPromptPending: true,
steering: undefined,
}),
);

Expand Down
Loading