Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
91 changes: 61 additions & 30 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,10 +661,12 @@ import {
AUTO_LANE_IDENTITY_JSON_SCHEMA,
AUTO_TITLE_SYSTEM_PROMPT,
buildSessionIntelligenceModelCandidates,
formatConversationTranscript,
LANE_NAME_FROM_PROMPT_SYSTEM_PROMPT,
LEGACY_LANE_NAME_SYSTEM_PROMPT,
MAX_NAMING_WORDS,
runNamingAcrossProviders,
type SessionMetadataConversationEntry,
} from "./sessionNaming";
import { createSessionMetadataRegenerator } from "./sessionMetadataService";
import {
Expand Down Expand Up @@ -9462,25 +9464,6 @@ export function createAgentChatService(args: {
`${label} timed out after ${timeoutMs}ms`,
);

const readTranscriptConversationEntries = (managed: ManagedChatSession): string[] => {
try {
return readTranscriptEnvelopes(managed)
.flatMap((entry) => {
if (entry.event.type === "user_message") {
const text = entry.event.text.trim();
return text.length ? [`User: ${text}`] : [];
}
if (entry.event.type === "text") {
const text = entry.event.text.trim();
return text.length ? [`Assistant: ${text}`] : [];
}
return [];
});
} catch {
return [];
}
};

const readTranscriptEntries = async (
managed: ManagedChatSession,
signal?: AbortSignal,
Expand Down Expand Up @@ -11103,17 +11086,40 @@ export function createAgentChatService(args: {
});
};

const buildRecentConversationContext = (managed: ManagedChatSession, limit = 20): string => {
const liveEntries = managed.recentConversationEntries.map((entry) =>
`${entry.role === "user" ? "User" : "Assistant"}: ${entry.text}`,
);
const combined: string[] = [];
for (const entry of [...readTranscriptConversationEntries(managed), ...liveEntries]) {
if (!entry.trim().length) continue;
if (combined[combined.length - 1] === entry) continue;
const collectConversationEntries = (
managed: ManagedChatSession,
): SessionMetadataConversationEntry[] => {
const fromTranscript: SessionMetadataConversationEntry[] = [];
try {
for (const entry of readTranscriptEnvelopes(managed)) {
if (entry.event.type === "user_message") {
const text = entry.event.text.trim();
if (text) fromTranscript.push({ role: "user", text });
} else if (entry.event.type === "text") {
const text = entry.event.text.trim();
if (text) fromTranscript.push({ role: "assistant", text });
}
}
} catch {
// Live ring still helps when the transcript file is unreadable.
}
const live = managed.recentConversationEntries
.map((entry) => ({
role: entry.role,
text: entry.text.trim(),
}))
.filter((entry) => entry.text.length > 0);
const combined: SessionMetadataConversationEntry[] = [];
for (const entry of [...fromTranscript, ...live]) {
const prev = combined[combined.length - 1];
if (prev && prev.role === entry.role && prev.text === entry.text) continue;
combined.push(entry);
}
return combined.slice(-limit).join("\n");
return combined;
};

const buildRecentConversationContext = (managed: ManagedChatSession, limit = 20): string => {
return formatConversationTranscript(collectConversationEntries(managed).slice(-limit));
};

const usesIdentityContinuity = (managed: ManagedChatSession): boolean => Boolean(managed.session.identityKey);
Expand Down Expand Up @@ -12050,8 +12056,33 @@ export function createAgentChatService(args: {
sessionModel: managed.session.model,
});
},
buildRecentConversationContext: (managed, limit) =>
buildRecentConversationContext(managed, limit),
collectConversationEntries: (managed) => collectConversationEntries(managed),
listLaneThreads: (managed) => {
const rows = sessionService.list({ laneId: managed.session.laneId, limit: 40 });
return rows
.filter((row) => isChatToolType(row.toolType))
.map((row) => ({
title: row.title,
statusNote: row.statusNote,
summary: row.summary,
isCurrent: row.id === managed.session.id,
}));
},
gatherLaneWorkVersusRemote: async ({ worktreePath, baseRef }) => {
const cwd = worktreePath.trim();
if (!cwd) return null;
const [changedFiles, commits, uncommitted] = await Promise.all([
runGit(["diff", "--name-status", `${baseRef}...HEAD`], { cwd, timeoutMs: 8_000 }).catch(() => null),
runGit(["log", "-n20", "--oneline", `${baseRef}..HEAD`], { cwd, timeoutMs: 8_000 }).catch(() => null),
runGit(["status", "--short"], { cwd, timeoutMs: 8_000 }).catch(() => null),
]);
return {
baseRef,
changedFiles: changedFiles?.exitCode === 0 ? changedFiles.stdout : null,
commits: commits?.exitCode === 0 ? commits.stdout : null,
uncommitted: uncommitted?.exitCode === 0 ? uncommitted.stdout : null,
};
},
runPrompt: ({ cwd, modelId, prompt, systemPrompt, jsonSchema }) => runSessionIntelligencePrompt({
cwd,
modelId,
Expand Down
168 changes: 164 additions & 4 deletions apps/desktop/src/main/services/chat/sessionMetadataService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { AgentChatSession } from "../../../shared/types/chat";
import { getAvailableModels } from "../../../shared/modelRegistry";
import type { Logger } from "../logging/logger";
import { createSessionMetadataRegenerator, type SessionMetadataManagedSession } from "./sessionMetadataService";
import type { SessionMetadataPromptRunner } from "./sessionNaming";
import type { SessionMetadataConversationEntry, SessionMetadataLaneThread, SessionMetadataPromptRunner } from "./sessionNaming";

const ANTHROPIC_MODELS = getAvailableModels([
{ type: "cli-subscription", cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true },
Expand All @@ -25,6 +25,14 @@ function createHarness(args?: {
autoTitleSeed?: string | null;
preview?: string | null;
resolveModelCandidates?: () => Promise<string[]>;
conversation?: SessionMetadataConversationEntry[];
laneThreads?: SessionMetadataLaneThread[];
laneWork?: {
baseRef: string;
commits?: string | null;
changedFiles?: string | null;
uncommitted?: string | null;
} | null;
}) {
const managed = {
session: {
Expand Down Expand Up @@ -55,6 +63,9 @@ function createHarness(args?: {
return true;
});
const renameLane = vi.fn();
const collectConversationEntries = vi.fn(() => args?.conversation ?? []);
const listLaneThreads = vi.fn(() => args?.laneThreads ?? []);
const gatherLaneWorkVersusRemote = vi.fn(async () => args?.laneWork ?? null);
const runPrompt = vi.fn<Parameters<SessionMetadataPromptRunner>, ReturnType<SessionMetadataPromptRunner>>(
args?.runPrompt ?? (async () => ({
text: JSON.stringify({
Expand All @@ -68,9 +79,11 @@ function createHarness(args?: {
const regenerate = createSessionMetadataRegenerator<typeof managed>({
ensureManagedSession: () => managed,
getSession: () => sessionRow,
getLaneSummary: async () => ({ name: sessionRow.laneName }),
getLaneSummary: async () => ({ name: sessionRow.laneName, worktreePath: managed.laneWorktreePath }),
resolveModelCandidates: args?.resolveModelCandidates ?? (async () => [ANTHROPIC_MODELS[0]!.id]),
buildRecentConversationContext: () => "",
collectConversationEntries,
listLaneThreads,
gatherLaneWorkVersusRemote,
runPrompt,
normalizeTitle,
normalizeStatusLine,
Expand All @@ -80,7 +93,18 @@ function createHarness(args?: {
persistChatState: vi.fn(),
logger,
});
return { regenerate, managed, sessionRow, applyTitle, setStatusNote, renameLane, runPrompt };
return {
regenerate,
managed,
sessionRow,
applyTitle,
setStatusNote,
renameLane,
runPrompt,
collectConversationEntries,
listLaneThreads,
gatherLaneWorkVersusRemote,
};
}

describe("createSessionMetadataRegenerator", () => {
Expand Down Expand Up @@ -144,4 +168,140 @@ describe("createSessionMetadataRegenerator", () => {
expect(setStatusNote).toHaveBeenCalled();
expect(renameLane).toHaveBeenCalled();
});

it("sends the full thread, latest assistant paragraphs, lane threads, and git work in one call", async () => {
const { regenerate, runPrompt } = createHarness({
conversation: [
{ role: "user", text: "stop one-shot AI from picking Haiku" },
{ role: "assistant", text: "Looked at executeTask.\n\nRemoved the default namer.\n\nTests are running on the skip path." },
],
laneThreads: [
{ title: "Stop Haiku default", statusNote: "Tests are running on the skip path.", isCurrent: true },
{ title: "Conflict picker", summary: "Added the settings row" },
],
laneWork: {
baseRef: "origin/main",
changedFiles: "M apps/desktop/src/main/services/ai/aiIntegrationService.ts",
commits: "c9685fa Stop one-shot AI from picking Haiku",
uncommitted: "",
},
});

await regenerate({ sessionId: "sess-1" });
expect(runPrompt).toHaveBeenCalledTimes(1);
const prompt = String(runPrompt.mock.calls[0]?.[0]?.prompt ?? "");
expect(prompt).toContain("source for chatTitle");
expect(prompt).toContain("stop one-shot AI from picking Haiku");
expect(prompt).toContain("source for statusLine");
expect(prompt).toContain("Tests are running on the skip path.");
expect(prompt).toContain("Conflict picker");
expect(prompt).toContain("Work on this lane that differs from remote");
expect(prompt).toContain("aiIntegrationService.ts");
});

it("does not gather sibling threads or git work for a status-only refresh", async () => {
const {
regenerate,
runPrompt,
listLaneThreads,
gatherLaneWorkVersusRemote,
} = createHarness({
conversation: [
{ role: "user", text: "rewrite every naming prompt" },
{ role: "assistant", text: "Looked at executeTask.\n\nRemoved the default namer.\n\nTests are running on the skip path." },
],
laneThreads: [{ title: "Conflict picker", isCurrent: false }],
laneWork: {
baseRef: "origin/main",
changedFiles: "M apps/desktop/src/main/services/ai/aiIntegrationService.ts",
},
});

await regenerate({ sessionId: "sess-1", fields: ["statusLine"] });
expect(listLaneThreads).not.toHaveBeenCalled();
expect(gatherLaneWorkVersusRemote).not.toHaveBeenCalled();
expect(runPrompt).toHaveBeenCalledTimes(1);
const call = runPrompt.mock.calls[0]?.[0];
const prompt = String(call?.prompt ?? "");
expect(prompt).toContain("long-running coding thread");
expect(prompt).toContain("Users manage many threads");
expect(prompt).toContain("Lane name: Start Skill Using Aws Other");
expect(prompt).toContain("Worktree: lane");
expect(prompt).toContain("Chat title: Start Skill Using Aws Other");
expect(prompt).toContain("Tests are running on the skip path.");
expect(prompt).not.toContain("rewrite every naming prompt");
expect(prompt).not.toContain("Conflict picker");
expect(prompt).not.toContain("aiIntegrationService.ts");
expect(String(call?.systemPrompt ?? "")).toContain(
"Copy these current values unchanged: chatTitle, laneName.",
);
});

it("does not gather git work for a title-only refresh", async () => {
const { regenerate, runPrompt, listLaneThreads, gatherLaneWorkVersusRemote } = createHarness({
conversation: [
{ role: "user", text: "stop one-shot AI from picking Haiku" },
{ role: "assistant", text: "Removed the default namer." },
],
laneThreads: [{ title: "Conflict picker" }],
laneWork: {
baseRef: "origin/main",
changedFiles: "M apps/desktop/src/main/services/ai/aiIntegrationService.ts",
},
});

await regenerate({ sessionId: "sess-1", fields: ["title"] });
expect(listLaneThreads).not.toHaveBeenCalled();
expect(gatherLaneWorkVersusRemote).not.toHaveBeenCalled();
const prompt = String(runPrompt.mock.calls[0]?.[0]?.prompt ?? "");
expect(prompt).toContain("stop one-shot AI from picking Haiku");
expect(prompt).not.toContain("aiIntegrationService.ts");
expect(prompt).not.toContain("Conflict picker");
});

it("does not collect the transcript when only the lane name is requested", async () => {
const { regenerate, collectConversationEntries, listLaneThreads, gatherLaneWorkVersusRemote } = createHarness({
conversation: [{ role: "user", text: "rewrite every naming prompt" }],
laneThreads: [{ title: "Conflict picker" }],
laneWork: {
baseRef: "origin/main",
changedFiles: "M apps/desktop/src/auth.ts",
},
});

await regenerate({ sessionId: "sess-1", fields: ["laneName"] });
expect(collectConversationEntries).not.toHaveBeenCalled();
expect(listLaneThreads).toHaveBeenCalled();
expect(gatherLaneWorkVersusRemote).toHaveBeenCalled();
});

it("titles from this thread when models fail, not from the kickoff slug", async () => {
const { regenerate, applyTitle, renameLane } = createHarness({
resolveModelCandidates: async () => [],
conversation: [
{ role: "user", text: "stop one-shot AI from picking Haiku" },
{ role: "assistant", text: "Removed the default namer so skip-path tests stay green." },
],
});

const result = await regenerate({ sessionId: "sess-1", fields: ["title"] });
expect(result.applied).toEqual(["title"]);
expect(renameLane).not.toHaveBeenCalled();
expect(applyTitle).toHaveBeenCalled();
expect(String(applyTitle.mock.calls[0]?.[1])).not.toMatch(/start skill using aws/i);
expect(String(applyTitle.mock.calls[0]?.[1])).toMatch(/stop one shot/i);
});

it("does not stamp this thread's kickoff onto the shared lane when models fail", async () => {
const { regenerate, applyTitle, renameLane, setStatusNote } = createHarness({
resolveModelCandidates: async () => [],
});

await expect(regenerate({ sessionId: "sess-1", fields: ["laneName"] })).rejects.toThrow(
"The AI returned no usable session metadata.",
);
expect(renameLane).not.toHaveBeenCalled();
expect(applyTitle).not.toHaveBeenCalled();
expect(setStatusNote).not.toHaveBeenCalled();
});
});
Loading
Loading