Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andEdo Sulaiman 37fd9259e0 fix(server): keep a provider-side model switch across reload
A model_changed event from the provider updated runtimeInfo.model but
left config.model at the creation-time value. Reload and daemon restart
resume from config.model, so the resumed session was switched back to
the old model. model_changed now writes config.model, as
thinking_option_changed already does for config.thinkingOptionId.

Co-authored-by: Edo Sulaiman <edosulai@icloud.com>
2026-09-28 23:02:03 +02:00
paseo-bot[bot] f4ba16a0b3 fix(app): keep line breaks in streamed replies when a chunk ends in indentation (#5577)
While a reply streams, presentation reparses only the growing last Markdown
block: the cached block text plus the new chunk. splitMarkdownBlocks drops a
trailing whitespace-only line, and only trailing "\n"s were added back, so a
reply that paused on "]\n   " cached the block as "]". The next chunk " B -->"
then continued the previous line, joining two lines of a code block. Codex
streams a line break and the next line's indent as separate deltas, so its
Mermaid diagrams hit this and failed to render. History reparses the whole
text, which is why a reload showed the reply correctly.

Keep every trailing blank line the split drops on the last block.

Closes #5456

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-28 22:09:16 +02:00
4 changed files with 52 additions and 2 deletions
@@ -210,6 +210,17 @@ describe("stream presentation through installed plugins", () => {
expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]);
});
// Codex streams a line break and the next line's indent as their own chunks.
it("keeps a line break when a chunk ends in the next line's indent", () => {
const harness = streamHarness();
harness.send(assistant("```mermaid\nflowchart TD\n A[Start] --> B[Review]\n"));
harness.send(assistant(" "));
const result = harness.send(assistant(" B --> C[Finish]\n```"));
expect(result.head).toMatchObject([
{ text: "```mermaid\nflowchart TD\n A[Start] --> B[Review]\n B --> C[Finish]\n```" },
]);
});
// One rendering path: a fetched message is the same block group as a streamed one,
// which is what lets find, scroll-to-message and history reveal address it by id.
// A link reference definition stays with the paragraph that uses it, so the split
@@ -112,8 +112,10 @@ export function createStreamPresentation() {
const index = prefix.length + offset;
let blockText = text;
if (offset === textBlocks.length - 1) {
const trailingNewlines = /\n+$/.exec(item.text)?.[0] ?? "";
blockText += trailingNewlines;
// The split drops trailing blank lines, including a line break followed only
// by the next line's indent. Keep them so the next append continues that line.
const trailingBlankLines = /\n\s*$/.exec(item.text)?.[0] ?? "";
blockText += trailingBlankLines;
}
const existing = previous?.[index];
const id = `${item.id}:block:${index}`;
@@ -5066,6 +5066,42 @@ test("session config drift events update state through the stream channel", asyn
expect(streams.map((event) => event.type)).toEqual([]);
});
test("provider-side model switch survives reload and daemon restart", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provider-model-switch-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
let capturedSession: TestAgentSession | null = null;
class ModelSwitchClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
capturedSession = new TestAgentSession(config);
return capturedSession;
}
}
const client = new ModelSwitchClient();
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000134",
});
const snapshot = await manager.createAgent(
{ provider: "codex", cwd: workdir, model: "gpt-5.2-codex" },
undefined,
{ workspaceId: undefined },
);
capturedSession!.pushEvent({
type: "model_changed",
provider: "codex",
runtimeInfo: { provider: "codex", sessionId: capturedSession!.id, model: "gpt-5.4" },
});
await manager.flush();
await manager.reloadAgentSession(snapshot.id);
expect(client.resumeOverrides.at(-1)?.model).toBe("gpt-5.4");
expect((await storage.get(snapshot.id))?.config?.model).toBe("gpt-5.4");
rmSync(workdir, { recursive: true, force: true });
});
test("setLabels merges and persists labels", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-set-labels-"));
const storagePath = join(workdir, "agents");
@@ -4310,6 +4310,7 @@ export class AgentManager {
return undefined;
case "model_changed":
agent.runtimeInfo = event.runtimeInfo;
agent.config.model = event.runtimeInfo.model ?? agent.config.model;
if (!agent.persistence && event.runtimeInfo.sessionId) {
agent.persistence = attachPersistenceCwd(
{ provider: agent.provider, sessionId: event.runtimeInfo.sessionId },