Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
dc74fb2
feat(session): add pure helpers for session title generation
arpan7sarkar Aug 13, 2026
18574f7
feat(session): persist a titleGenerated flag on sessions
arpan7sarkar Aug 13, 2026
d8f4fd1
fix(cli): persist /rename to disk instead of only React state
arpan7sarkar Aug 13, 2026
4de032a
feat(session): add smartTitles config and the title client resolver
arpan7sarkar Aug 13, 2026
22b26b4
feat(session): add the background title generation call
arpan7sarkar Aug 13, 2026
29219aa
feat(session): orchestrate one-shot title generation per session
arpan7sarkar Aug 13, 2026
d0f1292
fix(cli): derive the session title from the first message, not the la…
arpan7sarkar Aug 13, 2026
813a6ac
feat(acp): generate a session title after the first completed turn
arpan7sarkar Aug 13, 2026
123057a
feat(vscode): surface AI-generated session titles in the extension
arpan7sarkar Aug 27, 2026
c64e001
fix(session): title from the opening turns, not the first message alone
arpan7sarkar Aug 27, 2026
bd2beb1
fix:fixed the message helper testcase
arpan7sarkar Aug 27, 2026
7a1d639
chore:Added changeset for session title renamining
arpan7sarkar Aug 27, 2026
ac15767
chore(vscode): restore the packaged extension
arpan7sarkar Aug 27, 2026
50da591
chore(vscode): leave the packaged extension untouched in this PR
arpan7sarkar Aug 27, 2026
cb4ce6d
Merge remote-tracking branch 'upstream/main' into feat/ai-session-titles
arpan7sarkar Aug 27, 2026
f1d0787
fix(session): strip the active-file prefix from derived titles
arpan7sarkar Aug 27, 2026
bfc8df7
Merge remote-tracking branch 'upstream/main' into feat/ai-session-titles
arpan7sarkar Aug 30, 2026
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/ai-session-titles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nanocollective/nanocoder": minor
---

Added automatic session titles. A session keeps its opening prompt as the title, and when that prompt is too thin to be useful the agent generates a descriptive name once, after the first turn that ran a tool or the first follow-up message. Manual renames are never overwritten. Titling uses the session's own model by default - set `sessions.titleModel` / `sessions.titleProvider` to point it at a cheaper one, or `sessions.smartTitles: false` to turn it off. Also fixed the CLI's autosave deriving the session title from the latest user message and rewriting it on every save, which overwrote titles in the store the VS Code extension reads from. Closes #808.
15 changes: 15 additions & 0 deletions plugins/vscode/src/acp-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@ test('NanocoderAcpClient - permission flow', async (t) => {
t.false(client.hasPendingPermissions(), 'Pending permissions should be cleared');
});

test('NanocoderAcpClient - forwards background title notifications', async (t) => {
const client = makeClient({});
let notified = false;
client.onSessionTitleChanged = () => {
notified = true;
};

await client.handleExtNotification('_nanocoder/sessionTitleChanged', {
sessionId: 'session-1',
title: 'Updated title',
});

t.true(notified);
});

function makeClient(connection: unknown) {
const outputChannel = { appendLine: () => {} } as any;
const client = new NanocoderAcpClient(outputChannel, new AcpStateManager());
Expand Down
9 changes: 9 additions & 0 deletions plugins/vscode/src/acp-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export class NanocoderAcpClient {
public onStateSync?: (state: StateSyncPayload) => void;
public onSessionArtifacts?: (meta: unknown) => void;
public onConnectionReady?: () => void;
/** Fires when a background title update is received. */
public onSessionTitleChanged?: () => void;

public currentMode?: string;
public availableModes: string[] = [];
Expand Down Expand Up @@ -90,6 +92,13 @@ export class NanocoderAcpClient {
this._clearPendingPermissions();
}

/** Handle custom notifications from the agent. */
async handleExtNotification(method: string, _params: unknown): Promise<void> {
if (method === '_nanocoder/sessionTitleChanged') {
this.onSessionTitleChanged?.();
}
}

async handlePermissionRequest(params: any): Promise<unknown> {
const toolCall = params.toolCall;
const toolCallId = toolCall.toolCallId;
Expand Down
3 changes: 3 additions & 0 deletions plugins/vscode/src/acp-process-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ export class AcpProcessManager {
},
requestPermission: async (params: any) => {
return this.acpClient.handlePermissionRequest(params);
},
extNotification: async (method: string, params: any) => {
return this.acpClient.handleExtNotification(method, params);
}
} as any), stream);
this.acpClient.setConnection(connection);
Expand Down
5 changes: 5 additions & 0 deletions plugins/vscode/src/chat-webview-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ export class ChatWebviewProvider
});
};

// Refresh the history list after a background title update.
this._acpClient.onSessionTitleChanged = () => {
void this._broadcastSessions();
};

this._acpClient.onConnectionReady = () => {
this._initializeSessionIfReady();
};
Expand Down
114 changes: 114 additions & 0 deletions source/acp/acp-agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,120 @@ test.serial(
},
);

// ============================================================================
// background session titling
// ============================================================================

/** Poll the persisted session, since titling is deliberately fire and forget. */
async function waitForSession(
sessionId: string,
predicate: (s: any) => boolean,
timeoutMs = 3000,
): Promise<any | null> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const s = await sessionManager.readSession(sessionId);
if (s && predicate(s)) return s;
await new Promise(r => setTimeout(r, 20));
}
return null;
}

test('AcpAgent.prompt - a weak title waits for meaningful context', async t => {
const {agent} = createAgent();

let chatCalls = 0;
agent['initContext'].client.chat = async () => {
chatCalls++;
// Calls 1 and 2 are conversation turns; call 3 is the titler.
return chatCalls < 3
? {choices: [{message: {content: 'Done.'}}]}
: {choices: [{message: {content: 'Fix Login Redirect'}}]};
};

const session = await agent.newSession({cwd: '/tmp'});
await agent.prompt({
sessionId: session.sessionId,
prompt: [{type: 'text', text: 'fix this'}],
});

await new Promise(r => setTimeout(r, 200));
const beforeContext = await sessionManager.readSession(session.sessionId);
t.not(beforeContext?.titleGenerated, true);
t.is(chatCalls, 1);

await agent.prompt({
sessionId: session.sessionId,
prompt: [{type: 'text', text: 'summarize the README'}],
});

const titled = await waitForSession(
session.sessionId,
s => s.titleGenerated === true,
);
t.truthy(titled, 'expected a generated title to be persisted');
t.is(titled.title, 'Fix Login Redirect');
// A generated title must never masquerade as a user rename.
t.not(titled.titleManuallySet, true);

// A third turn must not re-title: titleGenerated short-circuits it.
await agent.prompt({
sessionId: session.sessionId,
prompt: [{type: 'text', text: 'and now this'}],
});
await new Promise(r => setTimeout(r, 200));

const after = await sessionManager.readSession(session.sessionId);
t.is(after!.title, 'Fix Login Redirect');
// Exactly one more chat call, the conversation turn, and no second titler.
t.is(chatCalls, 4);
});

test('AcpAgent.prompt - a cancelled turn does not generate a title', async t => {
const {agent} = createAgent();

let chatCalls = 0;
agent['initContext'].client.chat = async () => {
chatCalls++;
throw new Error('Operation was cancelled');
};

const session = await agent.newSession({cwd: '/tmp'});
await agent.prompt({
sessionId: session.sessionId,
prompt: [{type: 'text', text: 'fix this'}],
});
await new Promise(r => setTimeout(r, 200));

// The cancel path early-returns from inside catch, which still runs the
// finally. Reaching the finally must not be mistaken for a clean turn.
t.is(chatCalls, 1);
const stored = await sessionManager.readSession(session.sessionId);
t.not(stored?.titleGenerated, true);
});

test('AcpAgent.prompt - an errored turn does not generate a title', async t => {
const {agent} = createAgent();

let chatCalls = 0;
agent['initContext'].client.chat = async () => {
chatCalls++;
throw new Error('RequestError: Internal error (500)');
};

const session = await agent.newSession({cwd: '/tmp'});
await t.throwsAsync(
agent.prompt({
sessionId: session.sessionId,
prompt: [{type: 'text', text: 'fix this'}],
}),
);
await new Promise(r => setTimeout(r, 200));

t.is(chatCalls, 1);
const stored = await sessionManager.readSession(session.sessionId);
t.not(stored?.titleGenerated, true);
});
/** Throwaway workspace for the timeline tests, removed by the caller. */
const createTimelineWorkspace = (label: string): string => {
const cwd = join(
Expand Down
42 changes: 36 additions & 6 deletions source/acp/acp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ import {getAppConfig} from '@/config/index';
import {loadPreferences, updateLastUsed} from '@/config/preferences';
import {resolveTune} from '@/config/tune';
import {TimelineManager} from '@/services/timeline-manager';
import {maybeGenerateTitle} from '@/session/maybe-generate-title';
import {sessionManager} from '@/session/session-manager';
import {deriveTitleFromFirstMessage} from '@/session/title-generator';
import {getTuneToolMode} from '@/types/config';
import {getLogger} from '@/utils/logging';
import {buildSystemPrompt, setLastBuiltPrompt} from '@/utils/prompt-builder';
Expand Down Expand Up @@ -338,14 +340,20 @@ export class AcpAgent implements Agent {
const nonInteractiveAlwaysAllow = config.alwaysAllow ?? [];

session.turnActive = true;
// Both the cancel early-return below and the rethrow after it still run
// the finally, so a clean turn has to be tracked explicitly rather than
// inferred from getting there.
let turnSucceeded = false;
try {
return await runAcpConversation({
const result = await runAcpConversation({
session,
client: this.initContext.client,
toolManager: this.initContext.toolManager,
conn: this.conn,
nonInteractiveAlwaysAllow,
});
turnSucceeded = true;
return result;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);

Expand Down Expand Up @@ -395,6 +403,28 @@ export class AcpAgent implements Agent {
await this.saveAcpSessionToDisk(session).catch(err => {
logger.error(`Failed to save ACP session ${session.sessionId}: ${err}`);
});

// Fire and forget: the turn must return to idle immediately, and a
// cosmetic title landing a moment later is fine.
if (turnSucceeded) {
void maybeGenerateTitle({
sessionId: session.sessionId,
messages: session.messages,
client: this.initContext.client,
onTitle: title => {
// notify(), not the deprecated extNotification() alias.
// The client receives it as extNotification(method, params).
// Lands after the turn went idle, so the client may already be
// gone; an unhandled rejection here would kill the agent.
void this.conn
.notify('_nanocoder/sessionTitleChanged', {
sessionId: session.sessionId,
title,
})
.catch(() => {});
},
});
}
}
}

Expand Down Expand Up @@ -870,17 +900,17 @@ export class AcpAgent implements Agent {
let title = existingSession?.title;
if (!title || title === 'New Session') {
const firstUserMessage = saveableMessages.find(m => m.role === 'user');
if (firstUserMessage && typeof firstUserMessage.content === 'string') {
title = firstUserMessage.content.split('\n')[0].substring(0, 50);
} else {
title = 'New Session';
}
title =
(typeof firstUserMessage?.content === 'string'
? deriveTitleFromFirstMessage(firstUserMessage.content)
: null) ?? 'New Session';
}

await sessionManager.saveSession({
id: session.sessionId,
title,
titleManuallySet: existingSession?.titleManuallySet,
titleGenerated: existingSession?.titleGenerated,
createdAt: existingSession?.createdAt || timestamp,
lastAccessedAt: timestamp,
messageCount: saveableMessages.length,
Expand Down
66 changes: 66 additions & 0 deletions source/acp/acp-ext-notification.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import test from 'ava';
import {
AgentSideConnection,
ClientSideConnection,
ndJsonStream,
} from '@agentclientprotocol/sdk';

console.log('\nacp-ext-notification.spec.ts');

/**
* The agent announces a generated session title with conn.notify(); the VS Code
* client is expected to receive it as extNotification(method, params).
*
* Every other test in this repo stubs the connection, so that dispatch has
* never actually crossed a wire. If the SDK routed it differently, the sidebar
* would silently never refresh while all the unit tests stayed green.
*/
test('a title notification sent with notify() arrives as extNotification', async t => {
const a2b = new TransformStream<Uint8Array, Uint8Array>();
const b2a = new TransformStream<Uint8Array, Uint8Array>();

const received: Array<{method: string; params: unknown}> = [];
let resolveReceived: () => void;
const gotOne = new Promise<void>(r => {
resolveReceived = r;
});

// Client side, mirroring how acp-process-manager builds its handler object.
new ClientSideConnection(
() =>
({
sessionUpdate: async () => {},
requestPermission: async () => ({outcome: {outcome: 'cancelled'}}),
extNotification: async (method: string, params: unknown) => {
received.push({method, params});
resolveReceived();
},
}) as never,
ndJsonStream(b2a.writable, a2b.readable),
);

// Agent side.
const agentConn = new AgentSideConnection(
() => ({}) as never,
ndJsonStream(a2b.writable, b2a.readable),
);

await agentConn.notify('_nanocoder/sessionTitleChanged', {
sessionId: 'session-1',
title: 'Fix Login Redirect',
});

await Promise.race([
gotOne,
new Promise((_r, reject) =>
setTimeout(() => reject(new Error('notification never arrived')), 3000),
),
]);

t.is(received.length, 1);
t.is(received[0].method, '_nanocoder/sessionTitleChanged');
t.deepEqual(received[0].params, {
sessionId: 'session-1',
title: 'Fix Login Redirect',
});
});
14 changes: 14 additions & 0 deletions source/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ function loadSessionConfig(): AppConfig['sessions'] {
maxMessages: 1000,
retentionDays: 30,
directory: '',
smartTitles: true,
};

const normalizeSessionNumber = (
Expand Down Expand Up @@ -286,6 +287,19 @@ function loadSessionConfig(): AppConfig['sessions'] {
defaults.retentionDays ?? 30,
),
directory: sessions.directory || defaults.directory,
smartTitles:
sessions.smartTitles !== undefined
? Boolean(sessions.smartTitles)
: defaults.smartTitles,
// No default model: unset means "use the session's own".
titleModel:
typeof sessions.titleModel === 'string'
? sessions.titleModel
: undefined,
titleProvider:
typeof sessions.titleProvider === 'string'
? sessions.titleProvider
: undefined,
};
}
return null;
Expand Down
Loading
Loading