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
70 changes: 70 additions & 0 deletions apps/web/src/lib/changes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';

import { summarizeChanges } from './changes';
import type { DecisionState, TranscriptEntry } from './session';

/** A permission gate entry. Fixed ids keep tests independent — the summary never asserts on them. */
function gate(
toolName: string,
input: Record<string, unknown>,
decision: DecisionState = 'pending',
): TranscriptEntry {
return { kind: 'permission', id: 'e1', requestId: 'r1', toolName, input, decision };
}

const EDIT = { file_path: 'src/a.ts', old_string: 'a\nb', new_string: 'a\nc' }; // +1 −1
const WRITE = { file_path: 'src/b.ts', content: 'x\ny' }; // +2 −0
const MULTI_EDIT = {
file_path: 'src/c.ts',
edits: [
{ old_string: 'a', new_string: 'b' }, // +1 −1
{ old_string: 'c', new_string: 'd\ne' }, // +2 −1
],
};

describe('summarizeChanges', () => {
it('reads real +/- line counts from a pending edit gate and flags it pending', () => {
const summary = summarizeChanges([gate('Edit', EDIT, 'pending')]);
expect(summary.files).toEqual([{ path: 'src/a.ts', additions: 1, deletions: 1 }]);
expect(summary.additions).toBe(1);
expect(summary.deletions).toBe(1);
expect(summary.pending).toBe(1);
});

it('counts an approved write as applied (no longer pending)', () => {
const summary = summarizeChanges([gate('Write', WRITE, 'approved')]);
expect(summary.files).toEqual([{ path: 'src/b.ts', additions: 2, deletions: 0 }]);
expect(summary.pending).toBe(0);
});

it('counts an in-flight approving gate as a change, but not as pending', () => {
const summary = summarizeChanges([gate('Edit', EDIT, 'approving')]);
expect(summary.files).toEqual([{ path: 'src/a.ts', additions: 1, deletions: 1 }]);
expect(summary.pending).toBe(0);
});

it('excludes a rejected edit and an in-flight rejecting one — neither writes to disk', () => {
expect(summarizeChanges([gate('Edit', EDIT, 'rejected')]).files).toEqual([]);
expect(summarizeChanges([gate('Edit', EDIT, 'rejecting')]).files).toEqual([]);
});

it('reads additions/deletions from a MultiEdit gate (its own hunk parser)', () => {
const summary = summarizeChanges([gate('MultiEdit', MULTI_EDIT, 'approved')]);
expect(summary.files).toEqual([{ path: 'src/c.ts', additions: 3, deletions: 2 }]);
});

it('aggregates multiple edits to the same file', () => {
const summary = summarizeChanges([
gate('Edit', EDIT, 'approved'),
gate('Edit', EDIT, 'pending'),
]);
expect(summary.files).toEqual([{ path: 'src/a.ts', additions: 2, deletions: 2 }]);
expect(summary.pending).toBe(1);
});

it('ignores non-file gates for the diff totals but still counts them as pending', () => {
const summary = summarizeChanges([gate('Bash', { command: 'ls' }, 'pending')]);
expect(summary.files).toEqual([]);
expect(summary.pending).toBe(1);
});
});
58 changes: 58 additions & 0 deletions apps/web/src/lib/changes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { buildFileDiff } from './diff';
import type { TranscriptEntry } from './session';

/**
* The session-rail "Changes" summary: what files this session is touching, with real +/− line counts.
* Every consequential file edit passes through the approval gate (architecture invariant #4), so the
* permission entries are the authoritative record of proposed/applied changes — folding them through
* {@link buildFileDiff} (the same model the diff card renders) yields honest totals, never invented ones.
* A rejected gate never ran, so it is excluded; a still-pending gate is counted and surfaced as `pending`
* ("not yet written to disk"). Pure, so the rail stays a thin renderer and this unit-tests directly.
*/
export interface FileChange {
readonly path: string;
readonly additions: number;
readonly deletions: number;
}

export interface ChangesSummary {
/** Distinct files touched, with edits to the same path aggregated. */
readonly files: readonly FileChange[];
readonly additions: number;
readonly deletions: number;
/** Gates still awaiting a decision — proposed but not yet written. */
readonly pending: number;
}

/**
* The file diff a change-bearing entry implies, or null for non-file entries. A rejected gate — or an
* in-flight `rejecting` one, which is about to settle as rejected — never writes to disk, so it is not a
* change; everything else (pending, approving, approved) is a proposed or applied edit.
*/
function changeDiff(entry: TranscriptEntry): ReturnType<typeof buildFileDiff> {
if (entry.kind !== 'permission') return null;
if (entry.decision === 'rejected' || entry.decision === 'rejecting') return null;
return buildFileDiff(entry.toolName, entry.input);
}

export function summarizeChanges(entries: readonly TranscriptEntry[]): ChangesSummary {
const byPath = new Map<string, { additions: number; deletions: number }>();
let additions = 0;
let deletions = 0;
let pending = 0;

for (const entry of entries) {
if (entry.kind === 'permission' && entry.decision === 'pending') pending += 1;
const diff = changeDiff(entry);
if (!diff) continue;
const acc = byPath.get(diff.path) ?? { additions: 0, deletions: 0 };
acc.additions += diff.additions;
acc.deletions += diff.deletions;
byPath.set(diff.path, acc);
additions += diff.additions;
deletions += diff.deletions;
}

const files = [...byPath.entries()].map(([path, totals]) => ({ path, ...totals }));
return { files, additions, deletions, pending };
}
Loading
Loading