Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions .changeset/rename-worker-sessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@aoagents/ao-web": minor
---

Add inline rename for worker sessions in the sidebar. Each worker row now shows a small pencil button on hover; clicking it swaps the label for an input pre-filled with the current title. Enter persists via `PATCH /api/sessions/:id`, Escape cancels, and an empty value reverts the session to its default title. The rename is written to the existing `displayName` metadata field and is now the highest-priority signal in `getSessionTitle`, so a user-chosen label always beats PR/issue titles. The session ID (`ao-N`) remains the canonical identifier — only display surfaces change. (#1647)
33 changes: 33 additions & 0 deletions packages/core/src/__tests__/metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,39 @@ describe("writeMetadata + readMetadata", () => {
const meta = readMetadata(dataDir, "app-6");
expect(meta?.displayName).toBe("Refactor session manager");
});

it("serializes and reads back displayNameUserSet flag", () => {
writeMetadata(dataDir, "app-7", {
worktree: "/tmp/w",
branch: "feat/test",
status: "working",
displayName: "PR 1466 review",
displayNameUserSet: true,
});

const content = readFileSync(join(dataDir, "app-7.json"), "utf-8");
const parsed = JSON.parse(content);
expect(parsed.displayNameUserSet).toBe(true);

const meta = readMetadata(dataDir, "app-7");
expect(meta?.displayNameUserSet).toBe(true);
});

it("omits displayNameUserSet when undefined and does not flag auto-derived sessions", () => {
writeMetadata(dataDir, "app-8", {
worktree: "/tmp/w",
branch: "feat/test",
status: "working",
displayName: "Auto-derived at spawn",
});

const content = readFileSync(join(dataDir, "app-8.json"), "utf-8");
const parsed = JSON.parse(content);
expect(parsed.displayNameUserSet).toBeUndefined();

const meta = readMetadata(dataDir, "app-8");
expect(meta?.displayNameUserSet).toBeUndefined();
});
});

describe("readMetadataRaw", () => {
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,12 @@ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMeta
pinnedSummary: raw["pinnedSummary"] as string | undefined,
userPrompt: raw["userPrompt"] as string | undefined,
displayName: raw["displayName"] as string | undefined,
displayNameUserSet:
raw["displayNameUserSet"] === "true" || raw["displayNameUserSet"] === true
? true
: raw["displayNameUserSet"] === "false" || raw["displayNameUserSet"] === false
? false
Comment thread
harshitsinghbhandari marked this conversation as resolved.
Outdated
: undefined,
};
}

Expand Down Expand Up @@ -218,7 +224,7 @@ const jsonFields = new Set([
function unflattenFromStringRecord(data: Record<string, string>): Record<string, unknown> {
const result: Record<string, unknown> = {};
const numberFields = new Set(["dashboardPort", "terminalWsPort", "directTerminalWsPort"]);
const booleanFields = new Set(["prAutoDetect"]);
const booleanFields = new Set(["prAutoDetect", "displayNameUserSet"]);

for (const [key, value] of Object.entries(data)) {
if (value === undefined || value === "") continue;
Expand Down Expand Up @@ -281,6 +287,8 @@ export function writeMetadata(
if (metadata.pinnedSummary) data["pinnedSummary"] = metadata.pinnedSummary;
if (metadata.userPrompt) data["userPrompt"] = metadata.userPrompt;
if (metadata.displayName) data["displayName"] = metadata.displayName;
if (metadata.displayNameUserSet !== undefined)
data["displayNameUserSet"] = metadata.displayNameUserSet;

atomicWriteFileSync(path, serializeMetadata(data));
}
Expand Down
23 changes: 18 additions & 5 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1773,13 +1773,26 @@ export interface SessionMetadata {
pinnedSummary?: string; // First quality summary, pinned for display stability
userPrompt?: string; // Prompt used when spawning without a tracker issue
/**
* Stable human-readable display name derived from task context at spawn time.
* Populated from issue title, user prompt, or orchestrator system prompt —
* whichever was available when the session was created. Used by the dashboard
* as a fallback above humanized branch names so sessions are identifiable
* even when PR/issue enrichment is unavailable.
* Human-readable display name for the session.
*
* Populated automatically at spawn time from the best available task context
* (issue title, user prompt, or orchestrator system prompt). Can be
* overwritten later via the dashboard rename UI — the session ID (`ao-N`)
* remains the canonical identifier; only display surfaces are affected.
*
* Whether this value should beat PR/issue titles in the dashboard depends
* on `displayNameUserSet` — auto-derived values stay below live tracker
* signals, user-set values win over them.
*/
displayName?: string;
/**
* Set to `true` when the user explicitly renamed the session via the
* dashboard. The dashboard fallback chain promotes `displayName` above
* PR/issue titles only when this flag is true, so an auto-derived spawn-time
* `displayName` doesn't shadow a live PR title for sessions the user never
* touched.
*/
displayNameUserSet?: boolean;
}

// =============================================================================
Expand Down
176 changes: 158 additions & 18 deletions packages/web/src/__tests__/api-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
SessionNotRestorableError,
createInitialCanonicalLifecycle,
createActivitySignal,
updateMetadata,
type Session,
type SessionManager,
type OrchestratorConfig,
Expand Down Expand Up @@ -209,10 +210,28 @@ vi.mock("@/lib/services", () => ({
getSCM: vi.fn(() => mockSCM),
}));

// Mock filesystem-touching core helpers so PATCH /api/sessions/:id doesn't
// write to the user's actual ~/.agent-orchestrator dir during tests. Spread
// the real module first so the rest of the test file (types, errors, etc.)
// keeps working. Factory must self-contain its mocks because vi.mock is
// hoisted above any module-level declarations.
vi.mock("@aoagents/ao-core", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
updateMetadata: vi.fn(),
getProjectSessionsDir: vi.fn(() => "/tmp/ao-test/sessions"),
readAgentReportAuditTrailAsync: vi.fn(async () => []),
};
});

// ── Import routes after mocking ───────────────────────────────────────

import { GET as sessionsGET } from "@/app/api/sessions/route";
import { GET as sessionDetailGET } from "@/app/api/sessions/[id]/route";
import {
GET as sessionDetailGET,
PATCH as sessionDetailPATCH,
} from "@/app/api/sessions/[id]/route";
import { POST as orchestratorsPOST, GET as orchestratorsGET } from "@/app/api/orchestrators/route";
import { POST as spawnPOST } from "@/app/api/spawn/route";
import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route";
Expand Down Expand Up @@ -356,7 +375,10 @@ describe("API Routes", () => {
});

it("prefers the most recently active live orchestrator for project-scoped worker navigation", async () => {
const deadLifecycle = createInitialCanonicalLifecycle("orchestrator", new Date("2026-04-19T11:00:00.000Z"));
const deadLifecycle = createInitialCanonicalLifecycle(
"orchestrator",
new Date("2026-04-19T11:00:00.000Z"),
);
deadLifecycle.session.state = "terminated";
deadLifecycle.session.reason = "runtime_missing";
deadLifecycle.session.terminatedAt = "2026-04-19T11:00:00.000Z";
Expand Down Expand Up @@ -414,7 +436,10 @@ describe("API Routes", () => {
});

it("keeps dead orchestrators as the fallback project-scoped payload when none are live", async () => {
const deadLifecycle = createInitialCanonicalLifecycle("orchestrator", new Date("2026-04-19T11:00:00.000Z"));
const deadLifecycle = createInitialCanonicalLifecycle(
"orchestrator",
new Date("2026-04-19T11:00:00.000Z"),
);
deadLifecycle.session.state = "terminated";
deadLifecycle.session.reason = "runtime_missing";
deadLifecycle.session.terminatedAt = "2026-04-19T11:00:00.000Z";
Expand Down Expand Up @@ -449,7 +474,10 @@ describe("API Routes", () => {
});

it("prefers the most recently active dead orchestrator when no live project orchestrator exists", async () => {
const olderDeadLifecycle = createInitialCanonicalLifecycle("orchestrator", new Date("2026-04-19T10:00:00.000Z"));
const olderDeadLifecycle = createInitialCanonicalLifecycle(
"orchestrator",
new Date("2026-04-19T10:00:00.000Z"),
);
olderDeadLifecycle.session.state = "terminated";
olderDeadLifecycle.session.reason = "runtime_missing";
olderDeadLifecycle.session.terminatedAt = "2026-04-19T10:00:00.000Z";
Expand All @@ -458,7 +486,10 @@ describe("API Routes", () => {
olderDeadLifecycle.runtime.reason = "process_missing";
olderDeadLifecycle.runtime.lastObservedAt = "2026-04-19T10:00:00.000Z";

const newerDeadLifecycle = createInitialCanonicalLifecycle("orchestrator", new Date("2026-04-19T11:00:00.000Z"));
const newerDeadLifecycle = createInitialCanonicalLifecycle(
"orchestrator",
new Date("2026-04-19T11:00:00.000Z"),
);
newerDeadLifecycle.session.state = "terminated";
newerDeadLifecycle.session.reason = "runtime_missing";
newerDeadLifecycle.session.terminatedAt = "2026-04-19T11:00:00.000Z";
Expand Down Expand Up @@ -522,17 +553,20 @@ describe("API Routes", () => {
},
}),
);
(mockSessionManager.listCached as ReturnType<typeof vi.fn>).mockResolvedValue(sessionsWithPRs);
(mockSessionManager.listCached as ReturnType<typeof vi.fn>).mockResolvedValue(
sessionsWithPRs,
);

const metadataSpy = vi
.spyOn(serialize, "enrichSessionsMetadata")
.mockResolvedValue(undefined);

const enrichSpy = vi
.spyOn(serialize, "enrichSessionPR")
.mockImplementation(
() => new Promise<void>((resolve) => { setTimeout(resolve, 1_000); }),
);
const enrichSpy = vi.spyOn(serialize, "enrichSessionPR").mockImplementation(
() =>
new Promise<void>((resolve) => {
setTimeout(resolve, 1_000);
}),
);

const responsePromise = sessionsGET(makeRequest("http://localhost:3000/api/sessions"));

Expand Down Expand Up @@ -594,7 +628,9 @@ describe("API Routes", () => {
},
}),
];
(mockSessionManager.listCached as ReturnType<typeof vi.fn>).mockResolvedValue(sessionsWithPRs);
(mockSessionManager.listCached as ReturnType<typeof vi.fn>).mockResolvedValue(
sessionsWithPRs,
);

const metadataSpy = vi
.spyOn(serialize, "enrichSessionsMetadata")
Expand Down Expand Up @@ -635,7 +671,8 @@ describe("API Routes", () => {
const runtimeTerminalLifecycle = createInitialCanonicalLifecycle("worker", new Date());
runtimeTerminalLifecycle.session.state = "terminated";
runtimeTerminalLifecycle.session.reason = "user_killed";
runtimeTerminalLifecycle.session.terminatedAt = runtimeTerminalLifecycle.session.lastTransitionAt;
runtimeTerminalLifecycle.session.terminatedAt =
runtimeTerminalLifecycle.session.lastTransitionAt;
runtimeTerminalLifecycle.runtime.state = "missing";
runtimeTerminalLifecycle.runtime.reason = "process_missing";
runtimeTerminalLifecycle.pr.state = "open";
Expand All @@ -659,15 +696,15 @@ describe("API Routes", () => {
},
}),
];
(mockSessionManager.listCached as ReturnType<typeof vi.fn>).mockResolvedValue(sessionWithOpenPR);
(mockSessionManager.listCached as ReturnType<typeof vi.fn>).mockResolvedValue(
sessionWithOpenPR,
);

const metadataSpy = vi
.spyOn(serialize, "enrichSessionsMetadata")
.mockResolvedValue(undefined);

const enrichSpy = vi
.spyOn(serialize, "enrichSessionPR")
.mockResolvedValue(true);
const enrichSpy = vi.spyOn(serialize, "enrichSessionPR").mockResolvedValue(true);

const res = await sessionsGET(makeRequest("http://localhost:3000/api/sessions"));

Expand Down Expand Up @@ -710,6 +747,107 @@ describe("API Routes", () => {
}, 10_000);
});

// ── PATCH /api/sessions/[id] ───────────────────────────────────────

describe("PATCH /api/sessions/[id]", () => {
function patchRequest(id: string, body: unknown): NextRequest {
return makeRequest(`http://localhost:3000/api/sessions/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}

beforeEach(() => {
vi.mocked(updateMetadata).mockReset();
});

it("persists a sanitized displayName and sets the user-set flag", async () => {
const res = await sessionDetailPATCH(
patchRequest("backend-7", { displayName: " PR 432 review " }),
{
params: Promise.resolve({ id: "backend-7" }),
},
);

expect(res.status).toBe(200);
expect(vi.mocked(updateMetadata)).toHaveBeenCalledTimes(1);
const [, sessionId, updates] = vi.mocked(updateMetadata).mock.calls[0];
expect(sessionId).toBe("backend-7");
// Whitespace is collapsed and trimmed before persist; flag is set so the
// dashboard knows to promote this name above PR/issue titles.
expect(updates).toEqual({ displayName: "PR 432 review", displayNameUserSet: "true" });
expect(mockSessionManager.invalidateCache).toHaveBeenCalled();
});

it("treats null displayName as a clear and unsets the user-set flag", async () => {
const res = await sessionDetailPATCH(patchRequest("backend-7", { displayName: null }), {
params: Promise.resolve({ id: "backend-7" }),
});

expect(res.status).toBe(200);
expect(vi.mocked(updateMetadata)).toHaveBeenCalledWith(expect.any(String), "backend-7", {
displayName: "",
displayNameUserSet: "",
});
});

it("strips control characters", async () => {
const res = await sessionDetailPATCH(patchRequest("backend-7", { displayName: "FooBar" }), {
params: Promise.resolve({ id: "backend-7" }),
});
expect(res.status).toBe(200);
expect(vi.mocked(updateMetadata)).toHaveBeenCalledWith(expect.any(String), "backend-7", {
displayName: "FooBar",
displayNameUserSet: "true",
});
});

it("truncates names longer than 80 characters", async () => {
const longName = "a".repeat(120);
const res = await sessionDetailPATCH(patchRequest("backend-7", { displayName: longName }), {
params: Promise.resolve({ id: "backend-7" }),
});
expect(res.status).toBe(200);
const [, , updates] = vi.mocked(updateMetadata).mock.calls[0];
expect((updates as { displayName: string }).displayName.length).toBe(80);
});

it("returns 400 when displayName field is missing", async () => {
const res = await sessionDetailPATCH(patchRequest("backend-7", {}), {
params: Promise.resolve({ id: "backend-7" }),
});
expect(res.status).toBe(400);
expect(vi.mocked(updateMetadata)).not.toHaveBeenCalled();
});

it("returns 400 when displayName is not a string or null", async () => {
const res = await sessionDetailPATCH(patchRequest("backend-7", { displayName: 42 }), {
params: Promise.resolve({ id: "backend-7" }),
});
expect(res.status).toBe(400);
expect(vi.mocked(updateMetadata)).not.toHaveBeenCalled();
});

it("returns 400 on invalid JSON", async () => {
const req = makeRequest("http://localhost:3000/api/sessions/backend-7", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: "{not json",
});
const res = await sessionDetailPATCH(req, { params: Promise.resolve({ id: "backend-7" }) });
expect(res.status).toBe(400);
});

it("returns 404 when the session is unknown", async () => {
const res = await sessionDetailPATCH(patchRequest("does-not-exist", { displayName: "x" }), {
params: Promise.resolve({ id: "does-not-exist" }),
});
expect(res.status).toBe(404);
expect(vi.mocked(updateMetadata)).not.toHaveBeenCalled();
});
});

describe("GET /api/runtime/terminal", () => {
function withEnv(overrides: Record<string, string | undefined>, fn: () => Promise<void>) {
const saved: Record<string, string | undefined> = {};
Expand Down Expand Up @@ -1035,7 +1173,9 @@ describe("API Routes", () => {
});

it("returns 500 when list fails", async () => {
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("boom"));
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("boom"),
);
const res = await orchestratorsGET(
makeRequest("http://localhost:3000/api/orchestrators?project=my-app"),
);
Expand Down
1 change: 1 addition & 0 deletions packages/web/src/__tests__/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export function makeSession(overrides: Partial<DashboardSession> = {}): Dashboar
issueTitle: null,
userPrompt: null,
displayName: null,
displayNameUserSet: false,
summary: "Test session",
summaryIsFallback: false,
createdAt: new Date().toISOString(),
Expand Down
Loading
Loading