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
97 changes: 96 additions & 1 deletion apps/electron/src/main/kernelHost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,19 @@ let lastKernelOptions: Record<string, unknown> | null = null;
let lastMarketData: FakeMarketDataService | null = null;
let lastAutomationContext: unknown = null;
let forwardedEvents: unknown[] = [];
/** Evaluation runs handed to EvaluationStore.addRun (observability path). */
const savedEvalRuns: Array<Record<string, unknown>> = [];
const routerFetchers = { getQuote: async () => ({ symbol: 'AAPL.US' }) };

/** Poll until `predicate` holds; the settle path is fire-and-forget (`void`). */
async function waitFor(predicate: () => boolean, timeoutMs = 1000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() > deadline) throw new Error('waitFor timed out');
await new Promise((resolve) => setTimeout(resolve, 5));
}
}

class FakeMarketDataService {
quoteSymbols: string[] = [];
constructor(readonly options?: { fetchers?: unknown }) {}
Expand Down Expand Up @@ -54,6 +65,7 @@ const fakeSessions = {
deleteSession: async () => undefined,
listMessages: async (sessionId: string) => [{ id: 'm1', role: 'user', content: sessionId, timestamp: 1 }],
listRuns: async () => [],
getSession: async () => undefined,
};

const fakeRuns = {
Expand Down Expand Up @@ -288,7 +300,9 @@ mock.module('@finagent/shared', () => ({
});
saveSettings = async (settings: unknown) => settings;
getSettings = async () => ({});
addRun = async () => undefined;
addRun = async (run: Record<string, unknown>) => {
savedEvalRuns.push(run);
};
listExperiments = async () => [];
getExperiment = async () => undefined;
listRuns = async () => [];
Expand Down Expand Up @@ -352,6 +366,7 @@ beforeEach(() => {
lastMarketData = null;
lastAutomationContext = null;
forwardedEvents = [];
savedEvalRuns.length = 0;
});

afterEach(() => {
Expand Down Expand Up @@ -521,4 +536,84 @@ describe('AgentKernelHost', () => {
});
host.dispose();
});

it('records a user-cancelled run as cancelled, not failed', async () => {
const originalSubscribe = fakeRuns.subscribe;
let subscriber: ((event: AgentEvent) => void) | null = null;
fakeRuns.subscribe = (listener) => {
subscriber = listener;
return () => undefined;
};
const host = new AgentKernelHost();
const emit = subscriber as ((event: AgentEvent) => void) | null;

emit?.({
id: 'e1',
sessionId: 's1',
runId: 'r1',
type: 'run_started',
timestamp: 10,
sequence: 1,
payload: {
run: { id: 'r1', sessionId: 's1', status: 'running', input: 'x', startedAt: 10 },
userMessage: { id: 'm1', role: 'user', content: 'x', timestamp: 10 },
},
});
// A user cancel settles the run as run_failed/RUN_CANCELLED (RunManager
// normalizes it to `cancelled` on the stream channel).
emit?.({
id: 'e2',
sessionId: 's1',
runId: 'r1',
type: 'run_failed',
timestamp: 20,
sequence: 2,
payload: { error: { code: 'RUN_CANCELLED', message: 'Run cancelled by user.' } },
});

await waitFor(() => savedEvalRuns.length === 1);
expect(savedEvalRuns[0]).toMatchObject({ id: 'r1', status: 'cancelled' });

fakeRuns.subscribe = originalSubscribe;
host.dispose();
});

it('keeps a genuine runtime failure recorded as failed', async () => {
const originalSubscribe = fakeRuns.subscribe;
let subscriber: ((event: AgentEvent) => void) | null = null;
fakeRuns.subscribe = (listener) => {
subscriber = listener;
return () => undefined;
};
const host = new AgentKernelHost();
const emit = subscriber as ((event: AgentEvent) => void) | null;

emit?.({
id: 'e1',
sessionId: 's1',
runId: 'r2',
type: 'run_started',
timestamp: 10,
sequence: 1,
payload: {
run: { id: 'r2', sessionId: 's1', status: 'running', input: 'x', startedAt: 10 },
userMessage: { id: 'm1', role: 'user', content: 'x', timestamp: 10 },
},
});
emit?.({
id: 'e2',
sessionId: 's1',
runId: 'r2',
type: 'run_failed',
timestamp: 20,
sequence: 2,
payload: { error: { code: 'PROVIDER_ERROR', message: 'Upstream failed.' } },
});

await waitFor(() => savedEvalRuns.length === 1);
expect(savedEvalRuns[0]).toMatchObject({ id: 'r2', status: 'failed' });

fakeRuns.subscribe = originalSubscribe;
host.dispose();
});
});
23 changes: 18 additions & 5 deletions apps/electron/src/main/kernelHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,15 @@ interface StartRunRequest {
/** Experiment id for runs observed outside explicit evaluation experiments. */
const OBSERVABILITY_EXPERIMENT_ID = '__observability__';

/**
* `RunManager` reports a user cancel as `run_failed` carrying this code and
* normalizes it to `cancelled` on the stream channel. The observability store
* has to read the same code, otherwise a deliberate cancel is recorded as a
* task failure and lands in the evaluated-quality bucket instead of `skipped`
* (`isSkippedRun` keys off `status === 'cancelled'`).
*/
const RUN_CANCELLED_CODE = 'RUN_CANCELLED';

interface PendingEvalRun {
sessionId: string;
startedAt: number;
Expand Down Expand Up @@ -1326,21 +1335,25 @@ export class AgentKernelHost {
pending.answer = event.payload.answer;
} else if (event.type === 'run_completed' || event.type === 'run_failed') {
if (event.type === 'run_failed') pending.error = event.payload.error;
const completed = event.type === 'run_completed';
void this.settleEvaluationRun(event.runId, event.sessionId, completed, event.timestamp);
const status: EvaluationRunStatus =
event.type === 'run_completed'
? 'completed'
: event.payload.error?.code === RUN_CANCELLED_CODE
? 'cancelled'
: 'failed';
void this.settleEvaluationRun(event.runId, event.sessionId, status, event.timestamp);
}
}

private async settleEvaluationRun(
runId: string,
sessionId: string,
completed: boolean,
status: EvaluationRunStatus,
endedAt: number
): Promise<void> {
const pending = this.evalRuns.get(runId);
this.evalRuns.delete(runId);
if (!pending) return;
const status: EvaluationRunStatus = completed ? 'completed' : 'failed';
const toolCalls: ToolCallRecord[] = pending.toolCalls.map((toolCall) => ({
id: toolCall.id,
toolName: toolCall.toolName,
Expand Down Expand Up @@ -1387,7 +1400,7 @@ export class AgentKernelHost {
answer: run.answer,
toolCalls: run.toolCalls,
error: pending.error,
completed,
completed: status === 'completed',
});
if (ref) {
await this.persistTraceLink(runId, ref);
Expand Down
Loading