mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-29 13:31:58 +00:00
Compare commits
1 Commits
v0.99.1-pr
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fccaebcf87 |
@@ -2319,6 +2319,17 @@ function isTaskNotificationRecord(message: unknown): boolean {
|
||||
return origin?.kind === "task-notification" && origin.subkind === undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when CLI error text tells the user to sign in with `/login`. The CLI
|
||||
* words this several ways — "Not logged in · Please run /login", "Not logged
|
||||
* in · Run /login", "Login expired · Run /login to sign in again, or
|
||||
* re-authenticate your Anthropic profile" — so match the "run /login"
|
||||
* instruction rather than one sentence.
|
||||
*/
|
||||
export function isLoginRequiredText(text: string): boolean {
|
||||
return /\brun \/login\b/i.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the synthetic assistant message the CLI injects into the transcript
|
||||
* when a turn fails authentication (e.g. "Not logged in · Please run /login",
|
||||
@@ -2331,9 +2342,13 @@ function isTaskNotificationRecord(message: unknown): boolean {
|
||||
* session/load even after the user has logged back in.
|
||||
*
|
||||
* Takes the API message (`message.message`), which replay only knows as
|
||||
* `unknown`. The persisted record's structured `error: "authentication_failed"`
|
||||
* marker is stripped by `getSessionMessages`, so the synthetic model + text is
|
||||
* all both paths have to match on.
|
||||
* `unknown`. The persisted record's structured `error` marker is stripped by
|
||||
* `getSessionMessages`, and the CLI does not always set it to
|
||||
* `authentication_failed` anyway: an expired login with no refresh token
|
||||
* ("Login expired · Run /login to sign in again, …") arrives as
|
||||
* `invalid_request`. So the synthetic model + text is what both paths match on.
|
||||
* Usage-limit banners are excluded because some of them also mention `/login`
|
||||
* (to switch accounts) and have their own handling.
|
||||
*/
|
||||
export function isSyntheticLoginMessage(apiMessage: unknown): boolean {
|
||||
if (!apiMessage || typeof apiMessage !== "object") {
|
||||
@@ -2348,7 +2363,8 @@ export function isSyntheticLoginMessage(apiMessage: unknown): boolean {
|
||||
!!block &&
|
||||
block.type === "text" &&
|
||||
typeof block.text === "string" &&
|
||||
block.text.includes("Please run /login")
|
||||
isLoginRequiredText(block.text) &&
|
||||
!isSyntheticUsageLimitMessage(apiMessage)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7105,7 +7121,7 @@ export class ClaudeAcpAgent {
|
||||
message.stop_reason === "refusal" ||
|
||||
("result" in message &&
|
||||
message.is_error &&
|
||||
message.result.includes("Please run /login"));
|
||||
isLoginRequiredText(message.result));
|
||||
if (goalCommandFailed) {
|
||||
await this.publishGoal(params.sessionId, pendingGoalUpdate.previous ?? null);
|
||||
}
|
||||
@@ -7335,7 +7351,7 @@ export class ClaudeAcpAgent {
|
||||
// drained hold at their results is as good as the idle
|
||||
// fallback.) Then stop: everything below is user-turn
|
||||
// lifecycle, and an autonomous outcome must never touch it —
|
||||
// its is_error or "Please run /login" text would otherwise
|
||||
// its is_error or "run /login" text would otherwise
|
||||
// failActive a live turn (the held one, or the user's next
|
||||
// prompt) whose own result recorded a different outcome.
|
||||
if (isAutonomousResult) {
|
||||
@@ -7589,7 +7605,7 @@ export class ClaudeAcpAgent {
|
||||
|
||||
switch (message.subtype) {
|
||||
case "success": {
|
||||
if (message.is_error && message.result.includes("Please run /login")) {
|
||||
if (message.is_error && isLoginRequiredText(message.result)) {
|
||||
await failActiveWithSessionFailure(
|
||||
"auth_required",
|
||||
RequestError.authRequired(),
|
||||
@@ -9678,7 +9694,7 @@ export class ClaudeAcpAgent {
|
||||
replaySession.messageIdToUuid.set(replayMessageId, message.uuid);
|
||||
}
|
||||
|
||||
// The live prompt loop converts the synthetic "Please run /login"
|
||||
// The live prompt loop converts the synthetic "run /login"
|
||||
// assistant message into an authRequired error instead of showing its
|
||||
// TUI-specific text; skip it on replay too (issue #863).
|
||||
if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
|
||||
|
||||
@@ -2629,6 +2629,15 @@ describe("synthetic login message (issue #863)", () => {
|
||||
content: [{ type: "text", text: "Session expired. Please run /login to sign in again." }],
|
||||
}),
|
||||
).toBe(true);
|
||||
// Newer CLI wording that drops "Please".
|
||||
for (const text of [
|
||||
"Not logged in · Run /login",
|
||||
"Login expired · Run /login to sign in again, or re-authenticate your Anthropic profile",
|
||||
]) {
|
||||
expect(
|
||||
isSyntheticLoginMessage({ ...syntheticLoginApiMessage, content: [{ type: "text", text }] }),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("isSyntheticLoginMessage does not match real assistant messages", () => {
|
||||
@@ -2646,6 +2655,19 @@ describe("synthetic login message (issue #863)", () => {
|
||||
content: [{ type: "text", text: "API Error: 500 Internal Server Error" }],
|
||||
}),
|
||||
).toBe(false);
|
||||
// Usage-limit banners can mention /login (to switch accounts) but are
|
||||
// handled as usage limits, not as a sign-out.
|
||||
expect(
|
||||
isSyntheticLoginMessage({
|
||||
...syntheticLoginApiMessage,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "You've hit your limit · resets 5pm · run /login to switch to an API usage-billed account.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(isSyntheticLoginMessage(undefined)).toBe(false);
|
||||
expect(isSyntheticLoginMessage("Not logged in · Please run /login")).toBe(false);
|
||||
});
|
||||
@@ -12100,6 +12122,41 @@ describe("stop reason propagation", () => {
|
||||
expect(logged.join("\n")).not.toContain("cannot fail active turn");
|
||||
});
|
||||
|
||||
it("rejects an expired login the CLI reports as invalid_request with authRequired", async () => {
|
||||
const updates: SessionNotification[] = [];
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{
|
||||
sessionUpdate: async (update: SessionNotification) => {
|
||||
updates.push(update);
|
||||
},
|
||||
} as unknown as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
);
|
||||
await initializeClient(agent, {});
|
||||
// An expired token with no refresh token: the CLI tags this synthetic
|
||||
// message `invalid_request`, not `authentication_failed`.
|
||||
const text =
|
||||
"Login expired · Run /login to sign in again, or re-authenticate your Anthropic profile";
|
||||
const loginExpired = createAssistantError("invalid_request");
|
||||
loginExpired.message.model = "<synthetic>";
|
||||
loginExpired.message.content = [{ type: "text", text, citations: null }] as any;
|
||||
injectSession(agent, [
|
||||
loginExpired,
|
||||
createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
is_error: true,
|
||||
result: text,
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }),
|
||||
).rejects.toMatchObject({ code: RequestError.authRequired().code });
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
expect(JSON.stringify(updates)).not.toContain("/login");
|
||||
});
|
||||
|
||||
it("does not treat a successful answer mentioning /login as auth_required", async () => {
|
||||
const updates: SessionNotification[] = [];
|
||||
const agent = new ClaudeAcpAgent(
|
||||
|
||||
Reference in New Issue
Block a user