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
2 changes: 1 addition & 1 deletion .github/workflows/react-doctor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ jobs:
fi
REPORT="${RUNNER_TEMP}/react-doctor-report.json"
status=0
npx --yes react-doctor@0.4.2 . --blocking error --changed-files-from "$CHANGED" --json --json-compact --no-telemetry > "$REPORT" || status=$?
npx --yes react-doctor@0.5.4 . --blocking error --changed-files-from "$CHANGED" --json --json-compact --no-telemetry > "$REPORT" || status=$?
echo "exit-code=$status" >> "$GITHUB_OUTPUT"
echo "report=$REPORT" >> "$GITHUB_OUTPUT"
if [ "$status" -ne 0 ]; then
Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/adapters/claude/UPSTREAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth
- Branch naming in system prompt
- `broadcastUserMessage` in prompt()
- `interruptReason` on cancel
- Steer mode: `_meta.steer` maps to `priority:"next"` in `promptToClaude` (`acp-to-sdk.ts`). A mid-turn branch in `prompt()` pushes the message into the running turn's input and returns immediately with a benign `end_turn` instead of queueing a new turn. Advertised via `_meta.posthog.steering:"native"` in `initialize()`
- `SYSTEM_REMINDER` stripping from Read tool results
- WebFetch `resourceLink` content enrichment
- `customTitle` in listSessions (PostHog Code is ahead of upstream here)
Expand Down
15 changes: 14 additions & 1 deletion packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ import {
estimateSkillsTokens,
estimateSystemPrompt,
} from "./context-breakdown";
import { promptToClaude } from "./conversion/acp-to-sdk";
import { isSteerMeta, promptToClaude } from "./conversion/acp-to-sdk";
import {
handleResultMessage,
handleStreamEvent,
Expand Down Expand Up @@ -274,6 +274,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
_meta: {
posthog: {
resumeSession: true,
steering: "native",
},
claudeCode: {
promptQueueing: true,
Expand Down Expand Up @@ -433,6 +434,18 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
}

if (this.session.promptRunning) {
const isSteer = isSteerMeta(params._meta);
if (isSteer) {
// Fold this message into the turn already running instead of queueing a
// new turn. promptToClaude tagged it priority:"next" so the SDK delivers
// it at the next tool-call boundary. Return immediately with a benign
// end_turn: the in-flight turn (not this call) owns the loop and the
// real stop reason. The client tells steers apart by the request's
// _meta.steer, not by this value.
this.session.input.push(userMessage);
await this.broadcastUserMessage(params);
return { stopReason: "end_turn" };
}
this.session.input.push(userMessage);
const order = this.session.nextPendingOrder++;
const cancelled = await new Promise<boolean>((resolve) => {
Expand Down
32 changes: 32 additions & 0 deletions packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { PromptRequest } from "@agentclientprotocol/sdk";
import { describe, expect, it } from "vitest";
import {
isSteerMeta,
promptToClaude,
readToolGuidanceForPath,
workspacePromptFromFileUri,
Expand All @@ -27,6 +28,19 @@ describe("workspacePromptFromFileUri", () => {
});
});

describe("isSteerMeta", () => {
it.each([
[{ steer: true }, true],
[{ steer: false }, false],
[{}, false],
[undefined, false],
[null, false],
[{ steer: "true" }, false],
])("detects steer in %o as %s", (meta, expected) => {
expect(isSteerMeta(meta)).toBe(expected);
});
});

describe("promptToClaude", () => {
it("maps file resource_link to workspace path + Read guidance", () => {
const result = promptToClaude({
Expand Down Expand Up @@ -56,6 +70,24 @@ describe("promptToClaude", () => {
expect(text).toContain("pages");
});

it("tags a steer message with priority 'next' for tool-boundary delivery", () => {
const result = promptToClaude({
sessionId: "session-1",
prompt: [{ type: "text", text: "use a different approach" }],
_meta: { steer: true },
});
expect(result.priority).toBe("next");
});

it("leaves priority and shouldQuery unset for a normal message", () => {
const result = promptToClaude({
sessionId: "session-1",
prompt: [{ type: "text", text: "hello" }],
});
expect(result.priority).toBeUndefined();
expect(result.shouldQuery).toBeUndefined();
});

it("drops embedded body for file:// resource but keeps attachment:// payload", () => {
const hugeInline = `${"y".repeat(30_000)}KEEP_ATTACH${"y".repeat(30_000)}`;
const fileRes = promptToClaude({
Expand Down
23 changes: 20 additions & 3 deletions packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,12 +150,21 @@ function processPromptChunk(
}
}

/** True when an ACP request's `_meta` marks it as a mid-turn steer. */
export function isSteerMeta(meta: unknown): boolean {
return (
typeof meta === "object" &&
meta !== null &&
(meta as Record<string, unknown>).steer === true
);
}

export function promptToClaude(prompt: PromptRequest): SDKUserMessage {
const content: ContentBlockParam[] = [];
const context: ContentBlockParam[] = [];

const prContext = (prompt._meta as Record<string, unknown> | undefined)
?.prContext;
const meta = prompt._meta as Record<string, unknown> | undefined;
const prContext = meta?.prContext;
if (typeof prContext === "string") {
content.push(sdkText(prContext));
}
Expand All @@ -166,10 +175,18 @@ export function promptToClaude(prompt: PromptRequest): SDKUserMessage {

content.push(...context);

return {
const message: SDKUserMessage = {
type: "user",
message: { role: "user", content },
session_id: prompt.sessionId,
parent_tool_use_id: null,
};

// A steer is folded into the turn already running: priority "next" tells the
// SDK to deliver it at the next tool-call boundary rather than as a new turn.
if (isSteerMeta(meta)) {
message.priority = "next";
}

return message;
}
1 change: 1 addition & 0 deletions packages/agent/src/adapters/codex/codex-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
_meta: {
posthog: {
resumeSession: true,
steering: "interrupt-resend",
},
},
},
Expand Down
67 changes: 67 additions & 0 deletions packages/core/src/sessions/sessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1255,13 +1255,34 @@ export class SessionService {
this.idleKilledSubscription = null;
}

/**
* A steer message rides on `session/prompt` with `_meta.steer`. It is folded
* into the running turn, so its request must not participate in turn-state
* bookkeeping (currentPromptId / isPromptPending) or the live turn would be
* cut short. Its response carries a foreign request id, so the currentPromptId
* guard ignores it without needing a marker here.
*/
private isSteerMessage(msg: AcpMessage["message"]): boolean {
if (isJsonRpcRequest(msg) && msg.method === "session/prompt") {
const params = msg.params as { _meta?: { steer?: boolean } } | undefined;
return params?._meta?.steer === true;
}
return false;
}

private updatePromptStateFromEvents(
taskRunId: string,
events: AcpMessage[],
{ isLive = false }: { isLive?: boolean } = {},
): void {
for (const acpMsg of events) {
const msg = acpMsg.message;
// A steer is injected into the running turn, not a turn of its own. Skip
// its request so it never claims currentPromptId. Otherwise the steer's
// instant response would clear the live turn's pending state.
if (this.isSteerMessage(msg)) {
continue;
}
if (isJsonRpcRequest(msg) && msg.method === "session/prompt") {
this.d.store.updateSession(taskRunId, {
isPromptPending: true,
Expand Down Expand Up @@ -1648,6 +1669,7 @@ export class SessionService {
async sendPrompt(
taskId: string,
prompt: string | ContentBlock[],
options?: { steer?: boolean },
): Promise<{ stopReason: string }> {
if (!this.d.getIsOnline()) {
throw new Error(
Expand All @@ -1667,6 +1689,23 @@ export class SessionService {
);
}

// Steer: the user sent a message mid-turn and asked to fold it into the
// running turn rather than queue it. Native (Claude) injects at the next
// tool boundary; everything else interrupts the turn and resends below as a
// fresh prompt. Compaction always falls through to the queue.
if (options?.steer && session.isPromptPending && !session.isCompacting) {
const supportsNativeSteer =
!session.isCloud && session.adapter === "claude";
if (supportsNativeSteer) {
return this.sendSteerPrompt(session, prompt);
}
await this.cancelPrompt(taskId);
const refreshed = this.d.store.getSessionByTaskId(taskId);
if (refreshed) {
session = refreshed;
}
}

if (session.isCloud) {
return this.sendCloudPrompt(session, prompt);
}
Expand Down Expand Up @@ -1751,6 +1790,34 @@ export class SessionService {
});
}

/**
* Send a steer message: folded into the turn already running rather than
* queued. It renders when its `session/prompt` echo arrives and is injected
* by the agent at the next tool boundary. The running turn keeps ownership of
* the prompt lifecycle, so this never touches isPromptPending.
*/
private async sendSteerPrompt(
session: AgentSession,
prompt: string | ContentBlock[],
): Promise<{ stopReason: string }> {
const blocks = normalizePromptToBlocks(prompt);
const promptText = extractPromptText(prompt);

this.d.track(ANALYTICS_EVENTS.PROMPT_SENT, {
task_id: session.taskId,
is_initial: false,
execution_type: "local",
prompt_length_chars: promptText.length,
is_steer: true,
});

return this.d.trpc.agent.prompt.mutate({
sessionId: session.taskRunId,
prompt: blocks,
steer: true,
});
}

/**
* Send all queued messages as a single prompt.
* Called internally when a turn completes and there are queued messages.
Expand Down
4 changes: 3 additions & 1 deletion packages/host-router/src/routers/agent.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ export const agentRouter = router({
.mutation(({ ctx, input }) =>
ctx.container
.get<AgentService>(AGENT_SERVICE)
.prompt(input.sessionId, input.prompt as ContentBlock[]),
.prompt(input.sessionId, input.prompt as ContentBlock[], {
steer: input.steer,
}),
),

cancel: publicProcedure
Expand Down
8 changes: 8 additions & 0 deletions packages/ui/src/features/command/keyboard-shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const SHORTCUTS = {
FIND_IN_CONVERSATION: "mod+f",
BLUR: "escape",
SUBMIT_BLUR: "mod+enter",
SWITCH_MESSAGING_MODE: "mod+s",
} as const;

export type ShortcutCategory = "general" | "navigation" | "panels" | "editor";
Expand Down Expand Up @@ -63,6 +64,13 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
description: "Show keyboard shortcuts",
category: "general",
},
{
id: "switch-messaging-mode",
keys: SHORTCUTS.SWITCH_MESSAGING_MODE,
description: "Switch Steer / Queue mode",
category: "editor",
context: "Session composer",
},
{
id: "inbox",
keys: SHORTCUTS.INBOX,
Expand Down
23 changes: 23 additions & 0 deletions packages/ui/src/features/message-editor/components/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import "./message-editor.css";
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import { ArrowUp, Stop } from "@phosphor-icons/react";
import { InputGroup, InputGroupAddon, InputGroupButton } from "@posthog/quill";
import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts";
import { cycleModeOption } from "@posthog/ui/features/sessions/sessionStore";
import { hasOpenOverlay } from "@posthog/ui/utils/overlay";
import { Flex, Text, Tooltip } from "@radix-ui/themes";
Expand Down Expand Up @@ -42,6 +43,7 @@ export interface PromptInputProps {
// toolbar slots
modelSelector?: React.ReactElement | null | false;
reasoningSelector?: React.ReactElement | null | false;
messagingModeToggle?: React.ReactNode;
historyButton?: React.ReactNode;
// prompt history provider
getPromptHistory?: () => string[];
Expand All @@ -51,6 +53,7 @@ export interface PromptInputProps {
onBashCommand?: (command: string) => void;
onBashModeChange?: (isBashMode: boolean) => void;
onCancel?: () => void;
onToggleMessagingMode?: () => void;
onAttachFiles?: (files: File[]) => void;
onEmptyChange?: (isEmpty: boolean) => void;
onFocus?: () => void;
Expand Down Expand Up @@ -82,13 +85,15 @@ export const PromptInput = forwardRef<EditorHandle, PromptInputProps>(
enableCommands = true,
modelSelector,
reasoningSelector,
messagingModeToggle,
historyButton,
getPromptHistory,
onBeforeSubmit,
onSubmit,
onBashCommand,
onBashModeChange,
onCancel,
onToggleMessagingMode,
onAttachFiles,
onEmptyChange,
onFocus,
Expand Down Expand Up @@ -238,6 +243,23 @@ export const PromptInput = forwardRef<EditorHandle, PromptInputProps>(
[editor, modeOption, onModeChange, allowBypassPermissions, disabled],
);

useHotkeys(
SHORTCUTS.SWITCH_MESSAGING_MODE,
(e) => {
if (!editor?.isFocused) return;
if (hasOpenOverlay()) return;
if (!onToggleMessagingMode) return;
e.preventDefault();
onToggleMessagingMode();
},
{
enableOnFormTags: true,
enableOnContentEditable: true,
enabled: !disabled && !!onToggleMessagingMode,
},
[editor, onToggleMessagingMode, disabled],
);

const handleContainerClick = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement;
Expand Down Expand Up @@ -344,6 +366,7 @@ export const PromptInput = forwardRef<EditorHandle, PromptInputProps>(
)}
{modelSelector && <span>{modelSelector}</span>}
{reasoningSelector && <span>{reasoningSelector}</span>}
{messagingModeToggle && <span>{messagingModeToggle}</span>}
{isBashMode && (
<Text className="font-mono text-(--blue-9) text-[13px]">
! bash
Expand Down
9 changes: 9 additions & 0 deletions packages/ui/src/features/sessions/components/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import { PlanStatusBar } from "@posthog/ui/features/sessions/components/PlanStat
import { ReasoningLevelSelector } from "@posthog/ui/features/sessions/components/ReasoningLevelSelector";
import { RawLogsView } from "@posthog/ui/features/sessions/components/raw-logs/RawLogsView";
import { SessionResourcesBar } from "@posthog/ui/features/sessions/components/SessionResourcesBar";
import { SteerQueueToggle } from "@posthog/ui/features/sessions/components/SteerQueueToggle";
import { CHAT_CONTENT_MAX_WIDTH } from "@posthog/ui/features/sessions/constants";
import { useToggleMessagingMode } from "@posthog/ui/features/sessions/hooks/useToggleMessagingMode";
import {
useAdapterForTask,
useModeConfigOptionForTask,
Expand Down Expand Up @@ -161,6 +163,7 @@ export function SessionView({
const modeOption = useModeConfigOptionForTask(taskId);
const thoughtOption = useThoughtLevelConfigOptionForTask(taskId);
const adapter = useAdapterForTask(taskId);
const toggleMessagingMode = useToggleMessagingMode(taskId);
const { allowBypassPermissions } = useSettingsStore();
const { isOnline } = useConnectivity();
const currentModeId = modeOption?.currentValue;
Expand Down Expand Up @@ -625,6 +628,12 @@ export function SessionView({
/>
) : null
}
messagingModeToggle={
taskId ? (
<SteerQueueToggle taskId={taskId} />
) : undefined
}
onToggleMessagingMode={toggleMessagingMode}
onBeforeSubmit={handleBeforeSubmit}
onSubmit={handleSubmit}
onBashCommand={onBashCommand}
Expand Down
Loading
Loading