diff --git a/CHANGELOG.md b/CHANGELOG.md index a879239..589771a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,14 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] - -A batch of loop and ergonomics work landed after 1.0.0. Version remains `1.0.0` in -`src/version.ts`; these are folded into the next tagged release. +## [1.0.1] ### Added +- **Rendered, resumed history.** A session restored with `-r`/`-c` or `/resume` now shows + its saved conversation as real transcript lines instead of a blank prompt, converting the + stored wire messages (user, assistant, tool calls and their results) into the same view the + live loop paints. - **`/undo` and `/redo`.** Every prompt snapshots the files on disk first (capped near the last 100), and `/undo` restores files, trims the conversation, or both. `/redo` reverses it. A `bash` command's side effects are not files and cannot be rolled back, which is stated in the @@ -30,6 +31,9 @@ A batch of loop and ergonomics work landed after 1.0.0. Version remains `1.0.0` ### Fixed +- A resumed session rendered an empty transcript. Loading a saved session populated the wire + messages but never rebuilt the on-screen history, so after `-r`/`-c` or `/resume` the talk + was blank even though the session data was there. - The walk behind `@file` completion refreshed only once per session; it now re-walks on a slow cooldown so a file created after the first `@` shows up within a short window. - A handful of plugin write tools were mutating but not gated by the permission defaults; the diff --git a/package.json b/package.json index efa22f8..967a1c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shiro-neko", - "version": "1.0.0", + "version": "1.0.1", "type": "module", "private": true, "bin": { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index cb405a8..e38d20c 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -36,7 +36,7 @@ import { CommandMenu, InstallConfirm, Picker } from './Pickers'; import { contextPanel, costPanel, todosPanel, toolsPanel } from './panel-bodies'; import { PromptInput } from './PromptInput'; import { accent, glyph } from './theme'; -import { nextKey, resultSummary, toolDetail, withResult, type Line, type NewLine } from './transcript'; +import { historyFromMessages, nextKey, resultSummary, toolDetail, withResult, type Line, type NewLine } from './transcript'; export { createApprovalBridge, createNoticeBus, createSubagentBus, applySubagentEvent }; export type { ApprovalBridge, NoticeBus, SubagentBus }; @@ -128,7 +128,11 @@ export function App({ stdout.off('resize', onResize); }; }, [stdout]); - const [history, setHistory] = useState([]); + // Seeded from the resumed history: a session loaded with -r/-c should show its + // saved conversation rather than a blank transcript. Only read once, at mount. + const [history, setHistory] = useState(() => + session.messages.length === 0 ? [] : historyFromMessages(session.messages as { role?: string; content?: unknown }[]), + ); const [draft, setDraft] = useState(''); const [live, setLive] = useState(''); const [busy, setBusy] = useState(false); @@ -684,7 +688,13 @@ export function App({ push({ kind: 'user', text: chosen.trim() }); try { const msg = await hooks.resumeSession(action.id); - setHistory([]); + // Reflect the freshly loaded history: session.messages now holds the + // restored wire messages, and the transcript must show them again. + setHistory( + session.messages.length === 0 + ? [] + : historyFromMessages(session.messages as { role?: string; content?: unknown }[]), + ); push({ kind: 'info', text: msg }); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); diff --git a/src/ui/transcript.ts b/src/ui/transcript.ts index 13d497a..19ac8cf 100644 --- a/src/ui/transcript.ts +++ b/src/ui/transcript.ts @@ -197,6 +197,102 @@ export function withResult(lines: Line[], name: string, result: string, ok: bool return lines; } +/** + * The tool output that was stored on a `tool-result`. The SDK json-wraps a + * string return as `{ type: 'text', value }`, so a restored message needs the + * same unwrap the live stream already produced at save time. + */ +function toolResultText(output: unknown): string { + if (typeof output === 'string') return output; + if ( + output !== null && + typeof output === 'object' && + 'value' in output && + typeof (output as { value: unknown }).value === 'string' + ) { + return (output as { value: string }).value; + } + try { + return JSON.stringify(output); + } catch { + return String(output); + } +} + +type StoredPart = { + type?: string; + toolName?: string; + input?: unknown; + output?: unknown; + text?: unknown; +}; + +/** + * The transcript lines a saved `ModelMessage[]` becomes, so a resumed session + * renders its history instead of starting blank. + * + * Mirrors how the live loop paints: user strings as user lines, assistant text + * as an assistant line, assistant `tool-call` parts as tool lines, and each + * `role: 'tool'` result attached to the newest unanswered call of that name just + * like `withResult` does. A result with no matching call (a pruned lead-in) is + * dropped rather than left floating. + */ +export function historyFromMessages(messages: readonly { role?: string; content?: unknown }[]): Line[] { + const lines: Line[] = []; + + const textOf = (content: unknown): string => + typeof content === 'string' + ? content + : Array.isArray(content) + ? (content as StoredPart[]).filter((p) => p.type === 'text' && typeof p.text === 'string').map((p) => p.text as string).join('') + : ''; + + const toolPartsOf = (content: unknown): StoredPart[] => + Array.isArray(content) ? (content as StoredPart[]).filter((p) => p.type === 'tool-call') : []; + + for (const m of messages) { + switch (m.role) { + case 'user': { + const text = textOf(m.content).trim(); + if (text) lines.push({ key: nextKey(), kind: 'user', text }); + break; + } + case 'assistant': { + const text = textOf(m.content).trim(); + if (text) lines.push({ key: nextKey(), kind: 'assistant', text }); + for (const p of toolPartsOf(m.content)) { + const name = p.toolName ?? ''; + if (!name) continue; + lines.push({ key: nextKey(), kind: 'tool', name, detail: toolDetail(name, p.input), ok: true }); + } + break; + } + case 'tool': { + const parts = Array.isArray(m.content) ? (m.content as StoredPart[]) : []; + for (const p of parts) { + if (p.type !== 'tool-result' && p.type !== 'tool-error') continue; + const name = p.toolName ?? ''; + const result = + p.type === 'tool-error' + ? toolResultText(p.output) || 'tool failed' + : resultSummary(name, toolResultText(p.output)); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]!; + if (line.kind !== 'tool' || line.name !== name || line.result !== undefined) continue; + lines[i] = { ...line, result, ok: p.type !== 'tool-error' }; + break; + } + } + break; + } + default: + break; + } + } + + return lines; +} + /** A task list as markdown, for the `/todos` panel. */ export const todoLines = (todos: readonly { status: keyof typeof TODO_MARK; content: string; note?: string }[]) => todos.length > 0 diff --git a/src/version.ts b/src/version.ts index 6acc053..803be09 100644 --- a/src/version.ts +++ b/src/version.ts @@ -5,7 +5,7 @@ * fails inside the shipped binary. A constant is compiled in and always correct. * `scripts/release.ts` checks it against the release tag so the two cannot drift. */ -export const VERSION = '1.0.0'; +export const VERSION = '1.0.1'; /** What `--version` prints: enough to identify a build from a bug report. */ export function versionLine(): string { diff --git a/test/transcript.test.ts b/test/transcript.test.ts new file mode 100644 index 0000000..f8be316 --- /dev/null +++ b/test/transcript.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from 'bun:test'; +import { historyFromMessages } from '../src/ui/transcript'; +import type { Line } from '../src/ui/transcript'; + +const kind = (lines: Line[]) => lines.map((l) => l.kind); + +test('historyFromMessages maps a saved conversation into transcript lines', () => { + const lines = historyFromMessages([ + { role: 'user', content: 'fix the pagination test' }, + { role: 'assistant', content: 'Looking at the suite' }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'Found it.' }, + { + type: 'tool-call', + toolCallId: 't1', + toolName: 'edit_file', + input: { path: 'test/PageTest.php', oldString: 'a', newString: 'b' }, + }, + ], + }, + { + role: 'tool', + content: [{ type: 'tool-result', toolCallId: 't1', toolName: 'edit_file', output: { type: 'text', value: 'Replaced 1 occurrence' } }], + }, + ]); + + // user + assistant text + assistant text part + tool call + existing call line + expect(kind(lines)).toEqual(['user', 'assistant', 'assistant', 'tool']); + const tool = lines.at(-1)!; + expect(tool.kind).toBe('tool'); + if (tool.kind === 'tool') { + expect(tool.result).toBe('Replaced 1 occurrence'); + expect(tool.ok).toBe(true); + } +}); + +test('historyFromMessages unwraps SDK-wrapped and json-wrapped tool outputs', () => { + const lines = historyFromMessages([ + { + role: 'assistant', + content: [{ type: 'tool-call', toolCallId: 'g1', toolName: 'grep', input: { pattern: 'TODO' } }], + }, + { + role: 'tool', + content: [{ type: 'tool-result', toolCallId: 'g1', toolName: 'grep', output: { type: 'text', value: 'src/a.ts:1: TODO' } }], + }, + ]); + const line = lines[0]!; + expect(line.kind).toBe('tool'); + if (line.kind === 'tool') expect(line.result).toBe('1 hit'); +}); + +test('historyFromMessages marks a tool-error call as failed', () => { + const lines = historyFromMessages([ + { + role: 'assistant', + content: [{ type: 'tool-call', toolCallId: 'b1', toolName: 'bash', input: { command: 'false' } }], + }, + { + role: 'tool', + content: [{ type: 'tool-error', toolCallId: 'b1', toolName: 'bash', output: { type: 'text', value: 'exit 1' } }], + }, + ]); + const line = lines[0]!; + expect(line.kind).toBe('tool'); + if (line.kind === 'tool') { + expect(line.ok).toBe(false); + expect(line.result).toBe('exit 1'); + } +}); + +test('historyFromMessages drops a tool result with no matching call (pruned lead-in)', () => { + const lines = historyFromMessages([ + { role: 'user', content: 'continue' }, + { + role: 'tool', + content: [{ type: 'tool-result', toolCallId: 'gone', toolName: 'grep', output: { type: 'text', value: 'x' } }], + }, + ]); + // Only the user line remains; the orphaned result is not floated. + expect(kind(lines)).toEqual(['user']); +}); + +test('historyFromMessages skips system and empty messages', () => { + const lines = historyFromMessages([ + { role: 'system', content: 'You are an agent.' }, + { role: 'user', content: '' }, + { role: 'assistant', content: 'ok' }, + ]); + expect(kind(lines)).toEqual(['assistant']); +}); \ No newline at end of file