Skip to content

Commit cc8cb23

Browse files
fix: harden markdown rendering and session/runtime history handling (#177)
* fix: harden session history and prompt rendering - normalize wrapped markdown fences before rendering so malformed nested csv blocks still display correctly - page agent session history from the newest 10 messages and prepend older messages on scroll instead of hydrating the full thread - forward paginated history and per-input output event requests through the desktop bridge and runtime history API - hide internal runtime memory paths from resume prompts so the model does not create a workspace runtime folder from leaked metadata - make Pi prompts explicitly state when attachments and image inputs are absent to reduce image-attachment hallucinations - add focused regression coverage for markdown normalization, history pagination, runtime memory guards, and no-attachment prompts - validation: - node --test desktop/src/components/marketplace/SimpleMarkdown.test.mjs desktop/src/components/panes/ChatPane.history-pagination.test.mjs desktop/electron/session-history-pagination.test.mjs runtime/history-pagination.source.test.mjs runtime/internal-runtime-memory-guard.source.test.mjs runtime/no-attachment-image-prompt.source.test.mjs - npm --prefix desktop run typecheck * test: align Pi prompt expectations with explicit no-attachment guidance - update the harness-host runPi success test to expect the explicit attachments-none and image-inputs-none prompt text - keep the full runtime harness-host suite aligned with the new prompt hardening behavior - validation: - npm run runtime:harness-host:test * fix: seed new browser spaces from the sibling workspace tab - initialize an empty browser space from the active tab in the other space for the same workspace before falling back to the default home page - keep mirrored first-tab seeding from duplicating browser history entries - extend the browser space routing source test to lock the cross-space seeding behavior - validation: - node --test desktop/electron/browser-space-routing.test.mjs - npm run desktop:typecheck - npm run desktop:e2e
1 parent db27ae7 commit cc8cb23

24 files changed

+974
-116
lines changed

desktop/electron/browser-space-routing.test.mjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,20 @@ test("desktop browser tracks separate user and agent browser spaces and routes t
1313
assert.match(source, /const BROWSER_SPACE_IDS = \["user", "agent"\] as const;/);
1414
assert.match(source, /let activeBrowserSpaceId: BrowserSpaceId = "user";/);
1515
assert.match(source, /spaces: \{\s*user: createBrowserTabSpaceState\(\),\s*agent: createBrowserTabSpaceState\(\),\s*\}/);
16+
assert.match(source, /function oppositeBrowserSpaceId\(space: BrowserSpaceId\): BrowserSpaceId \{/);
17+
assert.match(source, /function initialBrowserTabSeed\(\s*workspaceId: string,\s*space: BrowserSpaceId,\s*\): \{/);
18+
assert.match(
19+
source,
20+
/const sourceSpace = browserTabSpaceState\(\s*workspace,\s*oppositeBrowserSpaceId\(space\),\s*\);/,
21+
);
22+
assert.match(
23+
source,
24+
/skipInitialHistoryRecord: true,/,
25+
);
26+
assert.match(
27+
source,
28+
/const seed = initialBrowserTabSeed\(workspaceId, space\);\s*const initialTabId = createBrowserTab\(workspaceId, \{\s*\.\.\.seed,\s*browserSpace: space,\s*\}\);/,
29+
);
1630
assert.match(source, /emitWorkbenchOpenBrowser\(\{\s*workspaceId: targetWorkspaceId,\s*url: targetUrl,\s*space: "agent",\s*\}\);/);
1731
assert.match(source, /await ensureBrowserWorkspace\(targetWorkspaceId, "agent"\);/);
1832
assert.match(source, /browserWorkspaceSnapshot\(targetWorkspaceId, "agent"\)/);

desktop/electron/main.ts

Lines changed: 77 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2624,6 +2624,14 @@ interface SessionHistoryResponsePayload {
26242624
raw: unknown | null;
26252625
}
26262626

2627+
interface SessionHistoryRequestPayload {
2628+
sessionId: string;
2629+
workspaceId: string;
2630+
limit?: number;
2631+
offset?: number;
2632+
order?: "asc" | "desc";
2633+
}
2634+
26272635
interface SessionOutputEventPayload {
26282636
id: number;
26292637
workspace_id: string;
@@ -2635,6 +2643,11 @@ interface SessionOutputEventPayload {
26352643
created_at: string;
26362644
}
26372645

2646+
interface SessionOutputEventListRequestPayload {
2647+
sessionId: string;
2648+
inputId?: string | null;
2649+
}
2650+
26382651
interface SessionOutputEventListResponsePayload {
26392652
items: SessionOutputEventPayload[];
26402653
count: number;
@@ -13067,6 +13080,7 @@ function isWorkspaceNotFoundError(error: unknown): boolean {
1306713080
function emptySessionHistoryPayload(
1306813081
sessionId: string,
1306913082
workspaceId: string,
13083+
request: Pick<SessionHistoryRequestPayload, "limit" | "offset"> = {},
1307013084
): SessionHistoryResponsePayload {
1307113085
return {
1307213086
workspace_id: workspaceId,
@@ -13077,44 +13091,49 @@ function emptySessionHistoryPayload(
1307713091
messages: [],
1307813092
count: 0,
1307913093
total: 0,
13080-
limit: 200,
13081-
offset: 0,
13094+
limit: request.limit ?? 200,
13095+
offset: request.offset ?? 0,
1308213096
raw: null,
1308313097
};
1308413098
}
1308513099

1308613100
async function getSessionHistory(
13087-
sessionId: string,
13088-
workspaceId: string,
13101+
payload: SessionHistoryRequestPayload,
1308913102
): Promise<SessionHistoryResponsePayload> {
1309013103
try {
1309113104
return await requestRuntimeJson<SessionHistoryResponsePayload>({
1309213105
method: "GET",
13093-
path: `/api/v1/agent-sessions/${sessionId}/history`,
13106+
path: `/api/v1/agent-sessions/${payload.sessionId}/history`,
1309413107
params: {
13095-
workspace_id: workspaceId,
13096-
limit: 200,
13097-
offset: 0,
13108+
workspace_id: payload.workspaceId,
13109+
limit: payload.limit ?? 200,
13110+
offset: payload.offset ?? 0,
13111+
order: payload.order ?? "asc",
1309813112
},
1309913113
});
1310013114
} catch (error) {
1310113115
if (
1310213116
isMissingSessionBindingError(error) ||
1310313117
isWorkspaceNotFoundError(error)
1310413118
) {
13105-
return emptySessionHistoryPayload(sessionId, workspaceId);
13119+
return emptySessionHistoryPayload(
13120+
payload.sessionId,
13121+
payload.workspaceId,
13122+
payload,
13123+
);
1310613124
}
1310713125
throw error;
1310813126
}
1310913127
}
1311013128

1311113129
async function getSessionOutputEvents(
13112-
sessionId: string,
13130+
payload: SessionOutputEventListRequestPayload,
1311313131
): Promise<SessionOutputEventListResponsePayload> {
1311413132
return requestRuntimeJson<SessionOutputEventListResponsePayload>({
1311513133
method: "GET",
13116-
path: `/api/v1/agent-sessions/${encodeURIComponent(sessionId)}/outputs/events`,
13134+
path: `/api/v1/agent-sessions/${encodeURIComponent(payload.sessionId)}/outputs/events`,
1311713135
params: {
13136+
input_id: payload.inputId ?? undefined,
1311813137
include_history: true,
1311913138
after_event_id: 0,
1312013139
include_native: false,
@@ -14744,6 +14763,10 @@ function browserTabSpaceState(
1474414763
return workspace.spaces[space] ?? null;
1474514764
}
1474614765

14766+
function oppositeBrowserSpaceId(space: BrowserSpaceId): BrowserSpaceId {
14767+
return space === "agent" ? "user" : "agent";
14768+
}
14769+
1474714770
function browserWorkspaceTabCounts(
1474814771
workspace: BrowserWorkspaceState | null | undefined,
1474914772
): BrowserTabCountsPayload {
@@ -17552,6 +17575,43 @@ function createBrowserTab(
1755217575
return tabId;
1755317576
}
1755417577

17578+
function initialBrowserTabSeed(
17579+
workspaceId: string,
17580+
space: BrowserSpaceId,
17581+
): {
17582+
url: string;
17583+
title?: string;
17584+
faviconUrl?: string;
17585+
skipInitialHistoryRecord: boolean;
17586+
} {
17587+
const workspace = browserWorkspaceFromMap(workspaceId);
17588+
const sourceSpace = browserTabSpaceState(
17589+
workspace,
17590+
oppositeBrowserSpaceId(space),
17591+
);
17592+
const sourceTab =
17593+
(sourceSpace?.activeTabId
17594+
? sourceSpace.tabs.get(sourceSpace.activeTabId)
17595+
: null) ??
17596+
(sourceSpace ? Array.from(sourceSpace.tabs.values())[0] ?? null : null);
17597+
if (!sourceTab) {
17598+
return {
17599+
url: HOME_URL,
17600+
title: NEW_TAB_TITLE,
17601+
skipInitialHistoryRecord: false,
17602+
};
17603+
}
17604+
17605+
const sourceContents = sourceTab.view.webContents;
17606+
return {
17607+
url: sourceContents.getURL() || sourceTab.state.url || HOME_URL,
17608+
title: sourceContents.getTitle() || sourceTab.state.title || NEW_TAB_TITLE,
17609+
faviconUrl: sourceTab.state.faviconUrl,
17610+
// Mirrored first-tab seeding should not create duplicate history entries.
17611+
skipInitialHistoryRecord: true,
17612+
};
17613+
}
17614+
1755517615
function ensureBrowserTabSpaceInitialized(
1755617616
workspaceId: string,
1755717617
space: BrowserSpaceId,
@@ -17562,8 +17622,9 @@ function ensureBrowserTabSpaceInitialized(
1756217622
return false;
1756317623
}
1756417624

17625+
const seed = initialBrowserTabSeed(workspaceId, space);
1756517626
const initialTabId = createBrowserTab(workspaceId, {
17566-
url: HOME_URL,
17627+
...seed,
1756717628
browserSpace: space,
1756817629
});
1756917630
tabSpace.activeTabId = initialTabId ?? "";
@@ -19810,14 +19871,14 @@ app.whenReady().then(async () => {
1981019871
handleTrustedIpc(
1981119872
"workspace:getSessionHistory",
1981219873
["main"],
19813-
async (_event, payload: { sessionId: string; workspaceId: string }) =>
19814-
getSessionHistory(payload.sessionId, payload.workspaceId),
19874+
async (_event, payload: SessionHistoryRequestPayload) =>
19875+
getSessionHistory(payload),
1981519876
);
1981619877
handleTrustedIpc(
1981719878
"workspace:getSessionOutputEvents",
1981819879
["main"],
19819-
async (_event, payload: { sessionId: string }) =>
19820-
getSessionOutputEvents(payload.sessionId),
19880+
async (_event, payload: SessionOutputEventListRequestPayload) =>
19881+
getSessionOutputEvents(payload),
1982119882
);
1982219883
handleTrustedIpc(
1982319884
"workspace:stageSessionAttachments",

desktop/electron/preload.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,14 @@ interface SessionHistoryResponsePayload {
671671
raw: unknown | null;
672672
}
673673

674+
interface SessionHistoryRequestPayload {
675+
sessionId: string;
676+
workspaceId: string;
677+
limit?: number;
678+
offset?: number;
679+
order?: "asc" | "desc";
680+
}
681+
674682
interface SessionOutputEventPayload {
675683
id: number;
676684
workspace_id: string;
@@ -682,6 +690,11 @@ interface SessionOutputEventPayload {
682690
created_at: string;
683691
}
684692

693+
interface SessionOutputEventListRequestPayload {
694+
sessionId: string;
695+
inputId?: string | null;
696+
}
697+
685698
interface SessionOutputEventListResponsePayload {
686699
items: SessionOutputEventPayload[];
687700
count: number;
@@ -1261,9 +1274,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
12611274
ipcRenderer.invoke("workspace:createAgentSession", payload) as Promise<CreateAgentSessionResponsePayload>,
12621275
listRuntimeStates: (workspaceId: string) =>
12631276
ipcRenderer.invoke("workspace:listRuntimeStates", workspaceId) as Promise<SessionRuntimeStateListResponsePayload>,
1264-
getSessionHistory: (payload: { sessionId: string; workspaceId: string }) =>
1277+
getSessionHistory: (payload: SessionHistoryRequestPayload) =>
12651278
ipcRenderer.invoke("workspace:getSessionHistory", payload) as Promise<SessionHistoryResponsePayload>,
1266-
getSessionOutputEvents: (payload: { sessionId: string }) =>
1279+
getSessionOutputEvents: (payload: SessionOutputEventListRequestPayload) =>
12671280
ipcRenderer.invoke("workspace:getSessionOutputEvents", payload) as Promise<SessionOutputEventListResponsePayload>,
12681281
stageSessionAttachments: (payload: StageSessionAttachmentsPayload) =>
12691282
ipcRenderer.invoke("workspace:stageSessionAttachments", payload) as Promise<StageSessionAttachmentsResponsePayload>,
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
import { readFile } from "node:fs/promises";
4+
5+
const MAIN_PATH = new URL("./main.ts", import.meta.url);
6+
const PRELOAD_PATH = new URL("./preload.ts", import.meta.url);
7+
const ELECTRON_TYPES_PATH = new URL("../src/types/electron.d.ts", import.meta.url);
8+
9+
test("desktop session history bridge forwards pagination and per-input output event filters", async () => {
10+
const [mainSource, preloadSource, typesSource] = await Promise.all([
11+
readFile(MAIN_PATH, "utf8"),
12+
readFile(PRELOAD_PATH, "utf8"),
13+
readFile(ELECTRON_TYPES_PATH, "utf8"),
14+
]);
15+
16+
assert.match(mainSource, /interface SessionHistoryRequestPayload \{[\s\S]*limit\?: number;[\s\S]*order\?: "asc" \| "desc";[\s\S]*\}/);
17+
assert.match(
18+
mainSource,
19+
/params: \{\s*workspace_id: payload\.workspaceId,\s*limit: payload\.limit \?\? 200,\s*offset: payload\.offset \?\? 0,\s*order: payload\.order \?\? "asc",\s*\}/,
20+
);
21+
assert.match(
22+
mainSource,
23+
/async \(_event, payload: SessionHistoryRequestPayload\) =>\s*getSessionHistory\(payload\)/,
24+
);
25+
assert.match(mainSource, /interface SessionOutputEventListRequestPayload \{[\s\S]*inputId\?: string \| null;[\s\S]*\}/);
26+
assert.match(
27+
mainSource,
28+
/params: \{\s*input_id: payload\.inputId \?\? undefined,\s*include_history: true,\s*after_event_id: 0,\s*\}/,
29+
);
30+
31+
assert.match(preloadSource, /getSessionHistory: \(payload: SessionHistoryRequestPayload\) =>/);
32+
assert.match(preloadSource, /getSessionOutputEvents: \(payload: SessionOutputEventListRequestPayload\) =>/);
33+
34+
assert.match(typesSource, /interface SessionHistoryRequestPayload \{[\s\S]*limit\?: number;[\s\S]*order\?: "asc" \| "desc";[\s\S]*\}/);
35+
assert.match(typesSource, /interface SessionOutputEventListRequestPayload \{[\s\S]*inputId\?: string \| null;[\s\S]*\}/);
36+
assert.match(typesSource, /getSessionHistory: \(payload: SessionHistoryRequestPayload\) => Promise<SessionHistoryResponsePayload>;/);
37+
assert.match(typesSource, /getSessionOutputEvents: \(payload: SessionOutputEventListRequestPayload\) => Promise<SessionOutputEventListResponsePayload>;/);
38+
});

desktop/src/components/marketplace/SimpleMarkdown.test.mjs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@ import test from "node:test";
22
import assert from "node:assert/strict";
33
import { readFile } from "node:fs/promises";
44
import path from "node:path";
5-
import { fileURLToPath } from "node:url";
5+
import { fileURLToPath, pathToFileURL } from "node:url";
66

77
const __dirname = path.dirname(fileURLToPath(import.meta.url));
88
const sourcePath = path.join(__dirname, "SimpleMarkdown.tsx");
9+
const normalizationPath = path.join(__dirname, "markdownFenceNormalization.mjs");
910

1011
test("simple markdown uses react-markdown with gfm and safe defaults", async () => {
1112
const source = await readFile(sourcePath, "utf8");
1213

1314
assert.match(source, /import ReactMarkdown, \{ defaultUrlTransform, type Components \} from "react-markdown";/);
1415
assert.match(source, /import remarkGfm from "remark-gfm";/);
16+
assert.match(source, /import \{ normalizeWrappedMarkdownFence \} from "\.\/markdownFenceNormalization\.mjs";/);
1517
assert.match(source, /remarkPlugins=\{\[remarkGfm\]\}/);
1618
assert.match(source, /skipHtml/);
1719
assert.match(source, /urlTransform=\{defaultUrlTransform\}/);
@@ -42,9 +44,55 @@ test("simple markdown memoizes renderer components to keep chat content stable d
4244
const source = await readFile(sourcePath, "utf8");
4345

4446
assert.match(source, /import \{ memo, useMemo \} from "react";/);
47+
assert.match(
48+
source,
49+
/const normalizedChildren = useMemo\(\s*\(\) => normalizeWrappedMarkdownFence\(children\),\s*\[children\],\s*\);/,
50+
);
4551
assert.match(
4652
source,
4753
/const components = useMemo\(\s*\(\) => createMarkdownComponents\(onLinkClick\),\s*\[onLinkClick\],\s*\);/,
4854
);
55+
assert.match(source, /<ReactMarkdown[\s\S]*>\s*\{normalizedChildren\}\s*<\/ReactMarkdown>/);
4956
assert.match(source, /export const SimpleMarkdown = memo\(SimpleMarkdownComponent\);/);
5057
});
58+
59+
test("markdown fence normalization repairs wrapped markdown that contains nested code fences", async () => {
60+
const { normalizeWrappedMarkdownFence } = await import(pathToFileURL(normalizationPath).href);
61+
62+
const broken = [
63+
"Draft preview:",
64+
"",
65+
"```md",
66+
"# AGENTS.md",
67+
"",
68+
"```csv",
69+
"name,value",
70+
"```",
71+
"",
72+
"```",
73+
"",
74+
"Confirm before writing it to disk.",
75+
].join("\n");
76+
77+
const normalized = normalizeWrappedMarkdownFence(broken);
78+
79+
assert.match(normalized, /````md/);
80+
assert.match(normalized, /name,value/);
81+
assert.match(normalized, /\n````\n\nConfirm before writing it to disk\.$/);
82+
});
83+
84+
test("markdown fence normalization leaves separate markdown and csv blocks unchanged", async () => {
85+
const { normalizeWrappedMarkdownFence } = await import(pathToFileURL(normalizationPath).href);
86+
87+
const separateBlocks = [
88+
"```md",
89+
"# AGENTS.md",
90+
"```",
91+
"",
92+
"```csv",
93+
"name,value",
94+
"```",
95+
].join("\n");
96+
97+
assert.equal(normalizeWrappedMarkdownFence(separateBlocks), separateBlocks);
98+
});

desktop/src/components/marketplace/SimpleMarkdown.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import { memo, useMemo } from "react";
77
import ReactMarkdown, { defaultUrlTransform, type Components } from "react-markdown";
88
import remarkGfm from "remark-gfm";
9+
import { normalizeWrappedMarkdownFence } from "./markdownFenceNormalization.mjs";
910

1011
function appendClassName(current: string | undefined, next: string): string {
1112
return current ? `${current} ${next}` : next;
@@ -124,6 +125,10 @@ function SimpleMarkdownComponent({
124125
className = "",
125126
onLinkClick,
126127
}: SimpleMarkdownProps) {
128+
const normalizedChildren = useMemo(
129+
() => normalizeWrappedMarkdownFence(children),
130+
[children],
131+
);
127132
const components = useMemo(
128133
() => createMarkdownComponents(onLinkClick),
129134
[onLinkClick],
@@ -137,7 +142,7 @@ function SimpleMarkdownComponent({
137142
skipHtml
138143
urlTransform={defaultUrlTransform}
139144
>
140-
{children}
145+
{normalizedChildren}
141146
</ReactMarkdown>
142147
</div>
143148
);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export function normalizeWrappedMarkdownFence(markdown: string): string;

0 commit comments

Comments
 (0)