Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
125 changes: 125 additions & 0 deletions apps/desktop/src/main/__tests__/inspector-ipc-main.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import {
MODEL_CALL_ATTEMPT_EVENT_TYPE,
MODEL_CALL_ATTEMPT_SCHEMA_VERSION,
type ModelCallAttempt,
} from '@maka/core/model-call-attempt';
import type { RuntimeEvent } from '@maka/core/runtime-event';
import { readSessionTrace } from '../inspector-ipc-main.js';

function attempt(overrides: Partial<ModelCallAttempt> = {}): ModelCallAttempt {
return {
schemaVersion: MODEL_CALL_ATTEMPT_SCHEMA_VERSION,
logicalCallId: 'call-1',
attemptId: 'attempt-1',
traceId: 'trace-1',
sessionId: 'session-1',
runId: 'run-1',
turnId: 'turn-1',
step: 0,
attempt: 0,
callKind: 'main',
providerId: 'anthropic',
modelId: 'claude-test',
startedAt: 1_000,
completedAt: 1_500,
latencyMs: 500,
status: 'completed',
usageBasis: 'reported',
inputTokens: 10,
outputTokens: 5,
costBasis: 'priced',
costUsd: 0.002,
...overrides,
};
}

function runtimeEvent(overrides: Partial<RuntimeEvent> = {}): RuntimeEvent {
return {
id: 'event-1',
invocationId: 'invocation-1',
runId: 'run-1',
sessionId: 'session-1',
turnId: 'turn-1',
ts: 1_000,
partial: false,
role: 'model',
author: 'agent',
...overrides,
};
}

describe('inspector trace read', () => {
it('joins the AgentRun stream with the session runtime events', async () => {
// The canonical metering authority is the run stream, not the Usage read
// model — that one is range-queried and carries no session predicate.
const readRuns: string[] = [];
const trace = await readSessionTrace(
{
readSessionRuntimeEvents: async () => [
runtimeEvent({
id: 'usage-1',
ts: 2_000,
actions: { tokenUsage: { input: 10, output: 5, total: 15, runtimeSteps: 1 } },
}),
],
listSessionRuns: async () => [{ runId: 'run-1' }, { runId: 'run-2' }],
readRunEvents: async (_sessionId, runId) => {
readRuns.push(runId);
return runId === 'run-1'
? [
{ type: 'run_started' },
{ type: MODEL_CALL_ATTEMPT_EVENT_TYPE, data: attempt() },
]
: [];
},
},
'session-1',
);

assert.deepEqual(readRuns, ['run-1', 'run-2'], 'every run of the session is read');
assert.equal(trace.sessionId, 'session-1');
assert.equal(trace.totals.modelAttempts, 1);
assert.equal(trace.totals.costUsd, 0.002);
assert.equal(trace.coverage.modelCalls, 'no_known_gap');
assert.equal(trace.coverage.unreadableRecords, 0);
});

it('counts a record it cannot decode instead of dropping it', async () => {
// An undecodable record is spend the trace cannot show. Silently skipping
// it would make an incomplete trace look complete.
const trace = await readSessionTrace(
{
readSessionRuntimeEvents: async () => [],
listSessionRuns: async () => [{ runId: 'run-1' }],
readRunEvents: async () => [
{ type: MODEL_CALL_ATTEMPT_EVENT_TYPE, data: attempt() },
{ type: MODEL_CALL_ATTEMPT_EVENT_TYPE, data: { schemaVersion: 1, broken: true } },
],
},
'session-1',
);

assert.equal(trace.totals.modelAttempts, 1, 'the readable record still projects');
assert.equal(trace.coverage.unreadableRecords, 1);
assert.equal(trace.coverage.modelCalls, 'partial', 'an unreadable record is a known gap');
});

it('ignores AgentRun events that are not canonical model calls', async () => {
const trace = await readSessionTrace(
{
readSessionRuntimeEvents: async () => [],
listSessionRuns: async () => [{ runId: 'run-1' }],
readRunEvents: async () => [
{ type: 'provider_request_attempt_recorded', data: { step: 0 } },
{ type: 'run_completed' },
],
},
'session-1',
);

assert.equal(trace.turns.length, 0);
assert.equal(trace.coverage.modelCalls, 'none');
});
});
162 changes: 162 additions & 0 deletions apps/desktop/src/main/__tests__/session-inspector-panel-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import {
SESSION_TRACE_SCHEMA_VERSION,
emptyTraceTotals,
type SessionTrace,
} from '@maka/core/session-trace';
import { deriveInspectorPanelModel } from '../../renderer/session-inspector-panel-model.js';

function trace(overrides: Partial<SessionTrace> = {}): SessionTrace {
return {
schemaVersion: SESSION_TRACE_SCHEMA_VERSION,
sessionId: 'session-1',
turns: [],
totals: emptyTraceTotals(),
coverage: {
modelCalls: 'none',
turnsMissingModelCalls: [],
turnsWithFewerModelCallsThanSteps: [],
unreadableRecords: 0,
},
...overrides,
};
}

describe('inspector panel model', () => {
it('shows no coverage notice when the trace reports no known gap', () => {
// A notice that always shows is a notice nobody reads.
const model = deriveInspectorPanelModel(
trace({
coverage: {
modelCalls: 'no_known_gap',
turnsMissingModelCalls: [],
turnsWithFewerModelCallsThanSteps: [],
unreadableRecords: 0,
},
}),
);

assert.equal(model.coverage, undefined);
});

it('surfaces a partial coverage notice with what is missing', () => {
const model = deriveInspectorPanelModel(
trace({
coverage: {
modelCalls: 'partial',
turnsMissingModelCalls: ['turn-1'],
turnsWithFewerModelCallsThanSteps: ['turn-2', 'turn-3'],
unreadableRecords: 2,
},
}),
);

assert.deepEqual(model.coverage, {
kind: 'partial',
turnsMissing: 1,
turnsShort: 2,
unreadableRecords: 2,
});
});

it('leaves an unpriced model call without a cost rather than showing zero', () => {
const model = deriveInspectorPanelModel(
trace({
turns: [
{
turnId: 'turn-1',
runId: 'run-1',
startedAt: 1_000,
endedAt: 1_500,
durationMs: 500,
totals: emptyTraceTotals(),
steps: [
{
kind: 'model_call',
id: 'call-1',
turnId: 'turn-1',
runId: 'run-1',
startedAt: 1_000,
endedAt: 1_500,
durationMs: 500,
callKind: 'main',
providerId: 'anthropic',
modelId: 'claude-test',
step: 0,
attempts: [
{
attemptId: 'a-0',
attempt: 0,
status: 'completed',
startedAt: 1_000,
completedAt: 1_500,
latencyMs: 500,
costBasis: 'unpriced',
usageBasis: 'reported',
},
],
status: 'completed',
},
],
},
],
}),
);

const step = model.turns[0]?.steps[0];
assert.equal(step?.label, 'claude-test');
assert.equal(step?.costUsd, undefined, 'no price is not a zero price');
assert.equal(step?.retries, undefined, 'a single attempt is not a retry');
});

it('marks the attributed step as the failure, not every later step', () => {
const model = deriveInspectorPanelModel(
trace({
turns: [
{
turnId: 'turn-1',
runId: 'run-1',
startedAt: 1_000,
endedAt: 3_000,
durationMs: 2_000,
totals: emptyTraceTotals(),
failure: { code: 'tool_failed', attributedToStepId: 'dispatch-1' },
steps: [
{
kind: 'tool',
id: 'dispatch-1',
turnId: 'turn-1',
runId: 'run-1',
startedAt: 1_000,
endedAt: 1_200,
durationMs: 200,
toolName: 'Bash',
status: 'failed',
failed: true,
} as never,
{
kind: 'permission',
id: 'permission-1',
turnId: 'turn-1',
runId: 'run-1',
startedAt: 2_000,
decision: 'allow',
} as never,
],
},
],
}),
);

const [tool, permission] = model.turns[0]!.steps;
assert.equal(tool?.failed, true);
assert.equal(permission?.failed, false, 'a later step is not implicated by sequence');
assert.equal(model.turns[0]?.failureCode, 'tool_failed');
});

it('reports an empty model for a session with no turns', () => {
assert.equal(deriveInspectorPanelModel(trace()).empty, true);
assert.equal(deriveInspectorPanelModel(undefined).empty, true);
});
});
33 changes: 33 additions & 0 deletions apps/desktop/src/main/__tests__/session-workbar-layout.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import { afterEach, describe, it } from 'node:test';
import {
readSessionWorkbarTab,
readSessionWorkbarWidth,
SESSION_WORKBAR_DEFAULT_WIDTH,
} from '../../renderer/session-workbar-layout.js';
Expand Down Expand Up @@ -37,3 +38,35 @@ describe('readSessionWorkbarWidth', () => {
}
});
});

/**
* The persistence whitelist decides which tab survives a restart. A tab that
* renders but is not listed here silently reverts to Tasks, which looks like
* the panel forgetting rather than like a missing case.
*/
function storedTab(value: string | null): void {
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: (key: string) => (key === 'maka-session-workbar-tab-v1' ? value : null),
};
}

describe('readSessionWorkbarTab', () => {
afterEach(() => {
delete (globalThis as { localStorage?: unknown }).localStorage;
});

it('restores every persistable tab, including the Inspector', () => {
for (const tab of ['browser', 'files', 'inspector'] as const) {
storedTab(tab);
assert.equal(readSessionWorkbarTab(), tab);
}
});

it('falls back to tasks for the transient quote tab and for junk', () => {
// 'quote' only exists while an excerpt is staged, so it is never restored.
for (const stored of ['quote', 'nonsense', null]) {
storedTab(stored);
assert.equal(readSessionWorkbarTab(), 'tasks');
}
});
});
7 changes: 7 additions & 0 deletions apps/desktop/src/main/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ import { registerPlanReminderIpc } from './plan-reminders-ipc-main.js';
import { registerWorkspaceResourcesIpc } from './workspace-resources-ipc-main.js';
import type { NewSessionSkillContext } from './workspace-resources-ipc-main.js';
import { registerDailyReviewIpc } from './daily-review-ipc-main.js';
import { registerInspectorIpc } from './inspector-ipc-main.js';
import { registerUsageIpc } from './usage-ipc-main.js';
import { registerWebSearchIpc } from './web-search-ipc-main.js';
import { registerNotificationsIpc } from './notifications-ipc-main.js';
Expand Down Expand Up @@ -1206,6 +1207,12 @@ function registerIpc(): void {
: {}),
});
registerDailyReviewIpc({ dailyReview, dailyReviewArchiveStore, mainWindowController });
registerInspectorIpc({
ipcMain,
readSessionRuntimeEvents: (sessionId) => runtimeEventStore.readSessionRuntimeEvents(sessionId),
listSessionRuns: (sessionId) => runStore.listSessionRuns(sessionId),
readRunEvents: (sessionId, runId) => runStore.readEvents(sessionId, runId),
});
registerUsageIpc({
ipcMain,
settingsStore,
Expand Down
Loading
Loading