Skip to content

Commit 9eae9bf

Browse files
fix(console): refresh agent list and reselect after rename (#375)
Closes #374
1 parent 66d2ea6 commit 9eae9bf

3 files changed

Lines changed: 247 additions & 4 deletions

File tree

‎packages/cli/src/__tests__/tui/console/hooks/useRenameAgentPane.test.ts‎

Lines changed: 237 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,46 @@
1-
import { describe, expect, it } from "vitest";
2-
import { getRenameActionError } from "../../../../tui/console/hooks/useRenameAgentPane.js";
1+
import { PassThrough } from "node:stream";
2+
import React, { useState } from "react";
3+
import { render } from "ink";
4+
import { describe, expect, it, vi, beforeEach } from "vitest";
5+
import type { AgentInfo, AgentManager } from "@ai-devkit/agent-manager";
6+
import type { DaemonEvent } from "@ai-devkit/daemon-client";
7+
import type { ActionResult } from "../../../../tui/console/actions/runAction.js";
8+
import type { RightPaneMode, TransientMessage } from "../../../../tui/console/types.js";
9+
10+
let daemonClient: {
11+
subscribe: (h: (e: DaemonEvent) => void) => Promise<void>;
12+
close: () => void;
13+
} | null = null;
14+
15+
vi.mock("@ai-devkit/daemon-client", () => ({
16+
ensureDaemon: () => Promise.resolve(daemonClient),
17+
DaemonClient: { tryConnect: () => Promise.resolve(null) },
18+
}));
19+
20+
vi.mock("../../../../tui/console/actions/runAction.js", () => ({
21+
runAction: vi.fn(),
22+
}));
23+
24+
import { runAction } from "../../../../tui/console/actions/runAction.js";
25+
import {
26+
getRenameActionError,
27+
useRenameAgentPane,
28+
} from "../../../../tui/console/hooks/useRenameAgentPane.js";
29+
import {
30+
ConsoleProvider,
31+
useConsoleAgentContext,
32+
} from "../../../../tui/console/state/ConsoleContext.js";
33+
34+
const runActionMock = vi.mocked(runAction);
35+
36+
function renderInk(element: React.ReactElement) {
37+
const output = new PassThrough();
38+
return render(element, {
39+
stdout: output as unknown as NodeJS.WriteStream,
40+
interactive: false,
41+
patchConsole: false,
42+
});
43+
}
344

445
describe("getRenameActionError", () => {
546
it("returns explicit subprocess errors", () => {
@@ -16,3 +57,197 @@ describe("getRenameActionError", () => {
1657
expect(getRenameActionError({ exitCode: 0 })).toBeNull();
1758
});
1859
});
60+
61+
interface RenameHarness {
62+
submit: (currentName: string, newName: string) => void;
63+
rightPaneMode: () => RightPaneMode;
64+
transient: () => TransientMessage | null;
65+
error: () => string | null;
66+
calls: string[];
67+
refresh: () => Promise<void>;
68+
selectAgent: (name: string) => void;
69+
}
70+
71+
function renderRenameHarness(): RenameHarness {
72+
const calls: string[] = [];
73+
const refresh = vi.fn(async () => {
74+
calls.push("refresh");
75+
});
76+
const selectAgent = vi.fn((name: string) => {
77+
calls.push(`select:${name}`);
78+
});
79+
let submitFn: (currentName: string, newName: string) => void = () => {};
80+
let mode: RightPaneMode = { type: "preview" };
81+
let msg: TransientMessage | null = null;
82+
let err: string | null = null;
83+
84+
const Harness: React.FC = () => {
85+
const [rightPaneMode, setRightPaneMode] = useState<RightPaneMode>({ type: "preview" });
86+
const [transient, setTransient] = useState<TransientMessage | null>(null);
87+
const [, setFocus] = useState("list");
88+
const hook = useRenameAgentPane({
89+
refresh,
90+
selectAgent,
91+
setFocus,
92+
setRightPaneMode,
93+
setTransient,
94+
});
95+
submitFn = (currentName, newName) => hook.handleRenameSubmit(currentName, { newName });
96+
mode = rightPaneMode;
97+
msg = transient;
98+
err = hook.renamePaneError;
99+
return null;
100+
};
101+
renderInk(React.createElement(Harness));
102+
return {
103+
submit: (currentName, newName) => submitFn(currentName, newName),
104+
rightPaneMode: () => mode,
105+
transient: () => msg,
106+
error: () => err,
107+
calls,
108+
refresh,
109+
selectAgent,
110+
};
111+
}
112+
113+
describe("useRenameAgentPane", () => {
114+
beforeEach(() => {
115+
runActionMock.mockReset();
116+
daemonClient = null;
117+
});
118+
119+
it("refreshes the list then selects the new name only after a successful rename", async () => {
120+
runActionMock.mockResolvedValue({ exitCode: 0 } satisfies ActionResult);
121+
const h = renderRenameHarness();
122+
123+
h.submit("old-name", "new-name");
124+
125+
await vi.waitFor(() => {
126+
expect(h.calls).toEqual(["refresh", "select:new-name"]);
127+
});
128+
expect(h.rightPaneMode()).toEqual({ type: "preview" });
129+
expect(h.transient()).toEqual({
130+
kind: "info",
131+
text: "Renamed old-name to new-name",
132+
});
133+
expect(h.error()).toBeNull();
134+
});
135+
136+
it("does not refresh or change selection when the rename fails", async () => {
137+
runActionMock.mockResolvedValue({ exitCode: 1, error: "name already exists" });
138+
const h = renderRenameHarness();
139+
140+
h.submit("old-name", "new-name");
141+
142+
await vi.waitFor(() => {
143+
expect(h.error()).toBe("name already exists");
144+
});
145+
expect(h.refresh).not.toHaveBeenCalled();
146+
expect(h.selectAgent).not.toHaveBeenCalled();
147+
expect(h.transient()).toBeNull();
148+
});
149+
});
150+
151+
function agentFixture(name: string): AgentInfo {
152+
return {
153+
name,
154+
status: "running",
155+
type: "codex",
156+
lastActive: new Date("2026-01-01T00:00:00Z"),
157+
} as AgentInfo;
158+
}
159+
160+
interface ConsoleRenameHarness {
161+
submit: (currentName: string, newName: string) => void;
162+
agents: () => AgentInfo[];
163+
selected: () => string | null;
164+
}
165+
166+
function renderConsoleRenameHarness(manager: AgentManager): ConsoleRenameHarness {
167+
let submitFn: (currentName: string, newName: string) => void = () => {};
168+
let currentAgents: AgentInfo[] = [];
169+
let currentSelected: string | null = "old-name";
170+
171+
const Consumer: React.FC = () => {
172+
const { agents, refresh } = useConsoleAgentContext();
173+
const [selected, setSelected] = useState<string | null>("old-name");
174+
const hook = useRenameAgentPane({
175+
refresh,
176+
selectAgent: setSelected,
177+
setFocus: () => {},
178+
setRightPaneMode: () => {},
179+
setTransient: () => {},
180+
});
181+
submitFn = (currentName, newName) => hook.handleRenameSubmit(currentName, { newName });
182+
currentAgents = agents;
183+
currentSelected = selected;
184+
return null;
185+
};
186+
187+
renderInk(
188+
React.createElement(
189+
ConsoleProvider,
190+
{ manager, inputFocused: false },
191+
React.createElement(Consumer),
192+
),
193+
);
194+
return {
195+
submit: (currentName, newName) => submitFn(currentName, newName),
196+
agents: () => currentAgents,
197+
selected: () => currentSelected,
198+
};
199+
}
200+
201+
describe("console rename refresh integration", () => {
202+
beforeEach(() => {
203+
runActionMock.mockReset();
204+
daemonClient = null;
205+
});
206+
207+
it("refreshes the visible list and reselects the new name without a daemon", async () => {
208+
const manager = {
209+
listAgents: vi
210+
.fn()
211+
.mockResolvedValueOnce([agentFixture("old-name")])
212+
.mockResolvedValue([agentFixture("new-name")]),
213+
} as unknown as AgentManager;
214+
const h = renderConsoleRenameHarness(manager);
215+
216+
await vi.waitFor(() => expect(h.agents().map((a) => a.name)).toEqual(["old-name"]));
217+
runActionMock.mockResolvedValue({ exitCode: 0 });
218+
219+
h.submit("old-name", "new-name");
220+
221+
await vi.waitFor(() => {
222+
expect(h.agents().map((a) => a.name)).toEqual(["new-name"]);
223+
expect(h.selected()).toBe("new-name");
224+
});
225+
expect(manager.listAgents).toHaveBeenCalledTimes(2);
226+
});
227+
228+
it("still refreshes immediately when the daemon subscription relaxes polling", async () => {
229+
daemonClient = {
230+
subscribe: () => Promise.resolve(),
231+
close: vi.fn(),
232+
};
233+
const manager = {
234+
listAgents: vi
235+
.fn()
236+
.mockResolvedValueOnce([agentFixture("old-name")])
237+
.mockResolvedValue([agentFixture("new-name")]),
238+
} as unknown as AgentManager;
239+
const h = renderConsoleRenameHarness(manager);
240+
241+
await vi.waitFor(() => expect(h.agents().map((a) => a.name)).toEqual(["old-name"]));
242+
// Give the daemon attach a tick to subscribe and relax the poll to 60s.
243+
await new Promise((resolve) => setTimeout(resolve, 10));
244+
runActionMock.mockResolvedValue({ exitCode: 0 });
245+
246+
h.submit("old-name", "new-name");
247+
248+
await vi.waitFor(() => {
249+
expect(h.agents().map((a) => a.name)).toEqual(["new-name"]);
250+
expect(h.selected()).toBe("new-name");
251+
});
252+
});
253+
});

‎packages/cli/src/tui/console/ConsoleApp.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,11 @@ const ConsoleAppShell: React.FC<{
202202
handleRenameCancel,
203203
handleRenameSubmit,
204204
} = useRenameAgentPane({
205+
refresh,
205206
setFocus,
206207
setRightPaneMode,
207208
setTransient,
209+
selectAgent: setSelectedName,
208210
});
209211

210212
const { openChannelSelect, startChannel, stopAgentChannel } = useChannelActions({

‎packages/cli/src/tui/console/hooks/useRenameAgentPane.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { runAction, type ActionResult } from "../actions/runAction.js";
33
import type { ConsoleFocus, RightPaneMode, TransientMessage } from "../types.js";
44

55
interface UseRenameAgentPaneOptions {
6+
refresh: () => Promise<void>;
67
setFocus: Dispatch<SetStateAction<ConsoleFocus>>;
78
setRightPaneMode: Dispatch<SetStateAction<RightPaneMode>>;
89
setTransient: Dispatch<SetStateAction<TransientMessage | null>>;
10+
selectAgent: (name: string) => void;
911
}
1012

1113
interface RenameAgentValues {
@@ -19,9 +21,11 @@ export function getRenameActionError(result: ActionResult): string | null {
1921
}
2022

2123
export function useRenameAgentPane({
24+
refresh,
2225
setFocus,
2326
setRightPaneMode,
2427
setTransient,
28+
selectAgent,
2529
}: UseRenameAgentPaneOptions) {
2630
const [renamePaneError, setRenamePaneError] = useState<string | null>(null);
2731
const [isRenamingAgent, setIsRenamingAgent] = useState(false);
@@ -47,20 +51,22 @@ export function useRenameAgentPane({
4751
setIsRenamingAgent(true);
4852
setRenamePaneError(null);
4953
void runAction({ type: "rename", currentName, newName: values.newName })
50-
.then((result) => {
54+
.then(async (result) => {
5155
const error = getRenameActionError(result);
5256
if (error) {
5357
setRenamePaneError(error);
5458
return;
5559
}
5660
setRightPaneMode({ type: "preview" });
5761
setTransient({ kind: "info", text: `Renamed ${currentName} to ${values.newName}` });
62+
await refresh();
63+
selectAgent(values.newName);
5864
})
5965
.finally(() => {
6066
setIsRenamingAgent(false);
6167
});
6268
},
63-
[isRenamingAgent, setRightPaneMode, setTransient],
69+
[isRenamingAgent, refresh, selectAgent, setRightPaneMode, setTransient],
6470
);
6571

6672
return {

0 commit comments

Comments
 (0)