feat(assistant): mid-turn steering of the running response

A follow-up sent during an active run now offers itself to the running
agentic loop via POST /api/ai/sessions/{id}/steer. Accepted steers join
the loop at its next turn boundary (the abort-check site) as plain user
messages, are announced with a steer_applied stream event so the drawer
settles the pending row, and persist through the end-of-run save. A
steer carries prompt text only: no route, control-level, or autonomy
changes, no turn-budget extension, system sessions rejected, and the
per-session inbox is bounded (steer_backlog overflow). Delivery is not
guaranteed by acceptance: a run that ends first discards the inbox and
the row drains as an ordinary queued turn, so pre-steering queue
semantics remain the fallback. Steering rows lose edit/remove once
accepted.
This commit is contained in:
rcourtman
2026-07-12 23:01:40 +01:00
parent 8676e5d5b9
commit 7d772acff3
26 changed files with 1025 additions and 38 deletions
@@ -500,6 +500,14 @@ only. Lifecycle surfaces and agents must not interpret an undone or
regenerated Assistant turn as reverting any lifecycle action, approval, or
agent command the original turn produced; governed action history remains
the only revert authority for infrastructure changes.
Mid-turn steering through `POST /api/ai/sessions/{id}/steer` is likewise
conversation input only: it adds a user message to a running Assistant loop
at a turn boundary. A steering message grants no agent command authority,
cannot approve, deny, or bypass a pending approval, cannot escalate the
running turn's control level or autonomous mode, and must not be
interpreted by lifecycle surfaces as operator authorization for any action
the steered turn subsequently proposes; those proposals still route through
the governed approval and action lifecycle unchanged.
The native Assistant surface-tool inventory at
`GET /api/ai/assistant/surface-tools` is also AI-runtime/API-contract metadata:
lifecycle surfaces may display which Assistant tools are available, but must
@@ -1340,11 +1340,30 @@ deriving an older display status from `workflowStatusHistory`.
pattern by using its persisted explicit route contract rather than terminal
provider/model objects.
Follow-up sends during an active Assistant response are chat-runtime queue
state by default. The drawer must accept and echo the user's follow-up as a
queued user turn without aborting or replacing the active model stream, must
show an itemized composer-adjacent queue with per-follow-up edit/remove
controls plus clear-all, and must drain queued turns in order only after the
active stream becomes idle. Queued follow-ups must snapshot the effective
state that steers the running response by default. The drawer must accept
and echo the user's follow-up as a queued user turn without aborting or
replacing the active model stream, must show an itemized composer-adjacent
queue with per-follow-up edit/remove controls plus clear-all, and must
offer each follow-up to the running loop through
`POST /api/ai/sessions/{id}/steer` (mid-turn steering). A steering-accepted
follow-up joins the loop at its next turn boundary (the abort-check site in
`agentic.go executeWithTools`) as a plain user message, never mid-provider-
stream and never mid-tool-batch; the loop announces the injection with a
`steer_applied` stream event carrying the client row id so the drawer
settles the pending row into a delivered user turn, splices the message
into `resultMessages` at its true position, and persists it through the
end-of-run save (whose skip-user-messages rule exempts `Steered` messages).
Steering carries prompt text only: it cannot change the model route,
control level, or autonomous mode of the running turn, does not extend the
turn budget or reset wrap-up brakes, and is rejected for system sessions.
Once accepted for steering, a follow-up row loses its edit/remove
affordances (the text is in the loop's hands). Delivery is not guaranteed:
a run that finishes before a boundary discards unconsumed steers without
persisting them, and the drawer, which keeps the row queued until
`steer_applied` arrives, drains it in order after the active stream
becomes idle exactly as before — the pre-steering queue semantics remain
the fallback path, and paused queues do not steer. Queued follow-ups must
snapshot the effective
model route at enqueue time so a later model/provider switch cannot silently
reroute an already-queued user turn, and both the transcript queued-user row
and composer-adjacent queue row must surface that snapshotted route label when
@@ -1314,6 +1314,22 @@ payload shape change when the portal presents compact client rows.
`AIChatAPI.undoLastTurn(sessionId, { expectedPrompt })` and
`AIChatAPI.redoLastTurn(sessionId)` helpers so path encoding, guard
trimming, and response shape stay canonical.
`POST /api/ai/sessions/{id}/steer` owns the mid-turn steering API
contract (`chat.SessionSteerRequest` -> `chat.SessionSteerResult`). The
request carries `prompt` plus an optional `client_message_id`; the
response is immediate JSON, never a second SSE stream, and
`accepted:false` with a `reason` (`no_active_run`, `system_session`,
`empty_prompt`, `steer_backlog` when the bounded per-session steering
inbox is full) is a normal outcome that clients handle by keeping the
follow-up on the ordinary queue-drain path. Steering cannot carry model
route, control level, or autonomous-mode changes. Confirmation of
delivery arrives only on the session's existing chat stream as a
generated `steer_applied` event (`chat.SteerAppliedData`, in
`aiChatEvents.ts` and the `AIChatStreamEvent` union) echoing
`client_message_id`, the server message id, the prompt, and the turn
index; clients must treat the endpoint's `accepted:true` as inbox
receipt, not delivery. Browser clients must use the shared
`AIChatAPI.steerSession(sessionId, { prompt, clientMessageId })` helper.
OpenCode-style file diff/revert session routes are deliberately not part
of Pulse's supported Assistant session contract: Pulse sessions do not own
local code-file edits, and infrastructure mutations must be reviewed
@@ -243,6 +243,15 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
atomic approval consume, not a route-wide scan or request-hot-path fan-out;
grant signing and WebSocket writes happen only after that bounded verifier
succeeds.
Assistant mid-turn steering (`POST /api/ai/sessions/{id}/steer`, routed
through the session sub-route dispatch in `internal/api/router.go`) is a
point operation on the same terms: a map lookup of the session's active
loop plus an in-memory inbox append, returning immediate JSON with no
second SSE stream, no session-file read, and no provider work on the
request path. The per-session steering inbox is bounded
(`maxPendingSteersPerSession`), so repeated steers cannot grow service
memory or the running turn's prompt without limit; overflow returns
`steer_backlog` and the message stays on the client's queue.
Scheduled-report background worker registration is allowed in router startup,
but it must stay outside protected request handling. Due-schedule scans may
enumerate tenant organization IDs and load each workspace schedule store, but
@@ -339,6 +339,13 @@ the `white_label` branding entitlement.
that rule: public chat and relay input cannot serialize its org/action
authorization context, and invalid approvals fail before signing or agent
dispatch rather than falling through to a route-local trust shortcut.
The Assistant steer sub-route (`POST /api/ai/sessions/{id}/steer`) added
to the session dispatch is bound by the same rule: it requires
`ScopeAIChat`, carries conversation text only, cannot approve or bypass
a pending approval, cannot change the running turn's control level,
autonomous mode, or model route, rejects Pulse-owned system sessions,
and its response discloses only `accepted` plus a coarse reason, never
run internals, provider state, or transcript content.
The Patrol action-broker and proposal-catalog factory glue wired here is bound by the
same rule: it may connect the investigation orchestrator to the tenant-bound
action lifecycle, but it exposes only typed-proposal capture and gives the
@@ -153,8 +153,9 @@ grant backup visibility, recovery authority, or storage health privileges; those
remain governed by the setup-script and source-specific backup API boundaries
below.
Assistant chat-session history endpoints in `internal/api/ai_handler.go`
(session list/rename/fork/summarize and turn undo/redo, including the
retry/regenerate expected-prompt guard on `POST /api/ai/sessions/{id}/undo`)
(session list/rename/fork/summarize, turn undo/redo including the
retry/regenerate expected-prompt guard on `POST /api/ai/sessions/{id}/undo`,
and mid-turn steering via `POST /api/ai/sessions/{id}/steer`)
are ai-runtime conversation-state surfaces only. Undoing or regenerating an
Assistant turn rewrites chat transcript history; it is never a restore
operation, does not revert storage mutations or governed actions the original
@@ -1407,6 +1407,24 @@ describe('AIChatAPI', () => {
});
});
it('steers a running session through the steer endpoint', async () => {
const result = { accepted: true, session_id: 'session/root' };
apiFetchJSONMock.mockResolvedValueOnce(result);
await expect(
AIChatAPI.steerSession('session/root', {
prompt: 'also check pve2',
clientMessageId: 'row-1',
}),
).resolves.toEqual(result);
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/sessions/session%2Froot/steer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: 'also check pve2', client_message_id: 'row-1' }),
});
});
it('sends the expected-prompt guard body when undo runs for a retry', async () => {
const undoResult = {
success: true,
@@ -130,6 +130,23 @@ describe('AI chat stream event contract', () => {
expect(aiChatEventsSource).toContain('model?: string');
});
it('exposes mid-turn steering injections as a typed stream contract', () => {
const event: AIChatStreamEvent = {
type: 'steer_applied',
data: {
session_id: 'sess-stream',
message_id: 'srv-1',
client_message_id: 'row-1',
prompt: 'also check pve2',
turn: 2,
},
};
expect(event.data.client_message_id).toBe('row-1');
expect(aiChatEventsSource).toContain('export interface SteerAppliedData');
expect(aiChatEventsSource).toContain("type: 'steer_applied'");
});
it('exposes the estimated cumulative session cost on done events', () => {
const done: DoneData = {
session_id: 'sess-stream',
+24
View File
@@ -37,6 +37,12 @@ export interface ChatSessionRedoResult {
message?: string;
}
export interface ChatSessionSteerResult {
accepted: boolean;
session_id: string;
reason?: string; // "no_active_run" | "system_session" | "empty_prompt" | "steer_backlog"
}
export interface ChatSessionCompactionResult {
success: boolean;
status: 'compacted' | 'not_needed' | 'empty' | string;
@@ -409,6 +415,24 @@ export class AIChatAPI {
}) as Promise<ChatSessionRedoResult>;
}
// Steer the session's running response: the message joins the in-flight
// agentic loop at its next turn boundary. accepted=false (e.g. the run
// already finished) is a normal outcome; the caller keeps the follow-up
// queued and it drains as an ordinary new turn.
static async steerSession(
sessionId: string,
request: { prompt: string; clientMessageId?: string },
): Promise<ChatSessionSteerResult> {
return apiFetchJSON(`${this.baseUrl}/sessions/${encodeURIComponent(sessionId)}/steer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: request.prompt,
...(request.clientMessageId ? { client_message_id: request.clientMessageId } : {}),
}),
}) as Promise<ChatSessionSteerResult>;
}
// Stream chat - the main chat interface
static async chat(
prompt: string,
@@ -90,6 +90,14 @@ export interface SessionData {
id: string;
}
export interface SteerAppliedData {
session_id?: string;
message_id?: string;
client_message_id?: string;
prompt?: string;
turn: number;
}
export interface ThinkingData {
text: string;
}
@@ -150,5 +158,6 @@ export type AIChatStreamEvent =
| { type: 'tool_end'; data: ToolEndData }
| { type: 'approval_needed'; data: ApprovalNeededData }
| { type: 'question'; data: QuestionData & { session_id?: string } }
| { type: 'steer_applied'; data: SteerAppliedData }
| { type: 'done'; data?: DoneData }
| { type: 'error'; data: ErrorData };
@@ -223,6 +223,7 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
position: index + 1,
count: entries.length,
paused: Boolean(props.queuedFollowUpsPaused),
steering: Boolean(entry.steering),
},
]),
);
@@ -333,8 +334,9 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
queuedPosition={queuedMeta()?.position}
queuedCount={queuedMeta()?.count}
queuedPaused={queuedMeta()?.paused}
queuedSteering={queuedMeta()?.steering}
onEditQueued={
queuedMeta() && props.onEditQueuedFollowUp
queuedMeta() && !queuedMeta()?.steering && props.onEditQueuedFollowUp
? () => {
const meta = queuedMeta();
if (meta) props.onEditQueuedFollowUp?.(meta.id);
@@ -342,7 +344,7 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
: undefined
}
onCancelQueued={
queuedMeta() && props.onCancelQueuedFollowUp
queuedMeta() && !queuedMeta()?.steering && props.onCancelQueuedFollowUp
? () => {
const meta = queuedMeta();
if (meta) props.onCancelQueuedFollowUp?.(meta.id);
@@ -68,6 +68,9 @@ interface MessageItemProps {
queuedPosition?: number;
queuedCount?: number;
queuedPaused?: boolean;
// The follow-up was accepted for mid-turn steering and will join the
// running response at its next step; edit/remove are no longer offered.
queuedSteering?: boolean;
onEditQueued?: () => void;
onCancelQueued?: () => void;
}
@@ -252,6 +255,7 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
const isQueuedUserMessage = () => isUser() && props.message.delivery === 'queued';
const queuedStatusLabel = createMemo(() => {
if (!isQueuedUserMessage()) return '';
if (props.queuedSteering) return 'Steering the running response';
const position = props.queuedPosition;
const count = props.queuedCount;
const state = props.queuedPaused ? 'Paused' : 'Queued';
@@ -11,6 +11,7 @@ vi.mock('@/api/aiChat', () => ({
answerQuestion: vi.fn(),
undoLastTurn: vi.fn(),
redoLastTurn: vi.fn(),
steerSession: vi.fn(),
},
}));
@@ -43,6 +44,7 @@ const mockAbortSession = AIChatAPI.abortSession as ReturnType<typeof vi.fn>;
const mockAnswerQuestion = AIChatAPI.answerQuestion as ReturnType<typeof vi.fn>;
const mockUndoLastTurn = AIChatAPI.undoLastTurn as ReturnType<typeof vi.fn>;
const mockRedoLastTurn = AIChatAPI.redoLastTurn as ReturnType<typeof vi.fn>;
const mockSteerSession = AIChatAPI.steerSession as ReturnType<typeof vi.fn>;
const mockNotifyError = notificationStore.error as ReturnType<typeof vi.fn>;
type TestStreamEvent = StreamEvent | { type: string; data?: unknown };
@@ -832,6 +834,131 @@ describe('useChat', () => {
dispose();
});
it('steers a follow-up into the running response when the backend accepts', async () => {
let fireEvent!: (e: TestStreamEvent) => void;
let resolveFirst!: () => void;
mockChat.mockImplementationOnce(
(_p: string, _s: string, _m: string | undefined, onEvent: (e: StreamEvent) => void) => {
fireEvent = onEvent as (e: TestStreamEvent) => void;
return new Promise<void>((resolve) => {
resolveFirst = resolve;
});
},
);
mockSteerSession.mockResolvedValueOnce({ accepted: true, session_id: 'sess' });
const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 'sess' }));
const first = chat.sendMessage('first');
await new Promise((r) => setTimeout(r, 0));
await chat.sendMessage('also check pve2');
await new Promise((r) => setTimeout(r, 0));
const entry = chat.queuedFollowUps()[0];
expect(mockSteerSession).toHaveBeenCalledWith('sess', {
prompt: 'also check pve2',
clientMessageId: entry.messageId,
});
expect(entry.steering).toBe(true);
// Backend confirms injection: the pending row settles into an
// ordinary delivered user message and leaves the queue.
fireEvent({
type: 'steer_applied',
data: {
client_message_id: entry.messageId,
message_id: 'srv-steer-1',
prompt: 'also check pve2',
turn: 1,
},
});
await new Promise((r) => setTimeout(r, 0));
expect(chat.queuedFollowUpCount()).toBe(0);
const steered = chat.messages().find((m) => m.content === 'also check pve2');
expect(steered?.delivery).toBeUndefined();
resolveFirst();
await first;
await new Promise((r) => setTimeout(r, 0));
// Nothing drains afterwards: the steered message must not re-send.
expect(mockChat).toHaveBeenCalledTimes(1);
dispose();
});
it('keeps the follow-up queued and drains normally when steering is not accepted', async () => {
let resolveFirst!: () => void;
let resolveSecond!: () => void;
mockChat
.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveFirst = resolve;
}),
)
.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveSecond = resolve;
}),
);
mockSteerSession.mockResolvedValueOnce({
accepted: false,
session_id: 'sess',
reason: 'no_active_run',
});
const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 'sess' }));
const first = chat.sendMessage('first');
await new Promise((r) => setTimeout(r, 0));
await chat.sendMessage('second');
await new Promise((r) => setTimeout(r, 0));
expect(chat.queuedFollowUps()[0]?.steering).toBeFalsy();
resolveFirst();
await first;
await new Promise((r) => setTimeout(r, 0));
expect(mockChat).toHaveBeenCalledTimes(2);
expect(mockChat.mock.calls[1][0]).toBe('second');
resolveSecond();
await new Promise((r) => setTimeout(r, 0));
dispose();
});
it("renders another client's steer as a delivered user message", async () => {
let fireEvent!: (e: TestStreamEvent) => void;
let resolveFirst!: () => void;
mockChat.mockImplementationOnce(
(_p: string, _s: string, _m: string | undefined, onEvent: (e: StreamEvent) => void) => {
fireEvent = onEvent as (e: TestStreamEvent) => void;
return new Promise<void>((resolve) => {
resolveFirst = resolve;
});
},
);
const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 'sess' }));
const first = chat.sendMessage('first');
await new Promise((r) => setTimeout(r, 0));
fireEvent({
type: 'steer_applied',
data: { client_message_id: 'foreign-row', message_id: 'srv-9', prompt: 'from another tab', turn: 2 },
});
await new Promise((r) => setTimeout(r, 0));
const echoed = chat.messages().find((m) => m.content === 'from another tab');
expect(echoed).toMatchObject({ role: 'user' });
resolveFirst();
await first;
dispose();
});
it('snapshots the selected model for queued follow-ups', async () => {
const resolvers: Array<() => void> = [];
mockChat.mockImplementation(
@@ -126,6 +126,11 @@ export interface QueuedFollowUp {
findingId?: string;
sendOptions?: SendMessageOptions;
timestamp: Date;
// The backend accepted this follow-up for mid-turn steering: it will join
// the running response at its next step. The row can no longer be edited
// or removed (the text is already in the loop's hands); if the run ends
// before delivery, the backend discards it and the row drains normally.
steering?: boolean;
}
export interface RestoredPromptDraft {
@@ -1344,6 +1349,40 @@ export function useChat(options: UseChatOptions = {}) {
applyStreamSessionId(extractSessionId(event.data));
}
if (event.type === 'steer_applied') {
const data = (event.data ?? {}) as {
client_message_id?: string;
message_id?: string;
prompt?: string;
};
const clientMessageId =
typeof data.client_message_id === 'string' ? data.client_message_id : '';
const prompt = typeof data.prompt === 'string' ? data.prompt : '';
const queued = clientMessageId
? queuedFollowUps().find((entry) => entry.messageId === clientMessageId)
: undefined;
if (queued) {
// Our pending row was injected into the running response: settle it
// into an ordinary delivered user message.
setQueuedFollowUps((prev) => prev.filter((entry) => entry.id !== queued.id));
setMessages((prev) =>
prev.map((msg) => (msg.id === queued.messageId ? { ...msg, delivery: undefined } : msg)),
);
} else if (prompt.trim()) {
// Another client steered this session; echo the injected user turn.
setMessages((prev) => [
...prev,
{
id: typeof data.message_id === 'string' && data.message_id ? data.message_id : generateId(),
role: 'user',
content: prompt,
timestamp: new Date(),
},
]);
}
return;
}
if (event.type === 'workflow_state') {
const workflowStatus = extractWorkflowStatus(event.data);
const startedModel =
@@ -1922,9 +1961,37 @@ export function useChat(options: UseChatOptions = {}) {
logger.debug('[useChat] Queued follow-up while assistant response is streaming', {
queuedFollowUpId: id,
});
void attemptSteer(queuedFollowUp);
return true;
};
// Offer a queued follow-up to the running response. Acceptance only means
// the backend inbox holds it — the row stays queued until steer_applied
// confirms injection, so an undelivered steer still drains normally.
const attemptSteer = async (entry: QueuedFollowUp) => {
const currentSessionId = sessionId().trim();
if (!currentSessionId) return;
try {
const result = await AIChatAPI.steerSession(currentSessionId, {
prompt: entry.prompt,
clientMessageId: entry.messageId,
});
if (!result.accepted) {
logger.debug('[useChat] Steer not accepted; follow-up stays queued', {
reason: result.reason,
});
return;
}
setQueuedFollowUps((prev) =>
prev.map((candidate) =>
candidate.id === entry.id ? { ...candidate, steering: true } : candidate,
),
);
} catch (error) {
logger.warn('[useChat] Steering attempt failed; follow-up stays queued', error);
}
};
const startMessageSend = async (
prompt: string,
mentions?: ChatMention[],
@@ -2141,7 +2208,9 @@ export function useChat(options: UseChatOptions = {}) {
const item = queuedFollowUps().find((entry) => entry.id === id);
if (!item) return false;
if (isLoading()) {
return promoteQueuedFollowUp(id);
const promoted = promoteQueuedFollowUp(id);
if (promoted && !item.steering) void attemptSteer(item);
return promoted;
}
setQueuedFollowUpsPaused(false);
@@ -1605,6 +1605,9 @@ export const AIChat: Component<AIChatProps> = (props) => {
};
const editQueuedFollowUp = (id: string) => {
// A steering follow-up is already in the running loop's hands; its text
// can no longer be recalled for editing.
if (chat.queuedFollowUps().find((entry) => entry.id === id)?.steering) return;
const queued = chat.takeQueuedFollowUp(id);
if (!queued) return;
resetPromptHistoryNavigation();
@@ -1666,6 +1669,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
id: string,
) => {
if (event.defaultPrevented || event.target !== event.currentTarget) return;
if (chat.queuedFollowUps().find((entry) => entry.id === id)?.steering) return;
if (event.key === 'Enter') {
event.preventDefault();
@@ -5055,10 +5059,14 @@ export const AIChat: Component<AIChatProps> = (props) => {
{(queued, index) => {
const preview = () => queuedFollowUpPreview(queued.prompt);
const routeLabel = () => queuedFollowUpRouteLabel(queued);
const rowLabel = () =>
routeLabel()
const rowLabel = () => {
if (queued.steering) {
return `Steering follow-up: ${preview()}. It joins the running response at its next step.`;
}
return routeLabel()
? `Queued follow-up: ${preview()}. Route: ${routeLabel()}. Press Enter to edit or Delete to remove.`
: `Queued follow-up: ${preview()}. Press Enter to edit or Delete to remove.`;
};
return (
<div
class="flex min-h-7 items-center gap-2 rounded-md bg-white/70 px-2 py-1 text-xs text-blue-900 outline-none transition-colors focus:bg-white focus:ring-2 focus:ring-blue-500/40 dark:bg-blue-900/30 dark:text-blue-100 dark:focus:bg-blue-900/50"
@@ -5083,7 +5091,12 @@ export const AIChat: Component<AIChatProps> = (props) => {
>
<span class="min-w-0 flex-1">
<span class="block truncate">{preview()}</span>
<Show when={routeLabel()}>
<Show when={queued.steering}>
<span class="block truncate text-[10px] font-medium text-blue-700 dark:text-blue-200">
Steering the running response
</span>
</Show>
<Show when={!queued.steering && routeLabel()}>
{(label) => (
<span
class="block truncate text-[10px] font-medium text-blue-700 dark:text-blue-200"
@@ -5094,7 +5107,11 @@ export const AIChat: Component<AIChatProps> = (props) => {
)}
</Show>
</span>
<Show when={chat.queuedFollowUpCount() > 1 && index() > 0}>
<Show
when={
chat.queuedFollowUpCount() > 1 && index() > 0 && !queued.steering
}
>
<ActionIconButton
onClick={() => sendQueuedFollowUpNext(queued.id)}
tone="accentGhost"
@@ -5116,27 +5133,29 @@ export const AIChat: Component<AIChatProps> = (props) => {
<SendIcon class="h-3.5 w-3.5" aria-hidden="true" />
</ActionIconButton>
</Show>
<ActionIconButton
onClick={() => editQueuedFollowUp(queued.id)}
tone="accentGhost"
size="xs"
title="Edit queued follow-up"
label={`Edit queued follow-up: ${preview()}`}
>
<PencilIcon class="h-3.5 w-3.5" aria-hidden="true" />
</ActionIconButton>
<ActionIconButton
onClick={() => {
chat.cancelQueuedFollowUp(queued.id);
focusComposer();
}}
tone="accentGhost"
size="xs"
title="Remove queued follow-up"
label={`Remove queued follow-up: ${preview()}`}
>
<XIcon class="h-3.5 w-3.5" aria-hidden="true" />
</ActionIconButton>
<Show when={!queued.steering}>
<ActionIconButton
onClick={() => editQueuedFollowUp(queued.id)}
tone="accentGhost"
size="xs"
title="Edit queued follow-up"
label={`Edit queued follow-up: ${preview()}`}
>
<PencilIcon class="h-3.5 w-3.5" aria-hidden="true" />
</ActionIconButton>
<ActionIconButton
onClick={() => {
chat.cancelQueuedFollowUp(queued.id);
focusComposer();
}}
tone="accentGhost"
size="xs"
title="Remove queued follow-up"
label={`Remove queued follow-up: ${preview()}`}
>
<XIcon class="h-3.5 w-3.5" aria-hidden="true" />
</ActionIconButton>
</Show>
</div>
);
}}
+30
View File
@@ -498,6 +498,7 @@ type AgenticLoop struct {
mu sync.Mutex
aborted map[string]bool // sessionID -> aborted
pendingQs map[string]chan []QuestionAnswer // questionID -> answer channel
pendingSteers map[string][]pendingSteer // sessionID -> steering messages awaiting the next turn boundary
autonomousMode bool // When true, don't wait for approvals (for investigations)
// executionProfile is the core-owned request posture (interactive
// Assistant, Patrol detection, Patrol investigation). It owns
@@ -537,6 +538,7 @@ func NewAgenticLoop(provider providers.StreamingProvider, executor *tools.PulseT
orgID: approval.DefaultOrgID,
aborted: make(map[string]bool),
pendingQs: make(map[string]chan []QuestionAnswer),
pendingSteers: make(map[string][]pendingSteer),
}
}
@@ -599,6 +601,10 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
a.mu.Lock()
delete(a.aborted, sessionID)
a.mu.Unlock()
// Unconsumed steers are dropped, never persisted: the client keeps
// its row queued until steer_applied confirms delivery, so an
// undelivered steer re-sends as a normal follow-up turn.
a.discardPendingSteers(sessionID)
}()
// Convert our messages to provider format
@@ -654,6 +660,30 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
requestSanitizer := a.requestSanitizer
a.mu.Unlock()
// === MID-TURN STEERING: inject queued user messages at the boundary ===
// Steers arrive via Service.SteerSession while a turn streams or its
// tools run; they join the conversation here, before the next model
// call, so the model reads them as ordinary user turns. Each one is
// announced on the stream so the drawer can settle its pending row.
for _, steer := range a.takePendingSteers(sessionID) {
msg := steer.message.NormalizeCollections()
providerMessages = append(providerMessages, convertToProviderMessages([]Message{msg})...)
resultMessages = append(resultMessages, msg)
if data, err := json.Marshal(SteerAppliedData{
SessionID: sessionID,
MessageID: msg.ID,
ClientMessageID: steer.clientMessageID,
Prompt: msg.Content,
Turn: turn,
}); err == nil {
callback(StreamEvent{Type: "steer_applied", Data: data})
}
log.Info().
Int("turn", turn).
Str("session_id", sessionID).
Msg("[AgenticLoop] Steering message injected at turn boundary")
}
// Record telemetry for loop iteration
if metrics := GetAIMetrics(); metrics != nil {
metrics.RecordAgenticIteration(providerName, modelName)
+74
View File
@@ -0,0 +1,74 @@
package chat
import (
"errors"
"fmt"
"strings"
)
// maxPendingSteersPerSession bounds the steering inbox so a chatty client
// cannot grow the running turn's prompt (and the service's memory) without
// limit; excess steers stay on the client's ordinary follow-up queue.
const maxPendingSteersPerSession = 8
// errSteerBacklogFull reports a full steering inbox; the service maps it to
// a normal accepted=false outcome rather than an error status.
var errSteerBacklogFull = errors.New("steer backlog full")
// pendingSteer pairs the steering message with the client-side transcript
// row id so the steer_applied event can reconcile the originating drawer.
type pendingSteer struct {
message Message
clientMessageID string
}
// Steer queues a user message for injection into the running loop at the
// next turn boundary (the same checkpoint that observes aborts). Delivery is
// not guaranteed: if the run finishes before a boundary arrives, unconsumed
// steers are discarded and the client re-sends through the normal queue
// drain, so the message is never persisted twice.
func (a *AgenticLoop) Steer(sessionID string, msg Message, clientMessageID string) error {
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return fmt.Errorf("steer requires a session id")
}
if strings.TrimSpace(msg.Content) == "" {
return fmt.Errorf("steer requires a non-empty prompt")
}
a.mu.Lock()
defer a.mu.Unlock()
if a.pendingSteers == nil {
a.pendingSteers = make(map[string][]pendingSteer)
}
if len(a.pendingSteers[sessionID]) >= maxPendingSteersPerSession {
return errSteerBacklogFull
}
a.pendingSteers[sessionID] = append(a.pendingSteers[sessionID], pendingSteer{
message: msg,
clientMessageID: strings.TrimSpace(clientMessageID),
})
return nil
}
// takePendingSteers drains and returns the steering messages queued for a
// session, in arrival order.
func (a *AgenticLoop) takePendingSteers(sessionID string) []pendingSteer {
a.mu.Lock()
defer a.mu.Unlock()
steers := a.pendingSteers[sessionID]
if len(steers) == 0 {
return nil
}
delete(a.pendingSteers, sessionID)
return steers
}
// discardPendingSteers drops any unconsumed steering messages when a run
// ends. The frontend keeps the row queued until steer_applied confirms
// delivery, so an undelivered steer drains as a normal follow-up turn.
func (a *AgenticLoop) discardPendingSteers(sessionID string) {
a.mu.Lock()
defer a.mu.Unlock()
delete(a.pendingSteers, sessionID)
}
+346
View File
@@ -0,0 +1,346 @@
package chat
import (
"context"
"encoding/json"
"sync"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// TestAgenticLoop_SteerInjectsAtNextTurnBoundary drives a two-turn run where
// turn 1 blocks on a pulse_question. A steer delivered while the run is
// blocked must be injected as a user message before turn 2's provider call,
// announced via steer_applied, and returned in resultMessages marked Steered.
func TestAgenticLoop_SteerInjectsAtNextTurnBoundary(t *testing.T) {
executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
mockProvider := &MockProvider{}
loop := NewAgenticLoop(mockProvider, executor, "You are a helper")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
sessionID := "steer-session"
messages := []Message{{Role: "user", Content: "Do something but ask me first"}}
const steerPrompt = "actually check pve2 as well"
// Turn 1: model requests pulse_question (blocks until answered).
mockProvider.On("ChatStream", mock.Anything, mock.MatchedBy(func(req providers.ChatRequest) bool {
return len(req.Messages) == 1
}), mock.Anything).Return(nil).Run(func(args mock.Arguments) {
cb := args.Get(2).(providers.StreamCallback)
toolInput := map[string]interface{}{
"questions": []interface{}{
map[string]interface{}{
"id": "q1",
"type": "select",
"question": "Pick one",
"options": []interface{}{
map[string]interface{}{"label": "A", "value": "a"},
},
},
},
}
cb(providers.StreamEvent{
Type: "tool_start",
Data: providers.ToolStartEvent{ID: "t1", Name: pulseQuestionToolName, Input: toolInput},
})
cb(providers.StreamEvent{
Type: "done",
Data: providers.DoneEvent{
ToolCalls: []providers.ToolCall{{ID: "t1", Name: pulseQuestionToolName, Input: toolInput}},
},
})
}).Once()
// Turn 2: the request must carry the steer as a user message AFTER the
// tool result for t1.
mockProvider.On("ChatStream", mock.Anything, mock.MatchedBy(func(req providers.ChatRequest) bool {
toolResultIndex := -1
steerIndex := -1
for i, m := range req.Messages {
if m.ToolResult != nil && m.ToolResult.ToolUseID == "t1" {
toolResultIndex = i
}
if m.Role == "user" && m.Content == steerPrompt {
steerIndex = i
}
}
return toolResultIndex >= 0 && steerIndex > toolResultIndex
}), mock.Anything).Return(nil).Run(func(args mock.Arguments) {
cb := args.Get(2).(providers.StreamCallback)
cb(providers.StreamEvent{Type: "content", Data: providers.ContentEvent{Text: "Checking pve2 too."}})
cb(providers.StreamEvent{Type: "done", Data: providers.DoneEvent{}})
}).Once()
var (
mu sync.Mutex
questionEvt *QuestionData
steerApplied *SteerAppliedData
)
callback := func(event StreamEvent) {
mu.Lock()
defer mu.Unlock()
if event.Type == "question" && questionEvt == nil {
var qd QuestionData
_ = json.Unmarshal(event.Data, &qd)
questionEvt = &qd
}
if event.Type == "steer_applied" && steerApplied == nil {
var sd SteerAppliedData
_ = json.Unmarshal(event.Data, &sd)
steerApplied = &sd
}
}
var (
results []Message
err error
doneCh = make(chan struct{})
)
go func() {
defer close(doneCh)
results, err = loop.Execute(ctx, sessionID, messages, callback)
}()
require.Eventually(t, func() bool {
mu.Lock()
defer mu.Unlock()
return questionEvt != nil && questionEvt.QuestionID != ""
}, 2*time.Second, 10*time.Millisecond, "expected question event")
// Steer while the run is blocked, then unblock it.
require.NoError(t, loop.Steer(sessionID, Message{
ID: "steer-msg-1", Role: "user", Content: steerPrompt, Steered: true, Timestamp: time.Now(),
}, "client-row-1"))
mu.Lock()
qID := questionEvt.QuestionID
mu.Unlock()
require.NoError(t, loop.AnswerQuestion(qID, []QuestionAnswer{{ID: "q1", Value: "a"}}))
select {
case <-doneCh:
case <-ctx.Done():
t.Fatalf("agentic loop did not complete: %v", ctx.Err())
}
require.NoError(t, err)
mu.Lock()
require.NotNil(t, steerApplied, "expected steer_applied event")
assert.Equal(t, "client-row-1", steerApplied.ClientMessageID)
assert.Equal(t, "steer-msg-1", steerApplied.MessageID)
assert.Equal(t, steerPrompt, steerApplied.Prompt)
mu.Unlock()
steeredCount := 0
for _, msg := range results {
if msg.Steered {
steeredCount++
assert.Equal(t, "user", msg.Role)
assert.Equal(t, steerPrompt, msg.Content)
}
}
assert.Equal(t, 1, steeredCount, "expected exactly one steered message in results")
assert.Equal(t, "Checking pve2 too.", results[len(results)-1].Content)
mockProvider.AssertExpectations(t)
}
// TestAgenticLoop_UnconsumedSteerIsDiscarded verifies that a steer arriving
// too late for any boundary is dropped when the run ends, so the client's
// queue-drain fallback cannot double-record it.
func TestAgenticLoop_UnconsumedSteerIsDiscarded(t *testing.T) {
executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
provider := &stubStreamingProvider{}
loop := NewAgenticLoop(provider, executor, "system")
sessionID := "late-steer-session"
_, err := loop.Execute(context.Background(), sessionID, []Message{{Role: "user", Content: "hi"}}, func(StreamEvent) {})
require.NoError(t, err)
// The run already ended; the loop's defer must have cleared the inbox,
// and a fresh run must not see stale steers from a prior run either.
require.NoError(t, loop.Steer(sessionID, Message{ID: "late", Role: "user", Content: "too late"}, ""))
loop.discardPendingSteers(sessionID)
assert.Empty(t, loop.takePendingSteers(sessionID))
}
// TestAgenticLoop_SteerBacklogIsBounded verifies the inbox cap so a chatty
// client cannot grow the running turn's prompt without limit.
func TestAgenticLoop_SteerBacklogIsBounded(t *testing.T) {
executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
loop := NewAgenticLoop(&stubStreamingProvider{}, executor, "system")
for i := 0; i < maxPendingSteersPerSession; i++ {
require.NoError(t, loop.Steer("backlog-session", Message{ID: "m", Role: "user", Content: "steer"}, ""))
}
err := loop.Steer("backlog-session", Message{ID: "m", Role: "user", Content: "one too many"}, "")
require.ErrorIs(t, err, errSteerBacklogFull)
assert.Len(t, loop.takePendingSteers("backlog-session"), maxPendingSteersPerSession)
}
// TestService_SteerSession_RoutingOutcomes covers the service-level routing
// results that never reach a loop.
func TestService_SteerSession_RoutingOutcomes(t *testing.T) {
svc := &Service{}
result, err := svc.SteerSession(context.Background(), "no-run-session", SessionSteerRequest{Prompt: "hello"})
require.NoError(t, err)
assert.False(t, result.Accepted)
assert.Equal(t, "no_active_run", result.Reason)
result, err = svc.SteerSession(context.Background(), "patrol-main", SessionSteerRequest{Prompt: "hello"})
require.NoError(t, err)
assert.False(t, result.Accepted)
assert.Equal(t, "system_session", result.Reason)
result, err = svc.SteerSession(context.Background(), "some-session", SessionSteerRequest{Prompt: " "})
require.NoError(t, err)
assert.False(t, result.Accepted)
assert.Equal(t, "empty_prompt", result.Reason)
_, err = svc.SteerSession(context.Background(), "../bad", SessionSteerRequest{Prompt: "hello"})
require.Error(t, err)
}
// TestService_ExecuteStream_SteeredMessagePersists proves the end-to-end
// path: a steer accepted mid-run is injected at the boundary AND survives
// the end-of-run save (which skips ordinary user messages).
func TestService_ExecuteStream_SteeredMessagePersists(t *testing.T) {
tmpDir := t.TempDir()
store, err := NewSessionStore(tmpDir)
require.NoError(t, err)
executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
mockProvider := &MockProvider{}
loop := NewAgenticLoop(mockProvider, executor, "system")
svc := &Service{
cfg: &config.AIConfig{ChatModel: "openai:test"},
sessions: store,
executor: executor,
agenticLoop: loop,
provider: mockProvider,
started: true,
activeExecutions: make(map[string]map[*AgenticLoop]struct{}),
questionExecutions: make(map[string]*AgenticLoop),
}
const steerPrompt = "steer: also check the replication lag"
sessionID := "sess-steer-persist"
// Turn 1: block on a question; the test steers through the SERVICE while
// blocked, then answers.
toolInput := map[string]interface{}{
"questions": []interface{}{
map[string]interface{}{
"id": "q1", "type": "select", "question": "Proceed?",
"options": []interface{}{map[string]interface{}{"label": "Yes", "value": "y"}},
},
},
}
mockProvider.On("ChatStream", mock.Anything, mock.MatchedBy(func(req providers.ChatRequest) bool {
for _, m := range req.Messages {
if m.Role == "user" && m.Content == steerPrompt {
return false
}
}
return true
}), mock.Anything).Return(nil).Run(func(args mock.Arguments) {
cb := args.Get(2).(providers.StreamCallback)
cb(providers.StreamEvent{
Type: "tool_start",
Data: providers.ToolStartEvent{ID: "t1", Name: pulseQuestionToolName, Input: toolInput},
})
cb(providers.StreamEvent{
Type: "done",
Data: providers.DoneEvent{
ToolCalls: []providers.ToolCall{{ID: "t1", Name: pulseQuestionToolName, Input: toolInput}},
},
})
}).Once()
mockProvider.On("ChatStream", mock.Anything, mock.MatchedBy(func(req providers.ChatRequest) bool {
for _, m := range req.Messages {
if m.Role == "user" && m.Content == steerPrompt {
return true
}
}
return false
}), mock.Anything).Return(nil).Run(func(args mock.Arguments) {
cb := args.Get(2).(providers.StreamCallback)
cb(providers.StreamEvent{Type: "content", Data: providers.ContentEvent{Text: "done, checked lag"}})
cb(providers.StreamEvent{Type: "done", Data: providers.DoneEvent{}})
}).Once()
var (
mu sync.Mutex
questionEvt *QuestionData
)
callback := func(event StreamEvent) {
mu.Lock()
defer mu.Unlock()
if event.Type == "question" && questionEvt == nil {
var qd QuestionData
_ = json.Unmarshal(event.Data, &qd)
questionEvt = &qd
}
}
var execErr error
doneCh := make(chan struct{})
go func() {
defer close(doneCh)
execErr = svc.ExecuteStream(context.Background(), ExecuteRequest{SessionID: sessionID, Prompt: "check the cluster"}, callback)
}()
require.Eventually(t, func() bool {
mu.Lock()
defer mu.Unlock()
return questionEvt != nil && questionEvt.QuestionID != ""
}, 3*time.Second, 10*time.Millisecond, "expected question event")
steerResult, err := svc.SteerSession(context.Background(), sessionID, SessionSteerRequest{
Prompt: steerPrompt,
ClientMessageID: "client-row-9",
})
require.NoError(t, err)
require.True(t, steerResult.Accepted, "expected steer to reach the active loop, got reason %q", steerResult.Reason)
mu.Lock()
qID := questionEvt.QuestionID
mu.Unlock()
require.NoError(t, svc.AnswerQuestion(context.Background(), qID, []QuestionAnswer{{ID: "q1", Value: "y"}}))
select {
case <-doneCh:
case <-time.After(5 * time.Second):
t.Fatal("ExecuteStream did not complete")
}
require.NoError(t, execErr)
// The steered message must be in durable history, after the opening
// prompt and before the final assistant answer.
saved, err := store.GetMessages(sessionID)
require.NoError(t, err)
steerIndex, finalIndex := -1, -1
for i, msg := range saved {
if msg.Steered && msg.Content == steerPrompt {
steerIndex = i
}
if msg.Role == "assistant" && msg.Content == "done, checked lag" {
finalIndex = i
}
}
require.GreaterOrEqual(t, steerIndex, 1, "steered message missing from durable history")
require.Greater(t, finalIndex, steerIndex, "final answer should follow the steered message")
mockProvider.AssertExpectations(t)
}
+56 -2
View File
@@ -3,6 +3,7 @@ package chat
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
@@ -267,6 +268,58 @@ func (s *Service) unregisterActiveLoop(sessionID string, loop *AgenticLoop) {
}
}
// SteerSession routes a mid-turn steering message to the session's running
// agentic loop for injection at the next turn boundary. accepted=false with
// a reason is a normal outcome: the caller falls back to the ordinary
// follow-up queue. Steering carries prompt text only — it cannot change the
// model route, control level, or autonomous mode of the running turn.
func (s *Service) SteerSession(ctx context.Context, sessionID string, req SessionSteerRequest) (*SessionSteerResult, error) {
sessionID = strings.TrimSpace(sessionID)
if err := validateSessionID(sessionID); err != nil {
return nil, err
}
result := &SessionSteerResult{SessionID: sessionID}
if IsSystemSessionID(sessionID) {
result.Reason = "system_session"
return result, nil
}
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
result.Reason = "empty_prompt"
return result, nil
}
s.activeMu.RLock()
var loops []*AgenticLoop
for loop := range s.activeExecutions[sessionID] {
loops = append(loops, loop)
}
s.activeMu.RUnlock()
if len(loops) == 0 {
result.Reason = "no_active_run"
return result, nil
}
msg := Message{
ID: uuid.New().String(),
Role: "user",
Content: prompt,
Steered: true,
Timestamp: time.Now(),
}
for _, loop := range loops {
if err := loop.Steer(sessionID, msg, req.ClientMessageID); err != nil {
if errors.Is(err, errSteerBacklogFull) {
result.Reason = "steer_backlog"
return result, nil
}
return nil, err
}
}
result.Accepted = true
return result, nil
}
func assistantContextScopeForChatTurn(
req ExecuteRequest,
handoffFindingID string,
@@ -996,8 +1049,9 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac
// Save result messages
for _, msg := range resultMessages {
// Skip user messages (already saved)
if msg.Role == "user" && msg.ToolResult == nil {
// Skip user messages (already saved) — except steered ones, which
// entered the conversation inside the loop and exist nowhere else.
if msg.Role == "user" && msg.ToolResult == nil && !msg.Steered {
continue
}
if msg.Role == "assistant" && strings.TrimSpace(msg.Model) == "" {
+33 -1
View File
@@ -94,7 +94,12 @@ type Message struct {
ToolCalls []ToolCall `json:"tool_calls"`
ToolResult *ToolResult `json:"tool_result,omitempty"`
Model string `json:"model,omitempty"`
Timestamp time.Time `json:"timestamp"`
// Steered marks a user message that was injected into a running agentic
// loop at a turn boundary (mid-turn steering) rather than opening a new
// turn. Steered user messages are persisted by the end-of-run save,
// unlike the turn-opening user prompt which is saved before the loop.
Steered bool `json:"steered,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
func EmptyMessage() Message {
@@ -457,6 +462,33 @@ type ErrorData struct {
Message string `json:"message"`
}
// SteerAppliedData is the data for "steer_applied" events: a steering
// message was injected into the running loop at a turn boundary. The
// originating client reconciles its pending row via ClientMessageID; other
// clients on the same session render Prompt as a new steered user row.
type SteerAppliedData struct {
SessionID string `json:"session_id,omitempty"`
MessageID string `json:"message_id,omitempty"`
ClientMessageID string `json:"client_message_id,omitempty"`
Prompt string `json:"prompt,omitempty"`
Turn int `json:"turn"`
}
// SessionSteerRequest is the payload for POST /api/ai/sessions/{id}/steer.
type SessionSteerRequest struct {
Prompt string `json:"prompt"`
ClientMessageID string `json:"client_message_id,omitempty"`
}
// SessionSteerResult reports whether a steering message reached a running
// loop. accepted=false with a reason is a normal outcome (e.g. the run
// finished first); the client falls back to the ordinary queue drain.
type SessionSteerResult struct {
Accepted bool `json:"accepted"`
SessionID string `json:"session_id"`
Reason string `json:"reason,omitempty"` // "no_active_run" | "system_session" | "empty_prompt" | "steer_backlog"
}
// DoneData is the data for "done" events
type DoneData struct {
SessionID string `json:"session_id,omitempty"`
+38
View File
@@ -61,6 +61,7 @@ type AIService interface {
ForkSession(ctx context.Context, sessionID string) (*chat.Session, error)
UndoLastTurn(ctx context.Context, sessionID string, opts chat.SessionTurnUndoOptions) (*chat.SessionTurnUndoResult, error)
RedoLastTurn(ctx context.Context, sessionID string) (*chat.SessionTurnRedoResult, error)
SteerSession(ctx context.Context, sessionID string, req chat.SessionSteerRequest) (*chat.SessionSteerResult, error)
AnswerQuestion(ctx context.Context, questionID string, answers []chat.QuestionAnswer) error
AssistantSurfaceToolContract(ctx context.Context) agentcapabilities.SurfaceToolContract
SetAlertProvider(provider chat.AssistantAlertProvider)
@@ -3264,6 +3265,43 @@ func (h *AIHandler) HandleRedoLastTurn(w http.ResponseWriter, r *http.Request, s
json.NewEncoder(w).Encode(result)
}
// HandleSteerSession handles POST /api/ai/sessions/{id}/steer.
// It routes a mid-turn steering message to the session's running agentic
// loop; accepted=false with a reason is a normal outcome and the client
// falls back to the ordinary follow-up queue.
func (h *AIHandler) HandleSteerSession(w http.ResponseWriter, r *http.Request, sessionID string) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ctx := r.Context()
if !h.IsRunning(ctx) {
http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable)
return
}
svc := h.GetService(ctx)
if svc == nil {
http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable)
return
}
var steerReq chat.SessionSteerRequest
if err := json.NewDecoder(r.Body).Decode(&steerReq); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
result, err := svc.SteerSession(ctx, sessionID, steerReq)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
// HandleRevert handles POST /api/ai/sessions/{id}/revert
// Rejects OpenCode-style file revert requests; Pulse actions use governed history.
func (h *AIHandler) HandleRevert(w http.ResponseWriter, r *http.Request, sessionID string) {
@@ -66,6 +66,9 @@ func (s *capturingAIService) ForkSession(ctx context.Context, sessionID string)
func (s *capturingAIService) UndoLastTurn(ctx context.Context, sessionID string, opts chat.SessionTurnUndoOptions) (*chat.SessionTurnUndoResult, error) {
return &chat.SessionTurnUndoResult{Success: true, SessionID: sessionID}, nil
}
func (s *capturingAIService) SteerSession(ctx context.Context, sessionID string, req chat.SessionSteerRequest) (*chat.SessionSteerResult, error) {
return &chat.SessionSteerResult{Accepted: false, SessionID: sessionID, Reason: "no_active_run"}, nil
}
func (s *capturingAIService) RedoLastTurn(ctx context.Context, sessionID string) (*chat.SessionTurnRedoResult, error) {
return &chat.SessionTurnRedoResult{Success: true, SessionID: sessionID}, nil
}
+8
View File
@@ -197,6 +197,14 @@ func (m *MockAIService) UndoLastTurn(ctx context.Context, sessionID string, opts
return args.Get(0).(*chat.SessionTurnUndoResult), args.Error(1)
}
func (m *MockAIService) SteerSession(ctx context.Context, sessionID string, req chat.SessionSteerRequest) (*chat.SessionSteerResult, error) {
args := m.Called(ctx, sessionID, req)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*chat.SessionSteerResult), args.Error(1)
}
func (m *MockAIService) RedoLastTurn(ctx context.Context, sessionID string) (*chat.SessionTurnRedoResult, error) {
args := m.Called(ctx, sessionID)
if args.Get(0) == nil {
+5
View File
@@ -1004,6 +1004,11 @@ func (r *Router) routeAISessions(w http.ResponseWriter, req *http.Request) {
return
}
r.aiHandler.HandleRedoLastTurn(w, req, sessionID)
case "steer":
if !ensureScope(w, req, config.ScopeAIChat) {
return
}
r.aiHandler.HandleSteerSession(w, req, sessionID)
case "revert":
if !ensureScope(w, req, config.ScopeAIChat) {
return
@@ -252,6 +252,52 @@ func TestRouteAISessions_UndoLastTurnRetryOptions(t *testing.T) {
mockSvc.AssertExpectations(t)
}
func TestRouteAISessions_SteerSession(t *testing.T) {
mockSvc := &MockAIService{}
mockSvc.On("IsRunning").Return(true)
mockSvc.On("SteerSession", mock.Anything, "session-1", chat.SessionSteerRequest{
Prompt: "also check pve2",
ClientMessageID: "client-row-1",
}).Return(&chat.SessionSteerResult{
Accepted: true,
SessionID: "session-1",
}, nil)
handler := &AIHandler{}
setUnexportedField(t, handler, "defaultService", mockSvc)
router := &Router{aiHandler: handler}
body := strings.NewReader(`{"prompt":"also check pve2","client_message_id":"client-row-1"}`)
req := httptest.NewRequest(http.MethodPost, "/api/ai/sessions/session-1/steer", body)
rec := httptest.NewRecorder()
router.routeAISessions(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
if !strings.Contains(rec.Body.String(), `"accepted":true`) {
t.Fatalf("expected accepted result, got %s", rec.Body.String())
}
mockSvc.AssertExpectations(t)
}
func TestRouteAISessions_SteerSessionRejectsNonPost(t *testing.T) {
mockSvc := &MockAIService{}
handler := &AIHandler{}
setUnexportedField(t, handler, "defaultService", mockSvc)
router := &Router{aiHandler: handler}
req := httptest.NewRequest(http.MethodGet, "/api/ai/sessions/session-1/steer", nil)
rec := httptest.NewRecorder()
router.routeAISessions(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestRouteAISessions_RedoLastTurn(t *testing.T) {
mockSvc := &MockAIService{}
mockSvc.On("IsRunning").Return(true)
+2
View File
@@ -52,6 +52,7 @@ func main() {
reflect.TypeOf(chat.QuestionData{}),
reflect.TypeOf(chat.Question{}),
reflect.TypeOf(chat.QuestionOption{}),
reflect.TypeOf(chat.SteerAppliedData{}),
reflect.TypeOf(chat.DoneData{}),
reflect.TypeOf(chat.ErrorData{}),
}
@@ -155,6 +156,7 @@ func chatStreamEventUnion() string {
// The contract test covers {question_id, questions}; the UI currently expects session_id too.
// Keep session_id optional for backward compatibility.
buf.WriteString(" | { type: 'question'; data: QuestionData & { session_id?: string } }\n")
buf.WriteString(" | { type: 'steer_applied'; data: SteerAppliedData }\n")
buf.WriteString(" | { type: 'done'; data?: DoneData }\n")
buf.WriteString(" | { type: 'error'; data: ErrorData };\n")
return buf.String()