Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/acp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4380,6 +4380,14 @@ export class ClaudeAcpAgent {
}

if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
// The SDK can emit this terminal auth frame before replaying the
// queued prompt's user echo. Bind the queue head here so the
// failure settles that prompt instead of becoming session-only
// while its Promise remains pending forever.
const queued = firstUnsettledQueuedTurn();
if (!session.activeTurn && queued) {
activateTurn(queued);
}
await failActiveWithSessionFailure("auth_required", RequestError.authRequired());
break;
}
Expand Down
70 changes: 70 additions & 0 deletions src/tests/acp-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1727,6 +1727,12 @@ describe("synthetic login message (issue #863)", () => {
type: "message",
stop_reason: "stop_sequence",
content: [{ type: "text", text: "Not logged in · Please run /login" }],
usage: {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
};

it("isSyntheticLoginMessage matches the CLI auth-error message", () => {
Expand Down Expand Up @@ -1802,6 +1808,70 @@ describe("synthetic login message (issue #863)", () => {
// …but the TUI-specific "/login" instruction never reaches the client.
expect(JSON.stringify(updates)).not.toContain("/login");
});

it("fails a queued prompt when synthetic auth arrives before its user echo", async () => {
const client = {
sessionUpdate: async () => {},
} as unknown as AcpClient;
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
let markAuthHandled!: () => void;
const authHandled = new Promise<void>((resolve) => (markAuthHandled = resolve));
let finishGenerator!: () => void;
const generatorFinished = new Promise<void>((resolve) => (finishGenerator = resolve));

injectGeneratorSession(agent, (input) => {
async function* messageGenerator() {
await input[Symbol.asyncIterator]().next();
yield {
type: "assistant",
uuid: "synthetic-auth",
session_id: "test-session",
parent_tool_use_id: null,
parent_agent_id: null,
message: syntheticLoginApiMessage,
};
// Reaching this line proves the consumer handled the yielded auth frame
// and requested the next one. No clock or production credentials needed.
markAuthHandled();
await generatorFinished;
}
return messageGenerator();
});

type Outcome =
{ kind: "resolved"; value: PromptResponse } | { kind: "rejected"; error: unknown };
let outcome: Outcome | undefined;
const prompt = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "hello" }],
});
const terminal = prompt.then(
(value): Outcome => (outcome = { kind: "resolved", value }),
(error): Outcome => (outcome = { kind: "rejected", error }),
);

await authHandled;
await Promise.resolve();
const outcomeBeforeCancel = outcome;

// Always clean up the intentionally paused stream. On the regression path
// cancel settles the still-queued prompt, but must not leave orphan debt.
const cancelling = agent.cancel({ sessionId: "test-session" });
finishGenerator();
await cancelling;
const finalOutcome = await terminal;
await agent.sessions["test-session"]?.consumer;

expect(outcomeBeforeCancel).toMatchObject({
kind: "rejected",
error: { code: -32000 },
});
expect(finalOutcome).toMatchObject({ kind: "rejected", error: { code: -32000 } });
expect(agent.sessions["test-session"].activeTurn ?? null).toBeNull();
expect(agent.sessions["test-session"].turnQueue).toHaveLength(0);
expect(agent.sessions["test-session"].pendingOrphanResults ?? 0).toBe(0);
expect(agent.sessions["test-session"].orphanCommands?.size ?? 0).toBe(0);
});
});

describe("subagent transcript replay", () => {
Expand Down