release 1.0.1: fix resume showing a blank transcript
A resumed session loaded its wire messages but never rebuilt the on-screen history, so -r/-c and /resume rendered a blank transcript. Convert the stored messages back into transcript lines and seed the panel with them at mount and after /resume.
This commit is contained in:
+8
-4
@@ -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
|
[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).
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [1.0.1]
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
### Added
|
### 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
|
- **`/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
|
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
|
`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
|
### 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
|
- 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.
|
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
|
- A handful of plugin write tools were mutating but not gated by the permission defaults; the
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "shiro-neko",
|
"name": "shiro-neko",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
+13
-3
@@ -36,7 +36,7 @@ import { CommandMenu, InstallConfirm, Picker } from './Pickers';
|
|||||||
import { contextPanel, costPanel, todosPanel, toolsPanel } from './panel-bodies';
|
import { contextPanel, costPanel, todosPanel, toolsPanel } from './panel-bodies';
|
||||||
import { PromptInput } from './PromptInput';
|
import { PromptInput } from './PromptInput';
|
||||||
import { accent, glyph } from './theme';
|
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 { createApprovalBridge, createNoticeBus, createSubagentBus, applySubagentEvent };
|
||||||
export type { ApprovalBridge, NoticeBus, SubagentBus };
|
export type { ApprovalBridge, NoticeBus, SubagentBus };
|
||||||
@@ -128,7 +128,11 @@ export function App({
|
|||||||
stdout.off('resize', onResize);
|
stdout.off('resize', onResize);
|
||||||
};
|
};
|
||||||
}, [stdout]);
|
}, [stdout]);
|
||||||
const [history, setHistory] = useState<Line[]>([]);
|
// 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<Line[]>(() =>
|
||||||
|
session.messages.length === 0 ? [] : historyFromMessages(session.messages as { role?: string; content?: unknown }[]),
|
||||||
|
);
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
const [live, setLive] = useState('');
|
const [live, setLive] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -684,7 +688,13 @@ export function App({
|
|||||||
push({ kind: 'user', text: chosen.trim() });
|
push({ kind: 'user', text: chosen.trim() });
|
||||||
try {
|
try {
|
||||||
const msg = await hooks.resumeSession(action.id);
|
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 });
|
push({ kind: 'info', text: msg });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||||
|
|||||||
@@ -197,6 +197,102 @@ export function withResult(lines: Line[], name: string, result: string, ok: bool
|
|||||||
return lines;
|
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. */
|
/** A task list as markdown, for the `/todos` panel. */
|
||||||
export const todoLines = (todos: readonly { status: keyof typeof TODO_MARK; content: string; note?: string }[]) =>
|
export const todoLines = (todos: readonly { status: keyof typeof TODO_MARK; content: string; note?: string }[]) =>
|
||||||
todos.length > 0
|
todos.length > 0
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
* fails inside the shipped binary. A constant is compiled in and always correct.
|
* 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.
|
* `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. */
|
/** What `--version` prints: enough to identify a build from a bug report. */
|
||||||
export function versionLine(): string {
|
export function versionLine(): string {
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user