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
21 changes: 21 additions & 0 deletions apps/desktop/e2e/desktop-preview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,24 @@ test('reviews findings and the working tree from the Changes panel', async ({ pa
await expect(panel.getByText('applied', { exact: true })).toBeVisible();
await expect(panel.getByRole('button', { name: 'Revert', exact: true })).toBeVisible();
});

test('resumes a thread from its protocol items, not just its messages', async ({ page }) => {
await page.locator('[title*="2026-06-02-bbb222"]').click();
const main = page.getByRole('main');

// Messages and tool cards, as before.
await expect(main.getByText('Harden the loader', { exact: true })).toBeVisible();
await expect(main.getByText(/Reading the loader\./)).toBeVisible();
await expect(main.locator('.tool-card').filter({ hasText: 'Read' })).toBeVisible();

// The items the message projection dropped on the floor.
await expect(main.getByText('⏸ Edit — allow', { exact: true })).toBeVisible();
await expect(main.getByText('❯ Which loader? → the config one', { exact: true })).toBeVisible();
await expect(main.getByText(/Loader swallows parse errors/)).toBeVisible();

// And the finding is live in the Changes panel, not just narrated.
await page.getByRole('button', { name: /^Changes\b/ }).click();
const panel = page.getByTestId('changes-panel');
await expect(panel.getByText('Loader swallows parse errors')).toBeVisible();
await expect(panel.getByRole('button', { name: 'src/loader.ts:42' })).toBeVisible();
});
21 changes: 18 additions & 3 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ import { UpdateBanner } from './components/UpdateBanner.js';
import { registerShortcut } from './lib/keyboard.js';
import { clearProtocolThread as clearAgentHistory } from './lib/protocol-agent.js';
import { loadProjectPath, saveProjectPath } from './lib/project.js';
import { storedToMsgs, type Msg } from './lib/repl-stream.js';
import {
storedToMsgs,
threadReviewItems,
threadToMsgs,
type Msg,
type ThreadLike,
} from './lib/repl-stream.js';
import { onUpdateDownloaded, startUpdaterPolling } from './lib/updater.js';
import { changesBadge } from './lib/changes-reducer.js';
import { useChanges } from './lib/use-changes.js';
Expand Down Expand Up @@ -237,8 +243,17 @@ export function App(): JSX.Element {
// Load the session's stored messages, adopt them into the agent, and
// remount ReplScreen seeded with the reconstructed conversation.
try {
const { history } = await window.deepcode.sessions.resume({ id });
setResumedMessages(storedToMsgs(history as Parameters<typeof storedToMsgs>[0]));
const { history, thread } = await window.deepcode.sessions.resume({ id });
const snapshot = thread as ThreadLike | undefined;
// Prefer the protocol snapshot; fall back to the message projection
// for legacy threads that have no items yet.
const hasItems = (snapshot?.turns ?? []).some((t) => t.items.length > 0);
setResumedMessages(
hasItems
? threadToMsgs(snapshot!)
: storedToMsgs(history as Parameters<typeof storedToMsgs>[0]),
);
if (snapshot) changes.adopt(threadReviewItems(snapshot));
} catch {
setResumedMessages(undefined); // fall back to a fresh view
}
Expand Down
111 changes: 111 additions & 0 deletions apps/desktop/src/lib/repl-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import {
lastAssistantIndex,
pickTarget,
storedToMsgs,
threadReviewItems,
threadToMsgs,
type AssistantMsg,
type Msg,
type ToolInvocation,
} from './repl-stream.js';
Expand Down Expand Up @@ -170,3 +173,111 @@ describe('repl-stream mutators', () => {
expect(pickTarget({ irrelevant: 1 })).toBeUndefined();
});
});

describe('threadToMsgs', () => {
const turn = (items: Array<{ type: string; payload: Record<string, unknown> }>) => ({
turns: [{ items }],
});

it('projects a user message', () => {
expect(threadToMsgs(turn([{ type: 'user_message', payload: { text: 'hi' } }]))).toEqual([
{ role: 'user', text: 'hi' },
]);
});

it('attaches a tool result to the assistant turn that issued the call', () => {
const msgs = threadToMsgs(
turn([
{
type: 'assistant_message',
payload: {
message: {
role: 'assistant',
content: [
{ type: 'text', text: 'Reading.' },
{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: 'a.ts' } },
],
},
},
},
{
type: 'tool_result',
payload: {
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 't1', content: 'file body' }],
},
},
},
]),
);
const assistant = msgs.find((m) => m.role === 'assistant') as AssistantMsg;
expect(assistant.turn.tools).toHaveLength(1);
// The result must land on the existing card, not create a second turn.
expect(assistant.turn.tools[0]!.status).toBe('ok');
expect(assistant.turn.tools[0]!.resultText).toBe('file body');
expect(msgs.filter((m) => m.role === 'assistant')).toHaveLength(1);
});

it('restores the items the message projection dropped', () => {
const msgs = threadToMsgs(
turn([
{ type: 'approval', payload: { toolName: 'Edit', decision: 'allow' } },
{ type: 'ask_user', payload: { question: 'Which one?', answer: 'the first' } },
{
type: 'review_finding',
payload: { path: 'src/a.ts', startLine: 4, title: 'Null crash' },
},
{ type: 'review_action', payload: { kind: 'apply', findingIds: ['f1'] } },
{ type: 'error', payload: { message: 'provider timed out' } },
]),
);
const text = msgs.map((m) => (m.role === 'system' ? m.text : '')).join('\n');
expect(text).toContain('Edit');
expect(text).toContain('allow');
expect(text).toContain('Which one?');
expect(text).toContain('the first');
expect(text).toContain('src/a.ts:4');
expect(text).toContain('Null crash');
expect(text).toContain('review apply');
expect(text).toContain('provider timed out');
expect(msgs.at(-1)).toMatchObject({ level: 'error' });
});

it('keeps items in the order they completed', () => {
const msgs = threadToMsgs(
turn([
{ type: 'user_message', payload: { text: 'first' } },
{ type: 'ask_user', payload: { question: 'q', answer: 'a' } },
{ type: 'user_message', payload: { text: 'second' } },
]),
);
expect(msgs.map((m) => m.role)).toEqual(['user', 'system', 'user']);
});

it('ignores item types it does not know', () => {
expect(threadToMsgs(turn([{ type: 'something_new', payload: {} }]))).toEqual([]);
});

it('returns nothing for a thread with no turns', () => {
expect(threadToMsgs({ turns: [] })).toEqual([]);
});
});

describe('threadReviewItems', () => {
it('collects findings and actions across turns', () => {
const { findings, actions } = threadReviewItems({
turns: [
{ items: [{ type: 'review_finding', payload: { findingId: 'f1' } }] },
{
items: [
{ type: 'review_action', payload: { actionId: 'a1', kind: 'apply' } },
{ type: 'user_message', payload: { text: 'ignored' } },
],
},
],
});
expect(findings).toEqual([{ findingId: 'f1' }]);
expect(actions).toEqual([{ actionId: 'a1', kind: 'apply' }]);
});
});
118 changes: 116 additions & 2 deletions apps/desktop/src/lib/repl-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,20 @@ export interface StoredLine {
* dropped (they were streaming-only). All turns are non-streaming (finalized).
*/
export function storedToMsgs(stored: StoredLine[]): Msg[] {
let msgs: Msg[] = [];
for (const m of stored) {
return stored.reduce(appendStoredLine, [] as Msg[]);
}

/**
* Fold one stored message into the transcript.
*
* Split out of storedToMsgs so a thread projection can interleave non-message
* items without losing tool-result attachment: a `tool_result` block has to be
* matched against the assistant turn already in `msgs`, which a fresh
* storedToMsgs([line]) call cannot see.
*/
export function appendStoredLine(input: Msg[], m: StoredLine): Msg[] {
let msgs = [...input];
{
if (m.role === 'assistant') {
const texts: string[] = [];
const tools: ToolInvocation[] = [];
Expand Down Expand Up @@ -202,3 +214,105 @@ export function pickTarget(input: Record<string, unknown>): string | undefined {
}
return undefined;
}

// ── Resuming from a protocol thread ──────────────────────────────────────

/** The subset of a protocol CompletedItem this projection needs. */
export interface ThreadItem {
type: string;
payload: Record<string, unknown>;
}
export interface ThreadTurn {
items: ThreadItem[];
}
export interface ThreadLike {
turns: ThreadTurn[];
}

/**
* Rebuild the transcript from a protocol thread snapshot.
*
* The session projection the desktop used to resume from keeps only the items
* that carry a StoredMessage, so approvals, ask-user exchanges, errors and
* review findings were persisted in the snapshot and then never shown again.
* This reads the snapshot itself, so a resumed conversation looks like the one
* that was interrupted.
*/
export function threadToMsgs(thread: ThreadLike): Msg[] {
let msgs: Msg[] = [];
const str = (value: unknown): string => (typeof value === 'string' ? value : '');

for (const turn of thread.turns) {
for (const item of turn.items) {
switch (item.type) {
case 'user_message':
if (str(item.payload.text)) msgs.push({ role: 'user', text: str(item.payload.text) });
break;

case 'assistant_message':
case 'tool_result': {
const message = item.payload.message as StoredLine | undefined;
if (Array.isArray(message?.content)) msgs = appendStoredLine(msgs, message);
break;
}

case 'approval': {
const tool = str(item.payload.toolName) || 'tool';
const decision = str(item.payload.decision) || 'answered';
msgs.push({ role: 'system', text: `⏸ ${tool} — ${decision}` });
break;
}

case 'ask_user': {
const question = str(item.payload.question);
const answer = str(item.payload.answer);
msgs.push({ role: 'system', text: `❯ ${question}${answer ? ` → ${answer}` : ''}` });
break;
}

case 'review_finding':
msgs.push({
role: 'system',
text: `⚑ ${str(item.payload.path)}${
typeof item.payload.startLine === 'number' ? `:${item.payload.startLine}` : ''
} — ${str(item.payload.title)}`,
});
break;

case 'review_action':
msgs.push({
role: 'system',
text: `${str(item.payload.kind) === 'revert' ? '↩' : '✎'} review ${str(
item.payload.kind,
)} · ${(item.payload.findingIds as string[] | undefined)?.length ?? 0} finding(s)`,
});
break;

case 'error':
msgs.push({
role: 'system',
text: str(item.payload.message) || 'Turn failed.',
level: 'error',
});
break;
}
}
}
return msgs;
}

/** Review findings and actions carried by a resumed thread, for the Changes panel. */
export function threadReviewItems(thread: ThreadLike): {
findings: Record<string, unknown>[];
actions: Record<string, unknown>[];
} {
const findings: Record<string, unknown>[] = [];
const actions: Record<string, unknown>[] = [];
for (const turn of thread.turns) {
for (const item of turn.items) {
if (item.type === 'review_finding') findings.push(item.payload);
else if (item.type === 'review_action') actions.push(item.payload);
}
}
return { findings, actions };
}
29 changes: 28 additions & 1 deletion apps/desktop/src/lib/use-changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export interface UseChanges {
apply: (findings: ReviewFinding[]) => Promise<void>;
revert: (actionId: string) => Promise<void>;
clear: () => void;
/** Seed findings/actions carried by a resumed thread. */
adopt: (items: {
findings: Record<string, unknown>[];
actions: Record<string, unknown>[];
}) => void;
}

interface BusEvent {
Expand Down Expand Up @@ -103,5 +108,27 @@ export function useChanges(): UseChanges {
const toggleFile = useCallback((path: string) => dispatch({ type: 'toggle-file', path }), []);
const clear = useCallback(() => dispatch({ type: 'cleared' }), []);

return { state, refresh, toggleFile, apply, revert, clear };
// Resuming replays the thread's review items so the panel shows what the
// conversation already found, not an empty list over a repo full of changes.
const adopt = useCallback(
(items: { findings: Record<string, unknown>[]; actions: Record<string, unknown>[] }) => {
dispatch({ type: 'cleared' });
for (const finding of items.findings) {
dispatch({ type: 'finding', finding: finding as unknown as ReviewFinding });
}
for (const action of items.actions) {
dispatch({
type: 'action',
action: {
actionId: String(action.actionId ?? ''),
findingIds: Array.isArray(action.findingIds) ? action.findingIds.map(String) : [],
kind: action.kind === 'revert' ? 'revert' : 'apply',
},
});
}
},
[],
);

return { state, refresh, toggleFile, apply, revert, clear, adopt };
}
8 changes: 6 additions & 2 deletions apps/desktop/src/lib/window-shim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,18 @@ export function installTauriShim(): void {
}));
},
async resume({ id }) {
await resumeProtocolThread(id);
// The snapshot carries every completed item — approvals, ask-user
// exchanges, errors, review findings. The session projection keeps only
// the message-bearing ones, so resuming from it silently dropped the
// rest even though they were on disk.
const thread = await resumeProtocolThread(id);
const lines = await sessionRead(id);
const history = lines.map((l) => ({
role: l.role,
content: l.content,
timestamp: l.timestamp ?? '',
})) as unknown as import('@deepcode/core/dist/types.js').StoredMessage[];
return { history, sessionId: id };
return { history, sessionId: id, thread };
},
},
plugins: {
Expand Down
Loading
Loading