Skip to content
Open
278 changes: 278 additions & 0 deletions apps/agor-daemon/src/hooks/classify-missing-credential.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
/**
* Classification is driven by `resolveApiKey` (plus a native-auth probe), never
* by error text, and only fires on messages flagged as a failure or zero-turn.
*/

import { resolveApiKey } from '@agor/core/config';
import type { SessionRepository, TaskRepository } from '@agor/core/db';
import type { HookContext, Message, Session, Task } from '@agor/core/types';
import { MessageRole } from '@agor/core/types';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { classifyMissingCredentialFailure } from './classify-missing-credential';

vi.mock('@agor/core/config', () => ({
resolveApiKey: vi.fn(),
}));

const TOOL_DISPLAY_NAMES = { 'claude-code': 'Claude Code', opencode: 'OpenCode' };

function makeContext(data: Partial<Message> | undefined): HookContext {
return { data } as unknown as HookContext;
}

function makeTask(overrides: Partial<Task> = {}): Task {
return { task_id: 'task-1', session_id: 'session-1', created_by: 'user-1', ...overrides } as Task;
}

function makeSession(overrides: Partial<Session> = {}): Session {
return { session_id: 'session-1', agentic_tool: 'claude-code', ...overrides } as Session;
}

describe('classifyMissingCredentialFailure', () => {
let taskRepository: Pick<TaskRepository, 'findById'>;
let sessionsRepository: Pick<SessionRepository, 'findById'>;
let probeNativeAuth: (tool: string) => Promise<boolean>;

beforeEach(() => {
vi.mocked(resolveApiKey).mockReset();
taskRepository = { findById: vi.fn().mockResolvedValue(makeTask()) };
sessionsRepository = { findById: vi.fn().mockResolvedValue(makeSession()) };
// Default: native auth is NOT working, so no-key resolution stays classified.
probeNativeAuth = vi.fn().mockResolvedValue(false);
});

function runHook() {
return classifyMissingCredentialFailure(
{} as never,
taskRepository,
sessionsRepository,
TOOL_DISPLAY_NAMES,
probeNativeAuth
);
}

const failureMessage: Partial<Message> = {
task_id: 'task-1' as Message['task_id'],
session_id: 'session-1' as Message['session_id'],
content: 'Claude SDK error after 3 messages: Not logged in · Please run /login',
metadata: { is_task_failure: true },
};

it('classifies as missing_credential when no credential resolves anywhere', async () => {
vi.mocked(resolveApiKey).mockResolvedValue({
apiKey: undefined,
source: 'none',
useNativeAuth: true,
});

const ctx = await runHook()(makeContext({ ...failureMessage }));

expect((ctx.data as Message).metadata?.error_kind).toBe('missing_credential');
expect((ctx.data as Message).metadata?.tool).toBe('claude-code');
// Raw stderr text must not survive into the persisted content.
expect((ctx.data as Message).content).not.toContain('/login');
});

describe('native-auth tools (no stored key, resolveApiKey returns useNativeAuth)', () => {
beforeEach(() => {
vi.mocked(resolveApiKey).mockResolvedValue({
apiKey: undefined,
source: 'none',
useNativeAuth: true,
});
});

it('does NOT classify when the native CLI/OAuth probe reports authenticated', async () => {
probeNativeAuth = vi.fn().mockResolvedValue(true);

const ctx = await runHook()(makeContext({ ...failureMessage }));

expect((ctx.data as Message).metadata?.error_kind).toBeUndefined();
expect((ctx.data as Message).content).toBe(failureMessage.content);
expect(probeNativeAuth).toHaveBeenCalledWith('claude-code');
});

it('classifies when the native CLI/OAuth probe reports NOT authenticated', async () => {
probeNativeAuth = vi.fn().mockResolvedValue(false);

const ctx = await runHook()(makeContext({ ...failureMessage }));

expect((ctx.data as Message).metadata?.error_kind).toBe('missing_credential');
expect(probeNativeAuth).toHaveBeenCalledWith('claude-code');
});
});

describe('zero-turn "success" pathway (claude CLI reports success but never called the model)', () => {
// A subtype:'success' result with zero real assistant turns: the executor
// synthesizes the result text into an assistant message stamped with
// `is_zero_turn_result` (no `is_task_failure` marker on this pathway).
const zeroTurnAssistantMessage: Partial<Message> = {
task_id: 'task-1' as Message['task_id'],
session_id: 'session-1' as Message['session_id'],
type: 'assistant',
role: MessageRole.ASSISTANT,
content: [{ type: 'text', text: 'Not logged in · Please run /login' }],
metadata: { is_zero_turn_result: true },
};

it('classifies a zero-turn assistant message when no credential resolves anywhere', async () => {
vi.mocked(resolveApiKey).mockResolvedValue({
apiKey: undefined,
source: 'none',
useNativeAuth: true,
});

const ctx = await runHook()(makeContext({ ...zeroTurnAssistantMessage }));
const result = ctx.data as Message;

expect(result.metadata?.error_kind).toBe('missing_credential');
expect(result.metadata?.tool).toBe('claude-code');
// Normalized onto system/SYSTEM so the UI has one render branch
// regardless of which SDK pathway produced the classification.
expect(result.type).toBe('system');
expect(result.role).toBe(MessageRole.SYSTEM);
expect(JSON.stringify(result.content)).not.toContain('/login');
});

it('does not classify (and does not touch) a real assistant reply with no zero-turn marker', async () => {
const realReply: Partial<Message> = {
task_id: 'task-1' as Message['task_id'],
session_id: 'session-1' as Message['session_id'],
type: 'assistant',
role: MessageRole.ASSISTANT,
content: [{ type: 'text', text: 'Here is the answer.' }],
metadata: { tokens: { input: 512, output: 24 } },
};

const ctx = await runHook()(makeContext({ ...realReply }));

expect((ctx.data as Message).metadata?.error_kind).toBeUndefined();
expect((ctx.data as Message).type).toBe('assistant');
expect(resolveApiKey).not.toHaveBeenCalled();
});

it('does not reclassify legitimate zero-turn output (e.g. /cost) when the user IS authenticated', async () => {
// A local slash-command result (e.g. /cost) is also a zero-turn success,
// so it carries the same marker as an auth failure. resolveApiKey is the
// arbiter: a real credential means the output is left untouched.
vi.mocked(resolveApiKey).mockResolvedValue({
apiKey: 'sk-ant-user-key',
source: 'user',
useNativeAuth: false,
});

const localCommandOutput: Partial<Message> = {
task_id: 'task-1' as Message['task_id'],
session_id: 'session-1' as Message['session_id'],
type: 'assistant',
role: MessageRole.ASSISTANT,
content: [{ type: 'text', text: 'Total cost: $0.42' }],
metadata: { is_zero_turn_result: true },
};

const ctx = await runHook()(makeContext({ ...localCommandOutput }));
const result = ctx.data as Message;

expect(result.metadata?.error_kind).toBeUndefined();
expect(result.type).toBe('assistant');
expect(result.role).toBe(MessageRole.ASSISTANT);
expect(JSON.stringify(result.content)).toContain('Total cost');
});

it('does not trigger for messages lacking any zero-turn / failure marker', async () => {
const userMessage: Partial<Message> = {
task_id: 'task-1' as Message['task_id'],
session_id: 'session-1' as Message['session_id'],
type: 'user',
role: MessageRole.USER,
content: 'hello',
metadata: {},
};

const ctx = await runHook()(makeContext({ ...userMessage }));

expect((ctx.data as Message).metadata?.error_kind).toBeUndefined();
expect(resolveApiKey).not.toHaveBeenCalled();
});
});

it('does not classify when a workspace/env-level credential resolves', async () => {
vi.mocked(resolveApiKey).mockResolvedValue({
apiKey: 'sk-ant-env-key',
source: 'env',
useNativeAuth: false,
});

const ctx = await runHook()(makeContext({ ...failureMessage }));

expect((ctx.data as Message).metadata?.error_kind).toBeUndefined();
expect((ctx.data as Message).content).toBe(failureMessage.content);
});

it('does not classify when a per-user credential resolves', async () => {
vi.mocked(resolveApiKey).mockResolvedValue({
apiKey: 'sk-ant-user-key',
source: 'user',
useNativeAuth: false,
});

const ctx = await runHook()(makeContext({ ...failureMessage }));

expect((ctx.data as Message).metadata?.error_kind).toBeUndefined();
});

it('does not classify unrelated messages (no is_task_failure marker)', async () => {
vi.mocked(resolveApiKey).mockResolvedValue({
apiKey: undefined,
source: 'none',
useNativeAuth: true,
});

const rateLimitMessage: Partial<Message> = {
task_id: 'task-1' as Message['task_id'],
session_id: 'session-1' as Message['session_id'],
content: 'Rate limited, retrying...',
metadata: {},
};

const ctx = await runHook()(makeContext({ ...rateLimitMessage }));

expect((ctx.data as Message).metadata?.error_kind).toBeUndefined();
expect(resolveApiKey).not.toHaveBeenCalled();
});

it('is a no-op when context.data is undefined', async () => {
const ctx = await runHook()(makeContext(undefined));
expect(ctx.data).toBeUndefined();
expect(resolveApiKey).not.toHaveBeenCalled();
});

it('falls through untouched for tools with no mapped API key (e.g. opencode)', async () => {
sessionsRepository.findById = vi
.fn()
.mockResolvedValue(makeSession({ agentic_tool: 'opencode' }));

const ctx = await runHook()(makeContext({ ...failureMessage }));

expect((ctx.data as Message).metadata?.error_kind).toBeUndefined();
expect(resolveApiKey).not.toHaveBeenCalled();
});

it('falls through untouched when the task or session cannot be found', async () => {
taskRepository.findById = vi.fn().mockResolvedValue(null);

const ctx = await runHook()(makeContext({ ...failureMessage }));

expect((ctx.data as Message).metadata?.error_kind).toBeUndefined();
expect(resolveApiKey).not.toHaveBeenCalled();
});

it('swallows classification errors and leaves the message untouched', async () => {
vi.mocked(resolveApiKey).mockRejectedValue(new Error('boom'));

const ctx = await runHook()(makeContext({ ...failureMessage }));

expect((ctx.data as Message).metadata?.error_kind).toBeUndefined();
expect((ctx.data as Message).content).toBe(failureMessage.content);
});
});
80 changes: 80 additions & 0 deletions apps/agor-daemon/src/hooks/classify-missing-credential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Before-create hook that reclassifies a task failure as "missing credential"
* via `resolveApiKey`, never by matching the provider's raw stderr. Fires on
* two failure shapes — a thrown error (`is_task_failure`) and a zero-turn
* "success" whose text is the auth error (`is_zero_turn_result`) — then lets
* the resolve result (plus a native-auth probe) decide the outcome.
*/

import { resolveApiKey } from '@agor/core/config';
import type { SessionRepository, TaskRepository, TenantScopeAwareDatabase } from '@agor/core/db';
import type { HookContext, Message, TaskID, UserID } from '@agor/core/types';
import { MessageRole, TOOL_API_KEY_NAMES } from '@agor/core/types';

/** Fallback for consumers that render `content` raw (mobile, gateway, CLI).
* The web UI renders its own copy from MissingCredentialPanel instead. */
function fallbackContent(toolDisplayName: string): string {
return `This session needs to be connected to ${toolDisplayName} before it can run.`;
}

export function classifyMissingCredentialFailure(
db: TenantScopeAwareDatabase,
taskRepository: Pick<TaskRepository, 'findById'>,
sessionsRepository: Pick<SessionRepository, 'findById'>,
toolDisplayNames: Record<string, string>,
// Injected (not imported) so the hook stays free of the Claude SDK's import
// graph, which breaks under vitest's ESM resolution.
probeNativeAuth?: (tool: string) => Promise<boolean>
) {
return async (context: HookContext): Promise<HookContext> => {
const data = context.data as Partial<Message> | undefined;
if (!data?.task_id || !data.session_id) return context;

const isThrownFailureNotice = data.metadata?.is_task_failure === true;
const isZeroTurnResult = data.metadata?.is_zero_turn_result === true;

if (!isThrownFailureNotice && !isZeroTurnResult) return context;

try {
const [task, session] = await Promise.all([
taskRepository.findById(data.task_id as TaskID),
sessionsRepository.findById(data.session_id),
]);
if (!task || !session) return context;

const tool = session.agentic_tool;
const keyName = TOOL_API_KEY_NAMES[tool];
// Tools with no mapped key (e.g. opencode) aren't credential-gated.
if (!keyName) return context;

const { apiKey, useNativeAuth } = await resolveApiKey(keyName, {
userId: task.created_by as UserID,
db,
tool,
});
if (apiKey) return context; // A credential DID resolve — some other failure.

// Native-auth tools resolve to no key even when logged in via CLI/OAuth;
// probe the live auth state before concluding it's actually missing.
if (useNativeAuth && probeNativeAuth && (await probeNativeAuth(tool))) return context;

context.data = {
...data,
// Normalize both pathways onto system/SYSTEM so the UI has one render branch.
type: 'system',
role: MessageRole.SYSTEM,
content: fallbackContent(toolDisplayNames[tool] ?? tool),
content_preview: fallbackContent(toolDisplayNames[tool] ?? tool).substring(0, 200),
metadata: {
...data.metadata,
error_kind: 'missing_credential',
tool,
},
};
} catch (err) {
console.error('[classifyMissingCredentialFailure] classification failed:', err);
}

return context;
};
}
Loading