Skip to content
Merged
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
213 changes: 212 additions & 1 deletion src/lib/__tests__/agent-interface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
reportMcpSetup,
} from '@lib/agent/agent-interface';
import { AgentOutputSignals } from '@lib/agent/output-signals';
import { RESUME_INSTRUCTION } from '@lib/agent/signals';
import { analytics } from '@utils/analytics';
import { wizardAbort } from '@utils/wizard-abort';
import { Sequence } from '@lib/constants';
import type { WizardRunOptions } from '@utils/types';
import type { SpinnerHandle } from '@ui';
Expand All @@ -22,6 +24,11 @@ import {
// Mock dependencies
vi.mock('../../utils/analytics');
vi.mock('../../utils/debug');
// wizardAbort exits the process; the 401 tests below need it to just reject.
vi.mock('@utils/wizard-abort', async (importOriginal) => ({
...(await importOriginal<typeof import('@utils/wizard-abort')>()),
wizardAbort: vi.fn(),
}));

// Mock the SDK module
const mockQuery = vi.fn();
Expand Down Expand Up @@ -54,6 +61,7 @@ const mockUIInstance = {
showBlockingOutage: vi.fn(),
setReadinessWarnings: vi.fn(),
showSettingsOverride: vi.fn(),
showAuthError: vi.fn(),
startRun: vi.fn(),
syncTodos: vi.fn(),
groupMultiselect: vi.fn(),
Expand Down Expand Up @@ -94,6 +102,7 @@ describe('runAgent', () => {
// would make either source pass.
gatewayUrl: 'https://gateway.test',
token: 'phe_run_scoped_token',
refreshAtMs: Date.now() + 3600_000,
},
};

Expand Down Expand Up @@ -622,7 +631,7 @@ describe('subprocess gateway credentials', () => {
const config = {
workingDirectory: '/test/dir',
mcpServers: {},
model: 'claude-sonnet-4-6',
model: 'claude-sonnet-5',
// Deliberately different from the gateway bearer below: identical values
// would let either source pass.
posthogApiKey: 'phx_user_oauth_token',
Expand All @@ -632,6 +641,7 @@ describe('subprocess gateway credentials', () => {
gatewayUrl: 'https://ai-gateway.us.posthog.com',
token: 'phe_run_scoped_token',
teamId: 42,
refreshAtMs: Date.now() + 3600_000,
},
};
const options: WizardRunOptions = {
Expand Down Expand Up @@ -683,6 +693,207 @@ describe('subprocess gateway credentials', () => {
});
});

describe('gateway re-mint on 401', () => {
const spinner = { start: vi.fn(), stop: vi.fn(), message: vi.fn() };
const options: WizardRunOptions = {
debug: false,
installDir: '/test/dir',
signup: false,
ci: false,
benchmark: false,
yaraReport: false,
};
const HOUR = 3600_000;
const auth = (token: string, refreshAtMs: number) => ({
gatewayUrl: 'https://ai-gateway.us.posthog.com',
token,
teamId: 42,
refreshAtMs,
});
const config = (
gatewayAuth: ReturnType<typeof auth>,
refreshGatewayAuth: () => Promise<ReturnType<typeof auth>>,
) => ({
workingDirectory: '/test/dir',
mcpServers: {},
model: 'claude-sonnet-5',
posthogApiKey: 'phx_user_oauth_token',
sequence: Sequence.linear,
triageProvider: () => Promise.resolve('false_positive'),
gatewayAuth,
refreshGatewayAuth,
});
const run = (cfg: ReturnType<typeof config>) =>
runAgent(cfg, 'test prompt', options, spinner as unknown as SpinnerHandle, {
successMessage: 'ok',
errorMessage: 'err',
});

function* rejectedSession(id: string) {
yield {
type: 'system',
subtype: 'init',
session_id: id,
model: 'm',
tools: [],
mcp_servers: [],
};
yield {
type: 'assistant',
session_id: id,
message: {
role: 'assistant',
content: [
{ type: 'text', text: 'API Error: 401 {"detail":"token expired"}' },
],
},
};
// Not reached: the 401 handler leaves the loop before the SDK's result.
yield {
type: 'result',
subtype: 'success',
session_id: id,
is_error: true,
result: 'API Error: 401',
};
}
function* completedSession(id: string) {
yield {
type: 'system',
subtype: 'init',
session_id: id,
model: 'm',
tools: [],
mcp_servers: [],
};
yield {
type: 'result',
subtype: 'success',
session_id: id,
is_error: false,
result: 'done',
};
}

beforeEach(() => {
vi.clearAllMocks();
mockUIInstance.spinner.mockReturnValue(spinner);
vi.mocked(wizardAbort).mockRejectedValue(new Error('wizardAbort: exit'));
});

it('mints once and resumes the session when an aged bearer is rejected', async () => {
mockQuery
.mockReturnValueOnce(rejectedSession('sess-1'))
.mockReturnValueOnce(completedSession('sess-2'));
const refresh = vi
.fn()
.mockResolvedValue(auth('phe_fresh', Date.now() + HOUR));
const cfg = config(auth('phe_stale', Date.now() - 1), refresh);

const result = await run(cfg);

expect(result).toEqual({});
expect(refresh).toHaveBeenCalledTimes(1);
expect(wizardAbort).not.toHaveBeenCalled();
expect(mockQuery).toHaveBeenCalledTimes(2);
const [first, second] = mockQuery.mock.calls.map((c) => c[0]);
expect(first.options.resume).toBeUndefined();
expect(second.options.resume).toBe('sess-1');
// The new subprocess carries the new bearer and finds the transcript in
// the same config dir; the env is frozen at spawn, so a new one is the
// only way to hand it over.
expect(second.options.env.ANTHROPIC_AUTH_TOKEN).toBe('phe_fresh');
expect(second.options.env.CLAUDE_CODE_OAUTH_TOKEN).toBe('phe_fresh');
expect(second.options.env.CLAUDE_CONFIG_DIR).toBe(
first.options.env.CLAUDE_CONFIG_DIR,
);
// The resumed session is told to pick up, not restarted from the prompt.
const resumed = await second.prompt.next();
expect(resumed.value.message.content).toBe(RESUME_INSTRUCTION);
expect(cfg.gatewayAuth.token).toBe('phe_fresh');
expect(analytics.wizardCapture).toHaveBeenCalledWith(
'gateway token reminted',
{ resumed: true },
);
});

it('fails the run on a second 401 after the re-mint', async () => {
mockQuery
.mockReturnValueOnce(rejectedSession('sess-1'))
.mockReturnValueOnce(rejectedSession('sess-2'));
// The new bearer is also past refresh (a slow run under a short TTL), so
// only the once-per-run rule stands between this and a second mint.
const refresh = vi
.fn()
.mockResolvedValue(auth('phe_fresh', Date.now() - 1));

const result = await run(
config(auth('phe_stale', Date.now() - 1), refresh),
);

expect(refresh).toHaveBeenCalledTimes(1);
expect(mockQuery).toHaveBeenCalledTimes(2);
expect(mockUIInstance.showAuthError).toHaveBeenCalledTimes(1);
expect(wizardAbort).toHaveBeenCalledTimes(1);
// In production wizardAbort exits; the mocked rejection surfaces as the
// run's API error.
expect(result.error).toBe('WIZARD_API_ERROR');
});

it('judges a failed resumed session on its own error, not the old 401', async () => {
function* resumedThenFailed(id: string) {
yield {
type: 'system',
subtype: 'init',
session_id: id,
model: 'm',
tools: [],
mcp_servers: [],
};
yield {
type: 'result',
subtype: 'success',
session_id: id,
is_error: true,
result: 'API Error: 500 upstream exploded',
};
}
mockQuery
.mockReturnValueOnce(rejectedSession('sess-1'))
.mockReturnValueOnce(resumedThenFailed('sess-2'));
const refresh = vi
.fn()
.mockResolvedValue(auth('phe_fresh', Date.now() + HOUR));

const result = await run(
config(auth('phe_stale', Date.now() - 1), refresh),
);

// The 401 that triggered the re-mint is history; reporting it here would
// send the user to the auth screen for a 500.
expect(result.error).toBe('WIZARD_API_ERROR');
expect(result.message).toContain('500');
expect(result.message).not.toContain('401');
expect(mockUIInstance.showAuthError).not.toHaveBeenCalled();
});

it('does not re-mint when a fresh bearer is rejected', async () => {
mockQuery.mockReturnValueOnce(rejectedSession('sess-1'));
const refresh = vi.fn();

const result = await run(
config(auth('phe_fresh', Date.now() + HOUR), refresh),
);

// A fresh token the gateway rejects is a bad credential, not age.
expect(refresh).not.toHaveBeenCalled();
expect(mockQuery).toHaveBeenCalledTimes(1);
expect(mockUIInstance.showAuthError).toHaveBeenCalledTimes(1);
expect(wizardAbort).toHaveBeenCalledTimes(1);
expect(result.error).toBe('WIZARD_API_ERROR');
});
});

describe('auth error context', () => {
// The 401 screen's region comes from whichever url it is handed, which is why
// runAgent passes the run's resolved auth rather than the process global a
Expand Down
27 changes: 27 additions & 0 deletions src/lib/__tests__/gateway-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
GatewayMintRefused,
buildWizardPropertiesBlob,
gatewayAuth,
isPastRefresh,
isTrustedGatewayUrl,
resetGatewaySession,
} from '@lib/gateway-session';
Expand Down Expand Up @@ -70,6 +71,7 @@ describe('gatewayAuth', () => {
gatewayUrl: 'https://gateway.us.posthog.com',
token: 'phe_minted',
teamId: 42,
refreshAtMs: expect.any(Number),
});
expect(fetchMock).toHaveBeenCalledWith(
'https://us.posthog.com/api/wizard/gateway_token/',
Expand Down Expand Up @@ -566,6 +568,31 @@ describe('gatewayAuth', () => {
}
});

it('sets the refresh instant at the refresh fraction of the token life', async () => {
vi.useFakeTimers();
try {
const ttlMs = 60 * 60 * 1000;
fetchMock.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
token: 'phe_minted',
expires_at: new Date(Date.now() + ttlMs).toISOString(),
gateway_url: 'https://gateway.us.posthog.com',
}),
});
const auth = await gatewayAuth(host, 'pha_oauth', 'integration');
expect(auth.refreshAtMs).toBe(Date.now() + ttlMs * 0.8);
// A 401 before this instant is a bad credential; after it, an aged
// bearer that one re-mint recovers.
expect(isPastRefresh(auth)).toBe(false);
vi.setSystemTime(Date.now() + ttlMs * 0.8);
expect(isPastRefresh(auth)).toBe(true);
} finally {
vi.useRealTimers();
}
});

it('retries cleanly after a failed mint rather than wedging the session', async () => {
// A rejected resolve must leave neither a cached posture nor a claimed
// in-flight slot behind, or one transient 503 wedges the run for the
Expand Down
14 changes: 7 additions & 7 deletions src/lib/agent/__tests__/agent-prompt-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ describe('parseAgentPrompt', () => {
type: instrument-events
model_pi: openai/gpt-5.6-terra # per-profile model targets
effort_pi: medium
model_sdk: claude-sonnet-4-6
model_sdk: claude-sonnet-5
skills: [instrument-events]
allowedTools: [Read, Edit, Grep, Glob, Bash]
disallowedTools: [enqueue_task]
Expand All @@ -52,7 +52,7 @@ Add at least one capture call.
expect(p.type).toBe('instrument-events');
expect(p.modelPi).toBe('openai/gpt-5.6-terra');
expect(p.effortPi).toBe('medium');
expect(p.modelSdk).toBe('claude-sonnet-4-6');
expect(p.modelSdk).toBe('claude-sonnet-5');
expect(p.skills).toEqual(['instrument-events']);
expect(p.allowedTools).toEqual(['Read', 'Edit', 'Grep', 'Glob', 'Bash']);
expect(p.disallowedTools).toEqual(['enqueue_task']);
Expand Down Expand Up @@ -86,7 +86,7 @@ Connect the sources.
effort: 'medium',
});
expect(promptModelFor(p, 'anthropic')).toEqual({
model: 'claude-sonnet-4-6',
model: 'claude-sonnet-5',
effort: undefined,
});
});
Expand Down Expand Up @@ -281,7 +281,7 @@ describe('buildRegistry', () => {
flow: 'f',
modelPi: 'openai/gpt-5.6-terra',
effortPi: 'medium',
modelSdk: 'claude-sonnet-4-6',
modelSdk: 'claude-sonnet-5',
}),
prompt({ type: 'install', flow: 'f', modelPi: 'openai/gpt-5.6-luna' }),
];
Expand All @@ -294,7 +294,7 @@ describe('buildRegistry', () => {
expect(registry.get('review')).toMatchObject({
modelPi: 'openai/gpt-5.6-sol',
effortPi: 'medium',
modelSdk: 'claude-sonnet-4-6',
modelSdk: 'claude-sonnet-5',
});
expect(registry.seed).toMatchObject({
modelPi: 'openai/gpt-5.6-terra',
Expand Down Expand Up @@ -324,7 +324,7 @@ describe('resolveTask', () => {
runnerSeeded: false,
modelPi: 'openai/gpt-5.6-luna',
effortPi: 'low',
modelSdk: 'claude-haiku-4-5-20251001',
modelSdk: 'claude-haiku-4-5',
skills: ['instrument-events'],
allowedTools: ['Read', 'Edit'],
disallowedTools: ['enqueue_task'],
Expand Down Expand Up @@ -356,7 +356,7 @@ describe('resolveTask', () => {
effort: 'low',
});
expect(taskModelSpec(registry, task, 'anthropic').model).toBe(
'claude-haiku-4-5-20251001',
'claude-haiku-4-5',
);
});

Expand Down
12 changes: 12 additions & 0 deletions src/lib/agent/__tests__/output-signals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ describe('AgentOutputSignals', () => {
expect(signals.remark()).toBeUndefined();
});

it('forgets API error lines after a re-mint but keeps every other signal', () => {
const signals = new AgentOutputSignals();
signals.push('API Error: 401 token expired');
signals.push('[ERROR-MCP-MISSING] could not reach MCP');

signals.forgetApiErrors();

expect(signals.hasApiError()).toBe(false);
expect(signals.hasApiErrorStatus(401)).toBe(false);
expect(signals.has('MCP_MISSING')).toBe(true);
});

it('treats the API error status as a parameter, not a fixed marker', () => {
const signals = new AgentOutputSignals();
signals.push('API Error: 503 service unavailable');
Expand Down
Loading
Loading