diff --git a/packages/agent/src/harness/session/session.ts b/packages/agent/src/harness/session/session.ts index 6f136208..ce369dff 100644 --- a/packages/agent/src/harness/session/session.ts +++ b/packages/agent/src/harness/session/session.ts @@ -234,12 +234,13 @@ export class Session { } async appendSessionName(name: string): Promise { + const sanitizedName = name.replace(/[\r\n]+/g, " ").trim(); return this.appendTypedEntry({ type: "session_info", id: await this.storage.createEntryId(), parentId: await this.storage.getLeafId(), timestamp: new Date().toISOString(), - name: name.trim(), + name: sanitizedName, } satisfies SessionInfoEntry); } diff --git a/packages/agent/test/harness/session.test.ts b/packages/agent/test/harness/session.test.ts index c9598da8..39e285b5 100644 --- a/packages/agent/test/harness/session.test.ts +++ b/packages/agent/test/harness/session.test.ts @@ -86,6 +86,12 @@ async function runSessionSuite( expect(context.messages[1]?.role).toBe("custom"); }); + it("normalizes session names", async () => { + const session = new Session(await createStorage()); + await session.appendSessionName(" hello\nworld\r\nagain "); + expect(await session.getSessionName()).toBe("hello world again"); + }); + it("supports labels and session info entries without affecting context", async () => { const session = new Session(await createStorage()); const user1 = await session.appendMessage(createUserMessage("one")); diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index b07968b3..e40ed0c4 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1026,12 +1026,13 @@ export class SessionManager { /** Append a session info entry (e.g., display name). Returns entry id. */ appendSessionInfo(name: string): string { + const sanitizedName = name.replace(/[\r\n]+/g, " ").trim(); const entry: SessionInfoEntry = { type: "session_info", id: generateId(this.byId), parentId: this.leafId, timestamp: new Date().toISOString(), - name: name.trim(), + name: sanitizedName, }; this._appendEntry(entry); return entry.id; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 27e9f88a..fe733f20 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -5344,8 +5344,12 @@ export class InteractiveMode { } this.session.setSessionName(name); + const sessionName = this.sessionManager.getSessionName(); + if (sessionName !== name) { + this.showWarning(`Session name was normalized from ${JSON.stringify(name)} to ${JSON.stringify(sessionName)}`); + } this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(theme.fg("dim", `Session name set: ${name}`), 1, 0)); + this.chatContainer.addChild(new Text(theme.fg("dim", `Session name set: ${sessionName ?? name}`), 1, 0)); this.ui.requestRender(); } diff --git a/packages/coding-agent/test/suite/regressions/5996-session-name-newlines.test.ts b/packages/coding-agent/test/suite/regressions/5996-session-name-newlines.test.ts new file mode 100644 index 00000000..c33f7760 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/5996-session-name-newlines.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { ExtensionAPI } from "../../../src/index.ts"; +import { createHarness, type Harness } from "../harness.ts"; + +describe("regression #5996: session names do not contain newlines", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + while (harnesses.length > 0) { + harnesses.pop()?.cleanup(); + } + }); + + it("filters newlines when AgentSession.setSessionName is called", async () => { + const harness = await createHarness(); + harnesses.push(harness); + + harness.session.setSessionName("hello\nworld\r\nagain"); + + expect(harness.sessionManager.getSessionName()).toBe("hello world again"); + expect(harness.eventsOfType("session_info_changed").map((event) => event.name)).toEqual(["hello world again"]); + }); + + it("filters newlines when an extension calls pi.setSessionName", async () => { + let api: ExtensionAPI | undefined; + const harness = await createHarness({ + extensionFactories: [ + (pi) => { + api = pi; + }, + ], + }); + harnesses.push(harness); + + api?.setSessionName("from\nextension"); + + expect(harness.sessionManager.getSessionName()).toBe("from extension"); + expect(harness.eventsOfType("session_info_changed").map((event) => event.name)).toEqual(["from extension"]); + }); +});