Skip to content

Commit 106ad6c

Browse files
BUG: Isolate derived plan session state
Background events from the resumable planning session could continue updating the newly active implementation transcript, status, retry state, permissions, and process output. Mutating titles with a planned suffix also made lineage ambiguous and accumulated unstable labels. Scope UI callbacks and process output to their originating session while still refreshing the session index for inactive updates. Render planned and implementation badges from structured derivation metadata outside title truncation, and clear process output when navigating. Stopping source background processes was rejected because the source session must remain resumable and persist its eventual completion. Verified with repository checks, the 324-test CLI suite, the 354-test core suite with one platform skip, and the 57-test VS Code suite. Co-authored-by: Codex <noreply@openai.com>
1 parent a1ebed9 commit 106ad6c

5 files changed

Lines changed: 96 additions & 5 deletions

File tree

‎packages/cli/src/tests/exec-runner.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ function createHarness(scenario: ManagerScenario = {}) {
147147
},
148148
true
149149
);
150-
options.onProcessStdout?.(123, "process output\n");
150+
options.onProcessStdout?.(123, "process output\n", activeId);
151151
scenario.duringPrompt?.();
152152
entry = createEntry(activeId, scenario.finalStatus ?? "completed", {
153153
assistantReply: scenario.finalReply === undefined ? "final answer" : scenario.finalReply,

‎packages/cli/src/tests/session-list.test.ts‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { test } from "node:test";
22
import assert from "node:assert/strict";
3-
import { formatSessionTitle, filterSessions, formatSessionStatus } from "../ui";
3+
import { formatSessionTitle, filterSessions, formatSessionStatus, getSessionBadges, isActiveSessionEvent } from "../ui";
44
import type { SessionEntry } from "@vegamo/deepcode-core";
55

66
test("formatSessionTitle replaces newlines with spaces", () => {
@@ -11,6 +11,31 @@ test("formatSessionTitle truncates after normalizing whitespace", () => {
1111
assert.equal(formatSessionTitle("one\n two three", 10), "one two th…");
1212
});
1313

14+
test("plan derivation badges remain separate from long truncated titles", () => {
15+
const [source, implementation, fork] = buildSessions([
16+
{ id: "source", summary: "A very long plan title that must be independently truncated" },
17+
{
18+
id: "implementation",
19+
summary: "A very long plan title that must be independently truncated",
20+
derivedFrom: { kind: "plan-implementation", sessionId: "source", messageId: "plan-message" },
21+
},
22+
{ id: "fork", forkedFrom: { sessionId: "source", messageId: "plan-message" } },
23+
]);
24+
25+
assert.equal(formatSessionTitle(source!.summary!, 20), "A very long plan tit…");
26+
assert.deepEqual(getSessionBadges(source!, [source!, implementation!, fork!]), ["planned"]);
27+
assert.deepEqual(getSessionBadges(implementation!, [source!, implementation!, fork!]), ["implementation"]);
28+
assert.deepEqual(getSessionBadges(fork!, [source!, implementation!, fork!]), []);
29+
});
30+
31+
test("session callbacks ignore events from inactive sessions", () => {
32+
assert.equal(isActiveSessionEvent("implementation", "source"), false);
33+
assert.equal(isActiveSessionEvent("implementation", "implementation"), true);
34+
assert.equal(isActiveSessionEvent("implementation", undefined), true);
35+
assert.equal(isActiveSessionEvent(null, "source"), false);
36+
assert.equal(isActiveSessionEvent(null, "source", true), true);
37+
});
38+
1439
test("formatSessionStatus maps status values to display labels", () => {
1540
assert.equal(formatSessionStatus("completed"), "done");
1641
assert.equal(formatSessionStatus("processing"), "running");
@@ -101,7 +126,7 @@ test("filterSessions handles sessions with null fields", () => {
101126

102127
function buildSessions(overrides: Array<Partial<SessionEntry>>): SessionEntry[] {
103128
return overrides.map((override, i) => ({
104-
id: `session-${i}`,
129+
id: override.id ?? `session-${i}`,
105130
summary: override.summary ?? null,
106131
assistantReply: override.assistantReply ?? null,
107132
assistantThinking: null,
@@ -115,5 +140,7 @@ function buildSessions(overrides: Array<Partial<SessionEntry>>): SessionEntry[]
115140
createTime: new Date().toISOString(),
116141
updateTime: new Date().toISOString(),
117142
processes: null,
143+
forkedFrom: override.forkedFrom,
144+
derivedFrom: override.derivedFrom,
118145
}));
119146
}

‎packages/cli/src/ui/index.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export {
1414
resolvePromptTerminalCursorPosition,
1515
} from "./hooks/cursor";
1616
export { default as AppContainer } from "./views/AppContainer";
17+
export { isActiveSessionEvent } from "./views/App";
1718
export { AskUserQuestionPrompt, applyOtherAnswerEdit } from "./views/AskUserQuestionPrompt";
1819
export {
1920
PlanImplementationPrompt,
@@ -39,7 +40,13 @@ export {
3940
type PromptSubmission,
4041
type PromptDraft,
4142
} from "./views/PromptInput";
42-
export { SessionList, formatSessionTitle, filterSessions, formatSessionStatus } from "./views/SessionList";
43+
export {
44+
SessionList,
45+
formatSessionTitle,
46+
filterSessions,
47+
formatSessionStatus,
48+
getSessionBadges,
49+
} from "./views/SessionList";
4350
export { ThemedGradient } from "./views/ThemedGradient";
4451
export { UpdatePrompt, type UpdatePromptChoice } from "./views/UpdatePrompt";
4552
export { WelcomeScreen, formatHomeRelativePath, buildWelcomeTips } from "./views/WelcomeScreen";

‎packages/cli/src/ui/views/App.tsx‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,18 @@ type AppProps = {
6969
onRestart?: () => void;
7070
};
7171

72+
export function isActiveSessionEvent(
73+
activeSessionId: string | null,
74+
eventSessionId?: string,
75+
allowWhenNoActive = false
76+
): boolean {
77+
return (
78+
eventSessionId === undefined ||
79+
eventSessionId === activeSessionId ||
80+
(activeSessionId === null && allowWhenNoActive)
81+
);
82+
}
83+
7284
const StatusLine = React.memo(function StatusLine({
7385
busy,
7486
text,
@@ -111,6 +123,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
111123
const resumeSessionIdRef = useRef(false);
112124
const startupDoneRef = useRef(false);
113125
const processStdoutRef = useRef<Map<number, string>>(new Map());
126+
const busyRef = useRef(false);
114127
const rawModeRef = useRef<RawMode>(mode);
115128
const writeRef = useRef(write);
116129
const lastRenderedColumnsRef = useRef<number | null>(null);
@@ -155,19 +168,29 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
155168
getResolvedSettings: () => resolveCurrentSettings(projectRoot),
156169
renderMarkdown: (text) => text,
157170
onAssistantMessage: (message: SessionMessage) => {
171+
if (!isActiveSessionEvent(sessionManager.getActiveSessionId(), message.sessionId, busyRef.current)) {
172+
return;
173+
}
158174
setMessages((prev) => [...prev, message]);
159175
if (rawModeRef.current === RawMode.Raw) {
160176
writeStdoutLine("\n");
161177
writeStdoutLine(renderMessageToStdout(message, rawModeRef.current) + "\n\n");
162178
}
163179
},
164180
onSessionEntryUpdated: (entry) => {
181+
setSessions(sessionManager.listSessions());
182+
if (!isActiveSessionEvent(sessionManager.getActiveSessionId(), entry.id, busyRef.current)) {
183+
return;
184+
}
165185
setStatusLine(buildStatusLine(entry, resolveCurrentSettings(projectRoot)));
166186
setRunningProcesses(entry.processes);
167187
setActiveStatus(entry.status);
168188
setActiveAskPermissions(entry.askPermissions);
169189
},
170190
onLlmStreamProgress: (progress) => {
191+
if (!isActiveSessionEvent(sessionManager.getActiveSessionId(), progress.sessionId, busyRef.current)) {
192+
return;
193+
}
171194
setRetryEvent(null);
172195
if (progress.phase === "end") {
173196
setStreamProgress(null);
@@ -176,13 +199,19 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
176199
setStreamProgress(progress);
177200
},
178201
onLlmRetry: (event) => {
202+
if (!isActiveSessionEvent(sessionManager.getActiveSessionId(), event.sessionId, busyRef.current)) {
203+
return;
204+
}
179205
setRetryEvent(event);
180206
},
181207
onMcpStatusChanged: () => {
182208
// 当 MCP 状态变更时,如果当前正在查看 MCP 状态页面,则更新显示
183209
setMcpStatuses(sessionManager.getMcpStatus());
184210
},
185-
onProcessStdout: (pid, chunk) => {
211+
onProcessStdout: (pid, chunk, sessionId) => {
212+
if (!isActiveSessionEvent(sessionManager.getActiveSessionId(), sessionId)) {
213+
return;
214+
}
186215
const buf = processStdoutRef.current;
187216
const current = buf.get(pid) ?? "";
188217
// Cap at 1 MB per process to avoid unbounded memory growth
@@ -371,6 +400,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
371400
try {
372401
const sessionId = sessionManager.forkSession(sourceSessionId);
373402
sessionManager.setActiveSessionId(sessionId);
403+
processStdoutRef.current.clear();
374404
await resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true });
375405
const session = sessionManager.getSession(sessionId);
376406
setStatusLine(session ? buildStatusLine(session, resolveCurrentSettings(projectRoot)) : "");
@@ -446,6 +476,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
446476
}
447477

448478
setBusy(true);
479+
busyRef.current = true;
449480
setErrorLine(null);
450481
setRetryEvent(null);
451482
const activeProcesses = activeSessionId ? (sessionManager.getSession(activeSessionId)?.processes ?? null) : null;
@@ -472,6 +503,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
472503
setErrorLine(message);
473504
} finally {
474505
setBusy(false);
506+
busyRef.current = false;
475507
setStreamProgress(null);
476508
setRetryEvent(null);
477509
const finalActiveSessionId = sessionManager.getActiveSessionId();
@@ -665,6 +697,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
665697
const handleSelectSession = useCallback(
666698
async (sessionId: string) => {
667699
sessionManager.setActiveSessionId(sessionId);
700+
processStdoutRef.current.clear();
668701
// Clear first so <Static> resets its index to 0.
669702
await resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true });
670703
const session = sessionManager.getSession(sessionId);

‎packages/cli/src/ui/views/SessionList.tsx‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete, onRename }
312312
const isSelected = actualIndex === safeIndex;
313313
const isConfirming = confirmDeleteSessionId === session.id;
314314
const isRenaming = renameSessionId === session.id;
315+
const badges = getSessionBadges(session, sessions);
315316
return (
316317
<Box key={session.id} height={2} marginBottom={1}>
317318
<Box>
@@ -330,6 +331,13 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete, onRename }
330331
{formatSessionTitle(session.summary || "Untitled")}
331332
</Text>
332333
)}
334+
{!isRenaming
335+
? badges.map((badge) => (
336+
<Text key={badge} color={badge === "implementation" ? "cyan" : "yellow"}>
337+
{` [${badge}]`}
338+
</Text>
339+
))
340+
: null}
333341
{isConfirming ? (
334342
<Text color="yellow"> [Delete? Enter=yes, Esc=no]</Text>
335343
) : isRenaming ? null : (
@@ -413,6 +421,22 @@ export function formatSessionTitle(value: string, max = 70): string {
413421
return truncate(value.replace(/\r?\n/g, " ").replace(/\s+/g, " ").trim(), max);
414422
}
415423

424+
export function getSessionBadges(session: SessionEntry, sessions: SessionEntry[]): Array<"planned" | "implementation"> {
425+
const badges: Array<"planned" | "implementation"> = [];
426+
if (
427+
sessions.some(
428+
(candidate) =>
429+
candidate.derivedFrom?.kind === "plan-implementation" && candidate.derivedFrom.sessionId === session.id
430+
)
431+
) {
432+
badges.push("planned");
433+
}
434+
if (session.derivedFrom?.kind === "plan-implementation") {
435+
badges.push("implementation");
436+
}
437+
return badges;
438+
}
439+
416440
export function formatSessionStatus(status: SessionStatus): string {
417441
switch (status) {
418442
case "completed":

0 commit comments

Comments
 (0)