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
1 change: 1 addition & 0 deletions lessons.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
- Never live-handoff, stop, replace, or start a server on the user's active Herdr config/socket while diagnosing or smoke-testing Chat, even if a previous handoff appeared successful. Use a separate `HERDR_CONFIG_PATH`, data directory, socket, and named test session; runtime mutation requires explicit user consent.
- Treat the TODO as session-scoped state that survives resumed turns; pin the latest plan above Working even when its `plan_update` belongs to the immediately preceding interrupted turn.
- Preserve the user's explicitly requested UI copy and casing while fixing behavior; do not silently reinterpret a concrete format request based on incidental typography.
- For live Chat TODOs, treat every newest/newer read as an incomplete drain until a terminal page succeeds; metadata must trigger retry on any revision mismatch, and history backfill must preserve the live cursor.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "drover",
"productName": "Drover",
"version": "0.1.22",
"version": "0.1.23",
"description": "A native workspace for Herdr-powered agents, terminals, worktrees, and live sessions.",
"main": ".vite/build/main.js",
"private": true,
Expand Down
7 changes: 7 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,13 @@ function registerIpcHandlers(): void {
}
return engine.conversationRead(parseConversationReadRequest(candidate));
});
ipcMain.handle(IPC_CHANNELS.conversationMetadata, async (event, candidate: unknown) => {
assertTrustedSender(event.senderFrame?.url);
if (demoMode) {
throw new Error('Structured Chat is unavailable in demo mode.');
}
return engine.conversationMetadata(parseConversationReadRequest(candidate));
});

ipcMain.handle(IPC_CHANNELS.conversationPrompt, async (event, candidate: unknown) => {
assertTrustedSender(event.senderFrame?.url);
Expand Down
41 changes: 41 additions & 0 deletions src/main/herdr/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,47 @@ describe('HerdrEngine.execute', () => {
});
});

describe('HerdrEngine.conversationMetadata', () => {
it('uses the metadata endpoint without allocating a conversation cursor', async () => {
const runner = createRunner(async () => ({
stdout: JSON.stringify(runningStatus),
stderr: '',
}));
const requestClient: HerdrRequestClient = {
request: vi.fn(async () => ({
type: 'agent_conversation_read',
read: {
type: 'page',
page: {
provider: 'codex',
session: { id: 'session-1' },
capability: { availability: 'supported', reason: 'ready' },
items: [],
has_older: false,
revision: 12,
reader_generation: 'generation-1',
},
},
})),
};
const engine = new HerdrEngine(
runner,
{ launch: vi.fn() },
async () => undefined,
requestClient,
);

const result = await engine.conversationMetadata({ target: 'w1:p1' });

expect(requestClient.request).toHaveBeenCalledWith(
'/tmp/herdr.sock',
'agent.conversation.metadata',
{ target: 'w1:p1' },
);
expect(result).toMatchObject({ type: 'page', page: { revision: 12 } });
});
});

describe('HerdrEngine.query', () => {
it.each([
{
Expand Down
7 changes: 7 additions & 0 deletions src/main/herdr/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,13 @@ export class HerdrEngine {
});
return decodeConversationReadResult(result);
}
async conversationMetadata(request: ConversationReadRequest): Promise<ConversationReadResult> {
const socket = await this.conversationSocket();
const result = await this.requestClient.request(socket, 'agent.conversation.metadata', {
target: request.target,
});
return decodeConversationReadResult(result);
}

async conversationPrompt(request: ConversationPromptRequest): Promise<EngineBootstrap> {
const socket = await this.conversationSocket();
Expand Down
5 changes: 5 additions & 0 deletions src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ const api: DroverApi = {
conversation: Object.freeze({
read: (request: ConversationReadRequest) =>
ipcRenderer.invoke(IPC_CHANNELS.conversationRead, request) as Promise<ConversationReadResult>,
metadata: (request: ConversationReadRequest) =>
ipcRenderer.invoke(
IPC_CHANNELS.conversationMetadata,
request,
) as Promise<ConversationReadResult>,
prompt: (request: ConversationPromptRequest) =>
ipcRenderer.invoke(IPC_CHANNELS.conversationPrompt, request) as Promise<EngineBootstrap>,
respond: (request: ConversationRespondRequest) =>
Expand Down
1 change: 1 addition & 0 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,7 @@ function PaneStage({
<ConversationChatPanel
onOpenTerminal={() => onViewChange(item.pane_id, 'terminal')}
pane={item}
visible={view === 'chat'}
/>
</div>
) : null}
Expand Down
176 changes: 176 additions & 0 deletions src/renderer/chat/ConversationChatPanel.presentation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ describe('ConversationChatPanel turn projection', () => {
expect(setupResult.read).toHaveBeenLastCalledWith({
target: 'w1:p1',
direction: 'newer',
limit: 256,
cursor: 'cursor-1',
});

Expand Down Expand Up @@ -393,6 +394,181 @@ describe('ConversationChatPanel turn projection', () => {
await waitFor(() => expect(screen.queryByText('Mirror terminal TODO')).not.toBeInTheDocument());
expect(document.querySelector('[data-slot="active-plan"]')).toBeNull();
});
it('requests the full conversation tail so an active TODO outside the default page remains visible', async () => {
const started: ConversationItem = {
id: 'started-tail',
sequence: 1,
provider: 'omp',
session_id: 'session-1',
turn_id: 'turn-1',
type: 'turn_state',
state: 'started',
started_ms: Date.now(),
};
const plan: ConversationItem = {
id: 'plan-tail',
sequence: 2,
provider: 'omp',
session_id: 'session-1',
turn_id: 'turn-1',
type: 'plan_update',
steps: [
{ label: 'Recover the active TODO', status: 'active' },
{ label: 'Keep it synchronized', status: 'pending' },
],
};
const read = vi.fn<Window['herdr']['conversation']['read']>();
read.mockImplementation(async (request) =>
page(request.limit === 256 ? [started, plan] : [started]),
);

setup(page([started]), { read });

expect(await screen.findByText('Recover the active TODO')).toBeVisible();
expect(read).toHaveBeenCalledWith({
target: 'w1:p1',
direction: 'newest',
limit: 256,
});
});

it('backfills older pages when the active TODO is outside the newest page', async () => {
const initialItems: ConversationItem[] = [
...Array.from({ length: 255 }, (_, index) =>
assistant(index + 2, 'final', `tail item ${index}`),
),
{
id: 'started-tail',
sequence: 257,
provider: 'pi',
session_id: 'session-1',
turn_id: 'turn-1',
type: 'turn_state',
state: 'started',
},
];
const plan: ConversationItem = {
id: 'plan-outside-tail',
sequence: 1,
provider: 'pi',
session_id: 'session-1',
turn_id: 'turn-1',
type: 'plan_update',
steps: [
{ label: 'Recover an old TODO', status: 'active' },
{ label: 'Keep it visible', status: 'pending' },
],
};
const read = vi
.fn<Window['herdr']['conversation']['read']>()
.mockImplementation(async (request) =>
request.direction === 'older'
? page([plan])
: page(initialItems, { previousCursor: 'older-plan', nextCursor: 'newer-tail' }),
);

setup(page(initialItems, { previousCursor: 'older-plan', nextCursor: 'newer-tail' }), { read });

expect((await screen.findAllByText('Recover an old TODO')).length).toBeGreaterThan(0);
expect(read).toHaveBeenNthCalledWith(2, {
target: 'w1:p1',
direction: 'older',
limit: 256,
cursor: 'older-plan',
});
});
it('refreshes the live cursor while backfilling an old TODO boundary', async () => {
const started: ConversationItem = {
id: 'started-long-history',
sequence: 1,
provider: 'pi',
session_id: 'session-1',
turn_id: 'turn-1',
type: 'turn_state',
state: 'started',
};
const plan: ConversationItem = {
id: 'plan-after-long-history',
sequence: 65,
provider: 'pi',
session_id: 'session-1',
turn_id: 'turn-1',
type: 'plan_update',
steps: [{ label: 'Recover TODO beyond sixty-four pages', status: 'active' }],
};
let olderCalls = 0;
let newestCalls = 0;
const read = vi
.fn<Window['herdr']['conversation']['read']>()
.mockImplementation(async (request) => {
if (request.direction !== 'older') {
newestCalls += 1;
return page([started], {
previousCursor: newestCalls === 1 ? 'history-0' : 'history-refresh',
nextCursor: newestCalls === 1 ? 'live-0' : 'live-refresh',
});
}
olderCalls += 1;
return olderCalls === 65
? page([plan])
: page([], { previousCursor: `history-${olderCalls}` });
});

setup(page([started], { previousCursor: 'history-0', nextCursor: 'live-0' }), { read });

expect(await screen.findByText('Recover TODO beyond sixty-four pages')).toBeVisible();
expect(olderCalls).toBe(65);
expect(newestCalls).toBeGreaterThan(1);
});
it('hydrates an active TODO when only a running tool is in the newest page', async () => {
const runningTool: ConversationItem = {
id: 'running-tool-tail',
sequence: 256,
provider: 'pi',
session_id: 'session-1',
turn_id: 'turn-1',
type: 'tool_activity',
action: 'long-running-command',
label: 'running',
status: 'running',
};
const initialItems = [
...Array.from({ length: 255 }, (_, index) =>
assistant(index + 1, 'final', `history item ${index}`),
),
runningTool,
];
const plan: ConversationItem = {
id: 'plan-running-tool',
sequence: 1,
provider: 'pi',
session_id: 'session-1',
turn_id: 'turn-1',
type: 'plan_update',
steps: [{ label: 'Recover TODO from running tool', status: 'active' }],
};
const read = vi
.fn<Window['herdr']['conversation']['read']>()
.mockImplementation(async (request) =>
request.direction === 'older'
? page([plan])
: page(initialItems, { previousCursor: 'older-running', nextCursor: 'live-running' }),
);

setup(page(initialItems, { previousCursor: 'older-running', nextCursor: 'live-running' }), {
read,
});

expect((await screen.findAllByText('Recover TODO from running tool')).length).toBeGreaterThan(
0,
);
expect(read).toHaveBeenNthCalledWith(2, {
target: 'w1:p1',
direction: 'older',
limit: 256,
cursor: 'older-running',
});
});

it('preserves the pane working duration across chat view remounts', async () => {
let now = 1_800_000_000_000;
Expand Down
Loading