fix: publish and replace Claude goals reliably (#967)

## Summary

- publish optimistic provider-neutral goal snapshots for submitted and
steered goal commands
- advertise and implement `set`, using steering for an active turn and
an ordinary prompt while idle
- suppress stale old-goal runtime updates while replacement/clear is
pending and roll back failed commands
- keep cancellation turn-scoped and orphan-account pending steers so
stale results cannot settle the next prompt
- reject unsupported pause/resume controls without host-side fallbacks

## Validation

- `npm test -- --run` (702 passed, 20 skipped)
- `npm run check`
- `npm run build`
- regression coverage for idle/active set and clear, stale runtime
updates, command failure rollback, cancellation, and stale steer
isolation
This commit is contained in:
nikita-ashihmin
2026-08-07 17:01:18 +04:00
committed by GitHub
parent 133337ffe5
commit f8fd3ab822
4 changed files with 671 additions and 24 deletions

View File

@@ -52,4 +52,6 @@ This separation prevents a persistent goal from monopolizing the session's promp
Claude's session-scoped `/goal` command installs a Stop hook. The runtime emits internal `active_goal` messages when that hook updates or clears its state; the adapter maps them into the neutral snapshot before the exhaustive SDK message switch. `condition`, `iterations`, `set_at`, and `last_reason` become `objective`, `iterations`, `createdAt`, and `lastReason`. Provider-only bookkeeping such as `tokens_at_start` is not exposed.
Only `clear` is advertised for out-of-band control. The adapter submits `/goal clear` through the existing session prompt queue, preserving the same ordering and lifecycle rules as other Claude commands. Setting a goal remains available through `/goal <condition>` and is not advertised as an extension action because it starts Claude's long-running Stop-hook workflow.
Some Claude runtime versions persist the goal and begin the Stop-hook workflow without emitting the initial `active_goal` message on the SDK stream. To avoid leaving clients without visible state for the whole long-running prompt, the adapter publishes a minimal active snapshot as soon as it submits `/goal <condition>`, and publishes `goal: null` for `/goal clear`. While that command is pending, late updates for the previous goal are ignored. A matching runtime `active_goal` remains authoritative and enriches the optimistic snapshot with iteration, reason, and timestamp fields; a failed command restores the previously published state.
`set` and `clear` are advertised for out-of-band control; pause and resume are not. When a goal turn is running, the adapter injects the corresponding `/goal` command at steering priority `now` so it cannot remain queued behind the Stop-hook workflow; on an idle session it uses the ordinary prompt lifecycle. Cancelling a turn does not clear the session-scoped goal: clients use the goal control method when they intend to replace or remove it.

View File

@@ -92,6 +92,8 @@ import {
GoalCapability,
GoalRequest,
GoalControlResponse,
GoalSnapshot,
goalUpdateFromPrompt,
parseGoalRequest,
toGoalSnapshot,
} from "./goal-extension.js";
@@ -406,6 +408,20 @@ type Session = {
/** The turn whose messages the consumer is currently attributing output to
* (the head of `turnQueue` once its user message has been echoed). */
activeTurn?: Turn | null;
/** Optimistic goal state published for a submitted `/goal` command whose
* matching runtime update has not arrived yet. Runtime updates for the old
* goal are suppressed until this command is echoed or completes, otherwise
* a late old-goal update can overwrite a replacement that the runtime never
* announces (the compatibility case the optimistic update exists for). */
pendingGoalUpdate?: {
commandUuid: string;
expected: GoalSnapshot | null;
previous: GoalSnapshot | null | undefined;
started: boolean;
};
/** Last goal snapshot sent to the ACP client, used to roll back an
* optimistic `/goal` update when the command itself fails. */
lastPublishedGoal?: GoalSnapshot | null;
/** Count of result messages the consumer should treat as orphans and skip
* (not promote/attribute to the current head). When cancel() settles+removes
* a queued turn, that turn's user message was already pushed to the SDK, so
@@ -1907,17 +1923,74 @@ export class ClaudeAcpAgent {
session.turnQueue.push(turn);
session.input.push(userMessage);
this.ensureConsumer(session, params.sessionId);
await this.publishGoalFromPrompt(params.sessionId, firstText, promptUuid);
return response;
}
async goal(params: GoalRequest): Promise<GoalControlResponse> {
await this.prompt({
const command = params.action === "set" ? `/goal ${params.objective}` : "/goal clear";
const prompt = [{ type: "text" as const, text: command }];
const steering = await this.steer({
sessionId: params.sessionId,
prompt: [{ type: "text", text: "/goal clear" }],
prompt,
_meta: { steering: { idleBehavior: "promptRequired" } },
});
if (steering.outcome === "promptRequired") {
await this.prompt({ sessionId: params.sessionId, prompt });
}
return {};
}
private async publishGoal(sessionId: string, goal: GoalSnapshot | null): Promise<void> {
const session = this.sessions[sessionId];
if (session) {
session.lastPublishedGoal = goal;
}
await this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "session_info_update",
_meta: { goal },
},
});
}
private async publishGoalFromPrompt(
sessionId: string,
prompt: string,
commandUuid: string,
): Promise<void> {
const goalUpdate = goalUpdateFromPrompt(prompt);
if (goalUpdate !== undefined) {
const session = this.sessions[sessionId];
if (session) {
session.pendingGoalUpdate = {
commandUuid,
expected: goalUpdate,
previous: session.lastPublishedGoal,
started: false,
};
}
await this.publishGoal(sessionId, goalUpdate);
}
}
private async publishRuntimeGoal(sessionId: string, goal: GoalSnapshot | null): Promise<void> {
const session = this.sessions[sessionId];
const pending = session?.pendingGoalUpdate;
if (pending) {
const matchesPending =
pending.expected === null
? goal === null
: goal !== null && goal.objective === pending.expected.objective;
if (!matchesPending) {
return;
}
session.pendingGoalUpdate = undefined;
}
await this.publishGoal(sessionId, goal);
}
/** Steer the session per the ACP steering wire protocol: inject a follow-up
* message into the turn that is currently running. If that turn already
* settled, the established default starts a new detached turn; Hosts may opt
@@ -2001,6 +2074,8 @@ export class ClaudeAcpAgent {
turnInFlight.deferredSettle = undefined;
}
session.input.push(userMessage);
const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
await this.publishGoalFromPrompt(sessionId, firstText, steeredUuid);
return { outcome: "injected" };
}
@@ -2664,13 +2739,7 @@ export class ClaudeAcpAgent {
// exhaustive switch and publish only the provider-neutral ACP shape.
if ((message as { type: string }).type === "active_goal") {
const activeGoal = message as unknown as SDKActiveGoalMessage;
await this.client.sessionUpdate({
sessionId: params.sessionId,
update: {
sessionUpdate: "session_info_update",
_meta: { goal: toGoalSnapshot(activeGoal) },
},
});
await this.publishRuntimeGoal(params.sessionId, toGoalSnapshot(activeGoal));
continue;
}
@@ -3270,6 +3339,21 @@ export class ClaudeAcpAgent {
if (!isAutonomousResult) {
recordResultForOrphanCommands();
ensureActiveTurn();
// Once the submitted goal command has produced its own result,
// no older runtime update can still precede it in the ordered
// SDK stream. Stop suppressing updates even when this runtime
// omitted the matching active_goal notification entirely.
if (session.pendingGoalUpdate?.started) {
const pendingGoalUpdate = session.pendingGoalUpdate;
session.pendingGoalUpdate = undefined;
const goalCommandFailed =
message.is_error ||
message.stop_reason === "refusal" ||
("result" in message && message.result.includes("Please run /login"));
if (goalCommandFailed) {
await this.publishGoal(params.sessionId, pendingGoalUpdate.previous ?? null);
}
}
}
// A result closes the stretch of output it terminates: snapshot
@@ -3468,6 +3552,20 @@ export class ClaudeAcpAgent {
break;
}
// A priority:'now' steer can make the SDK terminate the
// interrupted cycle with an error-shaped diagnostic result
// before it replays the injected message's echo. That result is
// not the steered command's outcome: keep it on the steer lane
// and let the cycle after the pending echo decide the turn.
if (
message.is_error &&
isSteering(session.activeTurn) &&
session.activeTurn.steeredEchoes.size > 0
) {
settleOrDefer({ stopReason: "end_turn", usage: sessionUsage(session) });
break;
}
switch (message.subtype) {
case "success": {
if (message.result.includes("Please run /login")) {
@@ -3740,6 +3838,9 @@ export class ClaudeAcpAgent {
// is still promoted — activateTurn() clears the flag. The turn's own
// echo is then dropped from the feed (the client already shows it).
if (message.type === "user" && "uuid" in message && message.uuid) {
if (session.pendingGoalUpdate?.commandUuid === message.uuid) {
session.pendingGoalUpdate.started = true;
}
const queued = findUnsettledTurn(message.uuid);
if (queued) {
// Only (re)activate if this isn't already the active turn — a
@@ -4175,6 +4276,14 @@ export class ClaudeAcpAgent {
return;
}
session.cancelled = true;
// A priority steer may still be queued in the SDK when cancellation
// settles its owning turn. Its later echo matches no live turn, and its
// result must be skipped rather than promoted onto the next prompt.
if (isSteering(session.activeTurn)) {
for (const uuid of session.activeTurn.steeredEchoes) {
this.trackOrphanCommand(session, uuid, "pending");
}
}
// Capture the orphan-accounting lane before anything can await: the
// consumer latches msgLifecycleV1 when it drains the first system/init,
// which can happen DURING the awaited interrupt() below — the receipt

View File

@@ -4,7 +4,7 @@ import type { SDKActiveGoalMessage } from "@anthropic-ai/claude-agent-sdk";
export const GOAL_EXTENSION_VERSION = 1 as const;
export const GOAL_CONTROL_METHOD = "_session/goal";
export const GOAL_ACTIONS = ["clear"] as const;
export const GOAL_ACTIONS = ["set", "clear"] as const;
export type GoalAction = (typeof GOAL_ACTIONS)[number];
export type GoalCapability = {
@@ -28,25 +28,44 @@ export type GoalSnapshot = {
controlMethod: typeof GOAL_CONTROL_METHOD;
};
export type GoalRequest = {
sessionId: string;
action: GoalAction;
};
export type GoalRequest =
{ sessionId: string; action: "clear" } | { sessionId: string; action: "set"; objective: string };
export type GoalControlResponse = Record<string, never>;
export function goalUpdateFromPrompt(prompt: string): GoalSnapshot | null | undefined {
const match = /^\/goal(?:\s+([\s\S]*))?$/.exec(prompt);
const argument = match?.[1]?.trim();
if (!argument) {
return undefined;
}
if (argument === "clear") {
return null;
}
return {
objective: argument,
status: "active",
controlMethod: GOAL_CONTROL_METHOD,
};
}
export function parseGoalRequest(params: unknown): GoalRequest {
if (!params || typeof params !== "object") {
throw RequestError.invalidParams(undefined, "goal params must be an object");
}
const { sessionId, action } = params as Record<string, unknown>;
const { sessionId, action, objective } = params as Record<string, unknown>;
if (typeof sessionId !== "string" || sessionId.length === 0) {
throw RequestError.invalidParams(undefined, "goal params require a non-empty sessionId");
}
if (!GOAL_ACTIONS.includes(action as GoalAction)) {
throw RequestError.invalidParams(undefined, 'goal action must be "clear"');
throw RequestError.invalidParams(undefined, 'goal action must be "set" or "clear"');
}
return { sessionId, action: action as GoalAction };
if (action === "set" && (typeof objective !== "string" || objective.trim().length === 0)) {
throw RequestError.invalidParams(undefined, 'goal action "set" requires a non-empty objective');
}
return action === "set"
? { sessionId, action, objective: (objective as string).trim() }
: { sessionId, action: "clear" };
}
export function toGoalSnapshot(message: SDKActiveGoalMessage): GoalSnapshot | null {

View File

@@ -54,7 +54,12 @@ import {
SDKAssistantMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { randomUUID } from "crypto";
import { GOAL_CONTROL_METHOD, parseGoalRequest, toGoalSnapshot } from "../goal-extension.js";
import {
GOAL_CONTROL_METHOD,
goalUpdateFromPrompt,
parseGoalRequest,
toGoalSnapshot,
} from "../goal-extension.js";
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
@@ -3364,6 +3369,125 @@ describe("stop reason propagation", () => {
);
});
it("publishes a submitted goal before a long-running command returns without active_goal", async () => {
const updates: any[] = [];
let releaseResult!: () => void;
const resultGate = new Promise<void>((resolve) => (releaseResult = resolve));
const mockClient = {
sessionUpdate: async (notification: any) => updates.push(notification),
} as unknown as AcpClient;
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
injectGeneratorSession(agent, (input) => {
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
const { value: userMessage } = await iter.next();
yield userEcho(userMessage);
await resultGate;
yield createResultMessage({ subtype: "success", stop_reason: "end_turn", is_error: false });
yield { type: "system", subtype: "session_state_changed", state: "idle" };
}
return messageGenerator();
});
const response = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "/goal Finish the migration " }],
});
await vi.waitFor(() => {
expect(updates).toContainEqual({
sessionId: "test-session",
update: {
sessionUpdate: "session_info_update",
_meta: {
goal: {
objective: "Finish the migration",
status: "active",
controlMethod: GOAL_CONTROL_METHOD,
},
},
},
});
});
releaseResult();
await expect(response).resolves.toEqual(expect.objectContaining({ stopReason: "end_turn" }));
});
it("rolls back an optimistic goal update when the goal command fails", async () => {
const updates: any[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: any) => updates.push(notification),
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
injectGeneratorSession(agent, (input) => {
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
const { value: original } = await iter.next();
yield userEcho(original);
yield {
type: "active_goal",
value: {
condition: "Original",
iterations: 2,
set_at: 1710000000000,
tokens_at_start: 0,
},
uuid: randomUUID(),
session_id: "test-session",
};
yield createResultMessage({
subtype: "success",
stop_reason: "end_turn",
is_error: false,
});
yield { type: "system", subtype: "session_state_changed", state: "idle" };
const { value: replacement } = await iter.next();
yield userEcho(replacement);
yield createResultMessage({
subtype: "success",
stop_reason: null,
is_error: true,
result: "goal rejected",
});
yield { type: "system", subtype: "session_state_changed", state: "idle" };
}
return messageGenerator();
});
await expect(
agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "/goal Original" }],
}),
).resolves.toEqual(expect.objectContaining({ stopReason: "end_turn" }));
await expect(
agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "/goal Replacement" }],
}),
).rejects.toBeDefined();
const goalUpdates = updates
.map(({ update }) => update._meta?.goal)
.filter((goal) => goal !== undefined);
expect(goalUpdates.at(-2)).toEqual(
expect.objectContaining({ objective: "Replacement", status: "active" }),
);
expect(goalUpdates.at(-1)).toEqual(
expect.objectContaining({ objective: "Original", iterations: 2 }),
);
});
it("derives only state-changing goal slash commands", () => {
expect(goalUpdateFromPrompt("/goal clear")).toBeNull();
expect(goalUpdateFromPrompt("/goal")).toBeUndefined();
expect(goalUpdateFromPrompt("explain /goal clear")).toBeUndefined();
expect(goalUpdateFromPrompt(" /goal Finish the migration")).toBeUndefined();
});
it("maps the SDK goal shape without exposing provider fields", () => {
expect(
toGoalSnapshot({
@@ -9996,29 +10120,306 @@ describe("turn steering (_session/steering)", () => {
expect((response._meta as any)?.goal).toEqual({
version: 1,
controlMethod: GOAL_CONTROL_METHOD,
actions: ["clear"],
actions: ["set", "clear"],
});
});
it("submits clear through the session prompt queue", async () => {
it("submits set and clear through the session prompt queue when the session is idle", async () => {
const agent = createMockAgent();
const prompt = vi.spyOn(agent, "prompt").mockResolvedValue({ stopReason: "end_turn" });
agent.sessions["test-session"] = mockSessionState({
input: new Pushable<any>(),
turnQueue: [],
});
await expect(
agent.goal({ sessionId: "test-session", action: "set", objective: "Replacement" }),
).resolves.toEqual({});
await expect(agent.goal({ sessionId: "test-session", action: "clear" })).resolves.toEqual({});
expect(prompt).toHaveBeenCalledWith({
expect(prompt).toHaveBeenNthCalledWith(1, {
sessionId: "test-session",
prompt: [{ type: "text", text: "/goal Replacement" }],
});
expect(prompt).toHaveBeenNthCalledWith(2, {
sessionId: "test-session",
prompt: [{ type: "text", text: "/goal clear" }],
});
});
it("injects clear into a running goal at priority 'now' and publishes removal", async () => {
const updates: any[] = [];
const captured: any[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: any) => updates.push(notification),
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
injectGeneratorSession(agent, (input) => {
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
const goal = await iter.next();
yield userEcho(goal.value);
const clear = await iter.next();
captured.push(clear.value);
yield {
type: "active_goal",
value: {
condition: "Keep working",
iterations: 2,
set_at: 1710000000000,
tokens_at_start: 0,
},
uuid: randomUUID(),
session_id: "test-session",
};
yield createResultMessage();
yield userEcho(clear.value);
yield createResultMessage();
yield { type: "system", subtype: "session_state_changed", state: "idle" };
}
return messageGenerator();
});
const runningGoal = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "/goal Keep working" }],
});
await waitFor(() => !!agent.sessions["test-session"]?.activeTurn);
await expect(agent.goal({ sessionId: "test-session", action: "clear" })).resolves.toEqual({});
expect(captured).toHaveLength(1);
expect(captured[0].priority).toBe("now");
expect(JSON.stringify(captured[0].message.content)).toContain("/goal clear");
expect(updates).toContainEqual({
sessionId: "test-session",
update: {
sessionUpdate: "session_info_update",
_meta: { goal: null },
},
});
await expect(runningGoal).resolves.toEqual(expect.objectContaining({ stopReason: "end_turn" }));
const goalUpdates = updates
.map(({ update }) => update._meta?.goal)
.filter((goal) => goal !== undefined);
const clearIndex = goalUpdates.findIndex((goal) => goal === null);
expect(clearIndex).toBeGreaterThanOrEqual(0);
expect(goalUpdates.slice(clearIndex + 1)).not.toContainEqual(
expect.objectContaining({ objective: "Keep working" }),
);
});
it("replaces a running goal through the goal extension and publishes it immediately", async () => {
const updates: any[] = [];
const captured: any[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: any) => updates.push(notification),
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
injectGeneratorSession(agent, (input) => {
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
const goal = await iter.next();
yield userEcho(goal.value);
const replacement = await iter.next();
captured.push(replacement.value);
yield createResultMessage();
yield userEcho(replacement.value);
yield createResultMessage();
yield { type: "system", subtype: "session_state_changed", state: "idle" };
}
return messageGenerator();
});
const runningGoal = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "/goal Keep working" }],
});
await waitFor(() => !!agent.sessions["test-session"]?.activeTurn);
await expect(
agent.goal({ sessionId: "test-session", action: "set", objective: "Replacement" }),
).resolves.toEqual({});
expect(captured).toHaveLength(1);
expect(captured[0].priority).toBe("now");
expect(JSON.stringify(captured[0].message.content)).toContain("/goal Replacement");
expect(updates).toContainEqual({
sessionId: "test-session",
update: {
sessionUpdate: "session_info_update",
_meta: {
goal: {
objective: "Replacement",
status: "active",
controlMethod: GOAL_CONTROL_METHOD,
},
},
},
});
await expect(runningGoal).resolves.toEqual(expect.objectContaining({ stopReason: "end_turn" }));
});
it("does not let a stale runtime update overwrite an optimistic goal replacement", async () => {
const updates: any[] = [];
let staleUpdateEmitted!: () => void;
const staleUpdate = new Promise<void>((resolve) => (staleUpdateEmitted = resolve));
let releaseReplacement!: () => void;
const replacementGate = new Promise<void>((resolve) => (releaseReplacement = resolve));
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: any) => updates.push(notification),
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
injectGeneratorSession(agent, (input) => {
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
const original = await iter.next();
yield userEcho(original.value);
yield {
type: "active_goal",
value: {
condition: "Original",
iterations: 1,
set_at: 1710000000000,
tokens_at_start: 0,
},
uuid: randomUUID(),
session_id: "test-session",
};
const replacement = await iter.next();
yield {
type: "active_goal",
value: {
condition: "Original",
iterations: 2,
set_at: 1710000000000,
tokens_at_start: 0,
},
uuid: randomUUID(),
session_id: "test-session",
};
staleUpdateEmitted();
await replacementGate;
yield userEcho(replacement.value);
yield {
type: "active_goal",
value: {
condition: "Replacement",
iterations: 1,
set_at: 1710000001000,
tokens_at_start: 0,
},
uuid: randomUUID(),
session_id: "test-session",
};
yield createResultMessage();
yield idleMessage();
}
return messageGenerator();
});
const runningGoal = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "/goal Original" }],
});
await waitFor(() => !!agent.sessions["test-session"]?.activeTurn);
await agent.goal({ sessionId: "test-session", action: "set", objective: "Replacement" });
await staleUpdate;
const goalUpdates = updates
.map(({ update }) => update._meta?.goal)
.filter((goal) => goal !== undefined);
const replacementIndex = goalUpdates.findIndex((goal) => goal?.objective === "Replacement");
expect(replacementIndex).toBeGreaterThanOrEqual(0);
expect(goalUpdates.slice(replacementIndex + 1)).not.toContainEqual(
expect.objectContaining({ objective: "Original" }),
);
releaseReplacement();
await expect(runningGoal).resolves.toEqual(expect.objectContaining({ stopReason: "end_turn" }));
expect(goalUpdates).not.toContainEqual(
expect.objectContaining({ objective: "Replacement", iterations: 1 }),
);
await vi.waitFor(() => {
expect(updates).toContainEqual({
sessionId: "test-session",
update: {
sessionUpdate: "session_info_update",
_meta: {
goal: expect.objectContaining({ objective: "Replacement", iterations: 1 }),
},
},
});
});
});
it("cancels the running turn without clearing the persistent goal", async () => {
const updates: any[] = [];
let releaseAfterCancel!: () => void;
const afterCancel = new Promise<void>((resolve) => (releaseAfterCancel = resolve));
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: any) => updates.push(notification),
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
injectGeneratorSession(agent, (input) => {
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
const goal = await iter.next();
yield userEcho(goal.value);
await afterCancel;
yield { type: "system", subtype: "session_state_changed", state: "idle" };
}
return messageGenerator();
});
const runningGoal = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "/goal Keep working" }],
});
await waitFor(() => !!agent.sessions["test-session"]?.activeTurn);
await agent.cancel({ sessionId: "test-session" });
releaseAfterCancel();
await expect(runningGoal).resolves.toEqual(
expect.objectContaining({ stopReason: "cancelled" }),
);
expect(
updates.some(
({ update }) =>
update.sessionUpdate === "session_info_update" && update._meta?.goal === null,
),
).toBe(false);
});
it("validates goal control requests", () => {
expect(parseGoalRequest({ sessionId: "test-session", action: "clear" })).toEqual({
sessionId: "test-session",
action: "clear",
});
expect(() => parseGoalRequest({ sessionId: "test-session", action: "pause" })).toThrow(
'goal action must be "clear"',
expect(
parseGoalRequest({ sessionId: "test-session", action: "set", objective: " Replacement " }),
).toEqual({ sessionId: "test-session", action: "set", objective: "Replacement" });
expect(() => parseGoalRequest({ sessionId: "test-session", action: "set" })).toThrow(
'goal action "set" requires a non-empty objective',
);
expect(() =>
parseGoalRequest({ sessionId: "test-session", action: "set", objective: " " }),
).toThrow('goal action "set" requires a non-empty objective');
expect(() => parseGoalRequest({ sessionId: "test-session", action: "pause" })).toThrow(
'goal action must be "set" or "clear"',
);
expect(() => parseGoalRequest({ sessionId: "test-session", action: "resume" })).toThrow(
'goal action must be "set" or "clear"',
);
expect(goalUpdateFromPrompt("/goal pause")).toMatchObject({
objective: "pause",
status: "active",
});
});
it("preserves startedNewTurn by default when no turn is in flight", async () => {
@@ -10168,6 +10569,88 @@ describe("turn steering (_session/steering)", () => {
expect(JSON.stringify(injected.message.content)).toContain("also handle X");
});
it("publishes an optimistic goal update for a steered goal replacement", async () => {
const updates: any[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: any) => updates.push(notification),
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
const input = new Pushable<any>();
agent.sessions["test-session"] = mockSessionState({
input,
turnQueue: [
{
promptUuid: "running",
isLocalOnlyCommand: false,
settled: false,
resolve: () => {},
reject: () => {},
},
],
});
await agent.steer({
sessionId: "test-session",
prompt: [{ type: "text", text: "/goal Replace the objective" }],
});
expect(updates).toContainEqual({
sessionId: "test-session",
update: {
sessionUpdate: "session_info_update",
_meta: {
goal: {
objective: "Replace the objective",
status: "active",
controlMethod: GOAL_CONTROL_METHOD,
},
},
},
});
});
it("does not attribute a cancelled pending steer result to the next prompt", async () => {
const agent = createMockAgent();
let releaseCancel!: () => void;
const cancelGate = new Promise<void>((resolve) => (releaseCancel = resolve));
injectGeneratorSession(agent, (input) => {
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
const original = await iter.next();
yield userEcho(original.value);
const staleSteer = await iter.next();
await cancelGate;
yield idleMessage();
const replacement = await iter.next();
yield userEcho(staleSteer.value);
yield { ...createResultMessage(), stop_reason: "max_tokens" };
yield userEcho(replacement.value);
yield createResultMessage();
yield idleMessage();
}
return messageGenerator();
});
const first = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "/goal Original" }],
});
await waitFor(() => !!agent.sessions["test-session"]?.activeTurn);
await agent.goal({ sessionId: "test-session", action: "clear" });
await agent.cancel({ sessionId: "test-session" });
releaseCancel();
await expect(first).resolves.toEqual(expect.objectContaining({ stopReason: "cancelled" }));
await expect(
agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "/goal Replacement" }],
}),
).resolves.toEqual(expect.objectContaining({ stopReason: "end_turn" }));
});
// A steer does not join the running cycle — it ABORTS it: the CLI interrupts
// the in-flight query as soon as a queued message carries priority 'now'. The
// interrupted cycle ends with an ordinary human-origin `result` and the
@@ -10240,6 +10723,40 @@ describe("turn steering (_session/steering)", () => {
expect(agent.sessions["test-session"].turnQueue).toHaveLength(0);
});
it("does not fail the turn on the SDK diagnostic result emitted before a steered echo", async () => {
const agent = createMockAgent();
injectGeneratorSession(agent, (input) => {
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
const original = await iter.next();
yield userEcho(original.value);
const steered = await iter.next();
yield {
...createResultMessage(),
subtype: "success",
is_error: true,
result: "[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=null",
};
yield userEcho(steered.value);
yield createResultMessage();
yield idleMessage();
}
return messageGenerator();
});
const turn = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "start" }],
});
await waitFor(() => !!agent.sessions["test-session"]?.activeTurn);
await agent.steer({
sessionId: "test-session",
prompt: [{ type: "text", text: "/goal clear" }],
});
await expect(turn).resolves.toEqual(expect.objectContaining({ stopReason: "end_turn" }));
});
// The other steer ordering: the cycle had already finished when the steer
// landed, so nothing was aborted and its result is a natural one — followed by
// its own idle, before the steered message is picked up. Settling on that idle