mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
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:
@@ -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.
|
||||
|
||||
127
src/acp-agent.ts
127
src/acp-agent.ts
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user