diff --git a/.project-docs/30-worklog/tasks/20260825-pi-user-entry-fork-4a7d9c21.md b/.project-docs/30-worklog/tasks/20260825-pi-user-entry-fork-4a7d9c21.md
new file mode 100644
index 0000000..feadd73
--- /dev/null
+++ b/.project-docs/30-worklog/tasks/20260825-pi-user-entry-fork-4a7d9c21.md
@@ -0,0 +1,191 @@
+# Task: Fix Pi user-entry conversation fork
+
+## Identity
+
+- Task ID: 20260825-pi-user-entry-fork-4a7d9c21
+- Mode: Feature
+- Branch: codex/20260825-pi-user-entry-fork-4a7d9c21-pi-user-entry-fork
+- Worktree: D:\Datas\OthersProjects\makelore-pi-user-entry-fork-4a7d9c21
+- Base commit: 941b015330206af648f944183846ac2857468847
+- Owner: codex-root
+- Status: Verifying
+
+## Scope
+
+- Restrict the Renderer fork action to durable user messages on the active
+ Conversation path.
+- Validate the same invariant in `CodingConversationService.fork` before any
+ target Conversation metadata, worker, session binding, archive, or cleanup
+ resource can be created.
+- Preserve the existing real Pi `0.84.2` fork RPC for a valid user entry and
+ prove the resulting target binding/hydration through unit, managed runtime,
+ Windows Electron, and final packaged-product seams.
+- Produce and verify a new Windows x64 NSIS without modifying the user's
+ installed application.
+
+## Intent And Constraints
+
+- Keep the Pi hard cutover and current project/Agent/Conversation ownership.
+ Do not add an OpenCode fallback, runtime restart control, compatibility
+ layer, schema migration, feature flag, Pi upgrade, or Provider/worker-pool
+ redesign.
+- A valid source is a message node on the source Snapshot's current active
+ path with the exact `sourceEntryId`, `role === 'user'`, and non-optimistic
+ durable state. Missing, assistant, unknown, stale, abandoned-path, or
+ optimistic sources fail before target creation.
+- Invalid input returns existing `400 CODING_CONVERSATION_REQUEST_INVALID`
+ through the fixed safe Chinese route projection. Pi's raw rejection,
+ stderr, session paths, prompts, credentials, and provider data must not
+ reach Renderer or proof reports.
+- Keep fork mutation no-replay: a timeout or uncertain result is never
+ automatically submitted again. A valid fork retains the existing source
+ session and hydrates a distinct target binding under the same Agent.
+- Preserve cumulative base `941b015330206af648f944183846ac2857468847`,
+ including product parent `45d933732a8fbc0dadb07a4a4d65f0d12000c2ea`
+ and its Agent-owned Conversation hierarchy. Do not touch dirty `main`, the
+ diagnosis worktree/record, previous packaging worktrees, or user install.
+- Real external Provider remains Explicitly Waived / Accepted Risk with
+ `realTurnVerified=false`; macOS/native Linux status is unchanged and is not
+ Pass evidence. Do not create subagents, push, or publish.
+
+## Project Context Loaded
+
+Task context:
+- Task ID: `20260825-pi-user-entry-fork-4a7d9c21`
+- Mode: feature
+- Branch: `codex/20260825-pi-user-entry-fork-4a7d9c21-pi-user-entry-fork`
+- Worktree: `D:\Datas\OthersProjects\makelore-pi-user-entry-fork-4a7d9c21`
+- Base commit: `941b015330206af648f944183846ac2857468847`
+- Other active local tasks: eleven non-ready owner records were inspected.
+ One AI Design E2E task has a narrow unrelated test-only scope; the occupied
+ `main` integration task owns historical OpenCode model-switch integration.
+ The remaining older planning records still contain undefined placeholder
+ scopes.
+- Overlap or semantic-conflict assessment: no known peer owns Pi fork,
+ `CodingConversationTimeline`, `CodingConversationService`, or packaged Pi
+ fork proof semantics. Placeholder scopes remain unknown coordination state,
+ but their stated titles and isolated worktrees expose no semantic conflict
+ that changes this plan.
+
+Read:
+- `.project-docs/05-agent-entry/read-before-planning.md`
+- `.project-docs/05-agent-entry/memory-index.md`
+- `.project-docs/05-agent-entry/planning-gate.md`
+- this active task record
+- project positioning, current state, decision index, system overview,
+ module map, data flow, business rules, success criteria, glossary, evidence,
+ reflection, commitments, and stale-item indexes
+- diagnosis task `20260825-fork-runtime-unavailable-8e7c4a21`
+- Pi cutover Spec and the relevant PI-050/060/100/130/150 ticket sections
+- PI Conversation contracts, Renderer store, Host cutover, and cumulative
+ Agent→Conversation hierarchy task records
+- every non-ready peer's Scope, Intent And Constraints, and Promotion
+ Candidates sections
+
+Relevant understanding:
+- Project goal: Makelore Code exposes a vendor-neutral, local project-scoped
+ Conversation product while Electron Main exclusively owns Pi runtime,
+ sessions, credentials, files, and recovery.
+- Current integrated focus: shared canonical memory is OpenCode-stale relative
+ to the cumulative Pi product. Current source, the Pi Spec/task chain, and the
+ committed diagnosis are authoritative for this repair.
+- Active task scope: correct the supported fork source and error boundary only;
+ do not reinterpret a deterministic Pi entry rejection as runtime failure.
+- Active constraints: target validation precedes metadata/resource creation;
+ valid user fork semantics and no-replay remain unchanged; the Agent-owned
+ Conversation hierarchy from `45d9337` must remain intact.
+- Decisions affecting this task: Spec route `/api/coding/conversations/:id/fork`
+ is explicitly from a user entry; hydrated Snapshot nodes contain only the
+ active path and expose durable `sourceEntryId` without leaking Pi wire.
+- Evidence, reflections, or commitments affecting this task: real pinned Pi
+ accepted the persisted user entry and rejected the assistant entry with
+ empty stderr. Windows formal packaging must retain artifact/runtime closure;
+ real Provider and non-Windows platform gates remain out of scope.
+- Files or modules likely involved: Timeline and Chat E2E, shared/facade fork
+ types, `conversation-service.ts`, focused Host/runtime tests, Pi packaged
+ release proof, packaged smoke runner, and Windows artifact reports.
+- Unknowns, stale docs, or conflicts: canonical positioning is a placeholder
+ and architecture/data-flow are OpenCode-stale. Several peer task scopes are
+ placeholders, but no known decision conflicts with the precise Pi repair.
+
+Gate result:
+- Passed.
+
+## Plan
+
+1. Add failing Renderer and Main regressions for user-only visibility, exact
+ callback identity, invalid active-path rejection, and zero target-side
+ effects; verify they fail on the diagnosed behavior.
+2. Implement the smallest product-contract correction: user-only durable UI
+ action and service-owned active-Snapshot validation before target create.
+3. Add valid user fork coverage through the real pinned Pi managed opener and
+ service seam, checking distinct target binding, Pi session identity,
+ hydrated Snapshot, source isolation, and clean worker shutdown.
+4. Extend Windows Electron E2E and final packaged Main/UI proof so assistant
+ has no action, the user action creates/selects a same-Agent branch, the
+ original remains unchanged, and no runtime-unavailable state or process
+ residue appears.
+5. Run frozen install, focused tests, typecheck, lint, full unit, production
+ build, Windows Electron E2E, then commit a clean candidate and run formal
+ `package:win`, Windows/Pi closure verifiers, packaged proof, signing/hash,
+ documentation drift, registry completion, and final clean-state checks.
+
+## Outcome
+
+- Renderer now exposes the fork action only on non-optimistic user message
+ nodes with a durable `sourceEntryId`; assistant and non-persisted nodes do
+ not expose the action.
+- `CodingConversationService.fork` now resolves the source Conversation's
+ authoritative hydrated Snapshot and rejects missing, assistant, unknown,
+ stale, or abandoned-path entries with
+ `400 CODING_CONVERSATION_REQUEST_INVALID` before target metadata creation.
+ The Host keeps its fixed safe Chinese projection and never exposes Pi's raw
+ `Invalid entry ID for forking` response.
+- A valid user entry still reaches the locked Pi fork RPC. The permanent real
+ Pi `0.84.2` regression proves source hydration, a same-Agent target with a
+ distinct Pi session binding, correct before-entry target hydration, source
+ isolation, and clean shutdown of both workers.
+- Windows Electron coverage now proves one user fork action for a history that
+ also contains an assistant entry, exact `sourceEntryId` submission, target
+ selection under the same Agent, preserved source history, and absence of
+ the runtime-unavailable banner.
+- The final packaged proxy proof now performs the user-entry fork through the
+ real packaged Main composition and UI after a settled controlled turn. It
+ checks distinct bindings, correct target hydrate, source preservation,
+ zero prompt replay, user/assistant action visibility, process cleanup, and
+ the existing credential/role-contract boundaries.
+- `README.md` now states that only persisted user messages are fork sources.
+ No Provider, worker-pool, schema, compatibility, or fallback architecture
+ changed.
+
+## Verification
+
+- `corepack pnpm install --frozen-lockfile` — passed with package-manager
+ pinned pnpm `10.33.4` and Pi `0.84.2`.
+- Red phase — the focused Timeline/Main tests failed as expected: assistant
+ exposed a second fork action, and invalid entries reached target creation.
+- Focused green — `4` files / `29` tests passed, including the real managed Pi
+ service fork and release-proof wiring.
+- `corepack pnpm run typecheck` — passed.
+- `corepack pnpm run lint:check` — passed with the existing five warnings and
+ no errors.
+- `corepack pnpm test` — passed: `180` files / `1525` tests plus the isolated
+ pressure test; `2` pre-existing skips.
+- `corepack pnpm run build:vite` — passed for Renderer, Main, Preload, and
+ release utility worker.
+- Focused Windows Electron Playwright — passed: `2` tests, including the
+ same-Agent user-entry branch flow and source isolation.
+- `corepack pnpm run test:electron:windows` — passed: `2` files / `4` tests.
+- Formal Windows package, artifact closure, packaged UI/Main fork proof,
+ signing, fingerprint, and zero-residue evidence remain the active gate.
+
+## Follow-ups
+
+- Real external Provider execution remains explicitly waived and is not proof
+ for this fix (`realTurnVerified=false`). macOS and native non-WSL Linux
+ states remain unchanged.
+
+## Promotion Candidates
+
+- None. The current Pi Spec already states that fork is from a user entry; the
+ product README and this task-owned record now match that existing decision.
diff --git a/README.md b/README.md
index 541d3f4..148c5db 100644
--- a/README.md
+++ b/README.md
@@ -133,7 +133,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
- Makelore 在应用侧按 Session 独立提交、跟踪和隔离运行状态,不使用“当前对话正在回复”的全局界面锁;同一 Session 的后续消息仍按顺序排队。最终产品中的 Pi 运行时会通过受控 Provider-shaped 回环 smoke 验证两个 worker 的重叠执行、状态隔离与凭证引用隔离;真实外部 Provider 的并发、限流、协议兼容和凭证隔离仍是独立风险,未执行真实 Provider 验证时不得标记为 Pass。
- 首次发送会立即生成稳定的乐观用户消息;HTTP 202 只表示本地 Agent 已接收。后续失败不会删除已接受消息,不确定交付不会自动重发;准备失败可在目标 Conversation 上手动恢复。
- 运行中的 Conversation 可把新消息作为 steer 引导当前回答或 follow-up 排到下一轮,并显示队列位置;队列只在 `agent_settled` 后释放,用户可中止当前运行。select/confirm/input/editor 交互在输入区上方回答,并明确展示取消或失效结果。
-- 核心聊天页支持标题、归档、未读、恢复,以及“从这里创建新对话分支”;分支只创建新的 Conversation 历史,不表示文件回滚。右侧编程工具集中展示当前 Conversation 的 changes、项目文件预览、浏览器附件、技能、命令与脱敏运行诊断。分享、待办、全局运行时和 revert/unrevert 不属于该产品界面。
+- 核心聊天页支持标题、归档、未读、恢复,以及从已持久化的 user 消息“从这里创建新对话分支”;assistant 消息和未持久化消息不提供该动作。分支只创建新的 Conversation 历史,不表示文件回滚。右侧编程工具集中展示当前 Conversation 的 changes、项目文件预览、浏览器附件、技能、命令与脱敏运行诊断。分享、待办、全局运行时和 revert/unrevert 不属于该产品界面。
## 兼容标识
diff --git a/electron/coding-runtime/conversation-service.ts b/electron/coding-runtime/conversation-service.ts
index 93c105b..2594edb 100644
--- a/electron/coding-runtime/conversation-service.ts
+++ b/electron/coding-runtime/conversation-service.ts
@@ -491,7 +491,27 @@ export class CodingConversationService {
}
async fork(sourceConversationId: string, sourceEntryId?: string): Promise {
+ const entryId = requiredString(sourceEntryId, 'Fork source entry id', 256);
const prepared = await this.ensurePrepared(sourceConversationId);
+ let sourceSnapshot: ConversationSnapshot;
+ try {
+ sourceSnapshot = await this.runtime.getSnapshot(sourceConversationId);
+ } catch (error) {
+ runtimeError(error);
+ }
+ const sourceNode = sourceSnapshot.nodes.find((node) => (
+ node.kind === 'message'
+ && node.sourceEntryId === entryId
+ && node.role === 'user'
+ && node.status !== 'optimistic'
+ ));
+ if (!sourceNode) {
+ throw new CodingConversationServiceError(
+ 400,
+ 'CODING_CONVERSATION_REQUEST_INVALID',
+ 'Fork source must be a durable user message on the active Conversation path',
+ );
+ }
const source = await this.projects.findActiveConversation(sourceConversationId);
const store = this.projects.conversationStore(source.project.path);
const created = await persist(() => store.create({
@@ -503,7 +523,7 @@ export class CodingConversationService {
try {
await this.runtime.fork({
sourceConversationId,
- ...(sourceEntryId ? { sourceEntryId } : {}),
+ sourceEntryId: entryId,
conversation: {
...prepared,
conversationId: created.id,
diff --git a/electron/coding-runtime/pi/release-proof.ts b/electron/coding-runtime/pi/release-proof.ts
index 9855383..3c017f3 100644
--- a/electron/coding-runtime/pi/release-proof.ts
+++ b/electron/coding-runtime/pi/release-proof.ts
@@ -104,10 +104,12 @@ type ProxyCompositionRun = {
provider: LocalProofProvider;
projectId: string;
projectPath: string;
+ sourceConversationId?: string;
hostToken: string;
originalAcceptPrompt: CodingProductComposition['conversations']['acceptPrompt'];
submissionInjection: { definiteRejections: number };
activeStatus?: PiReleaseProxyCompositionStatus;
+ settledStatus?: PiReleaseProxyCompositionStatus;
};
type ResilienceCompositionRun = {
@@ -212,6 +214,18 @@ export interface PiReleaseProxyCompositionProof extends PiReleaseProxyCompositio
definiteRejectionObserved: boolean;
retryAccepted: boolean;
};
+ fork: {
+ conversationCount: number;
+ sameAgent: boolean;
+ targetBindingEstablished: boolean;
+ distinctSessionBinding: boolean;
+ workerStatus: string;
+ runStatus: string;
+ sourceEntryRole: 'user';
+ targetHydratedBeforeSourceEntry: boolean;
+ sourceUnchanged: boolean;
+ promptReplayObserved: boolean;
+ };
currentHostTokenUsedBy: ProofWorkerRole[];
released: { workers: number };
}
@@ -916,8 +930,12 @@ async function proxyCompositionStatus(
.filter(({ role }) => role === 'child')
.map(({ processId }) => processId)
.sort((left, right) => left - right);
+ const conversations = await run.composition.conversations.listConversations(run.projectId);
+ if (!run.sourceConversationId && conversations.length === 1) {
+ run.sourceConversationId = conversations[0]?.id;
+ }
return {
- conversationCount: (await run.composition.conversations.listConversations(run.projectId)).length,
+ conversationCount: conversations.length,
providerRequests,
activeProviderRequests: run.provider.activeCounts(),
workers: diagnostics.workers,
@@ -1442,6 +1460,12 @@ export async function getFinalAsarProxyCompositionStatus(): Promise 0) {
run.activeStatus = status;
}
+ if (status.conversationCount === 1
+ && status.activeProviderRequests.parent === 0
+ && status.activeProviderRequests.child === 0
+ && status.providerCompatibility.systemRoleRequests >= 2) {
+ run.settledStatus = status;
+ }
return status;
}
@@ -1456,10 +1480,12 @@ export async function finishFinalAsarProxyCompositionProof(): Promise id === run.sourceConversationId);
+ const forked = conversations.find(({ id }) => id !== run.sourceConversationId);
+ if (!conversation || !forked) throw new Error('Proxy fork source or target Conversation is unavailable');
let snapshot = await run.composition.conversations.getSnapshot(conversation.id);
const deadline = Date.now() + 30_000;
while (Date.now() < deadline && (
@@ -1473,9 +1499,24 @@ export async function finishFinalAsarProxyCompositionProof(): Promise (
+ node.kind === 'message'
+ && node.role === 'user'
+ && node.blocks.some((block) => block.kind === 'text'
+ && block.text.includes('Retry the packaged Main proxy Conversation'))
+ ));
+ if (!sourceEntry || sourceEntry.kind !== 'message' || !sourceEntry.sourceEntryId) {
+ throw new Error('Proxy source user entry is not durable after the controlled turn');
+ }
+ const [binding, forkedBinding] = await Promise.all([
+ run.composition.projects.conversationStore(run.projectPath).get(conversation.id),
+ run.composition.projects.conversationStore(run.projectPath).get(forked.id),
+ ]);
const finalStatus = await proxyCompositionStatus(run);
const activeStatus = run.activeStatus ?? finalStatus;
+ const settledStatus = run.settledStatus;
+ if (!settledStatus) throw new Error('Proxy source Conversation did not record a settled pre-fork baseline');
const currentHostTokenUsedBy: ProofWorkerRole[] = [
...(activeStatus.providerRequests.parent > 0 ? ['parent' as const] : []),
...(activeStatus.providerRequests.child > 0 ? ['child' as const] : []),
@@ -1486,6 +1527,27 @@ export async function finishFinalAsarProxyCompositionProof(): Promise (
+ node.kind !== 'message' || node.sourceEntryId !== sourceEntry.sourceEntryId
+ )) && !JSON.stringify(forkedSnapshot.nodes).includes('REAL_PARENT_COMPLETE');
+ if (forked.agentId !== conversation.agentId
+ || !distinctSessionBinding
+ || forkedSnapshot.worker.status !== 'ready'
+ || forkedSnapshot.run.status !== 'idle'
+ || !targetHydratedBeforeSourceEntry) {
+ throw new Error(`Proxy user-entry fork proof failed: ${JSON.stringify({
+ sameAgent: forked.agentId === conversation.agentId,
+ distinctSessionBinding,
+ workerStatus: forkedSnapshot.worker.status,
+ runStatus: forkedSnapshot.run.status,
+ targetHydratedBeforeSourceEntry,
+ })}`);
+ }
if (!finalStatus.providerCompatibility.roleContract.developerRejected
|| !finalStatus.providerCompatibility.roleContract.systemAccepted
|| finalStatus.providerCompatibility.developerRoleRequests !== 0
@@ -1512,7 +1574,7 @@ export async function finishFinalAsarProxyCompositionProof(): Promise= 2,
},
+ fork: {
+ conversationCount: conversations.length,
+ sameAgent: forked.agentId === conversation.agentId,
+ targetBindingEstablished: true,
+ distinctSessionBinding,
+ workerStatus: forkedSnapshot.worker.status,
+ runStatus: forkedSnapshot.run.status,
+ sourceEntryRole: 'user',
+ targetHydratedBeforeSourceEntry,
+ sourceUnchanged: serializedNodes.includes('Retry the packaged Main proxy Conversation')
+ && serializedNodes.includes('REAL_PARENT_COMPLETE'),
+ promptReplayObserved: finalStatus.providerRequests.parent !== settledStatus.providerRequests.parent
+ || finalStatus.providerRequests.child !== settledStatus.providerRequests.child,
+ },
currentHostTokenUsedBy,
};
await cleanupProxyCompositionRun(run);
diff --git a/scripts/run-pi-subagent-packaged-smoke.mjs b/scripts/run-pi-subagent-packaged-smoke.mjs
index a9f7c79..209d654 100644
--- a/scripts/run-pi-subagent-packaged-smoke.mjs
+++ b/scripts/run-pi-subagent-packaged-smoke.mjs
@@ -164,6 +164,16 @@ function assertProxyProof(proof) {
|| proof?.firstConversation?.parentResponseProjected !== true
|| proof?.firstConversation?.definiteRejectionObserved !== true
|| proof?.firstConversation?.retryAccepted !== true
+ || proof?.fork?.conversationCount !== 2
+ || proof?.fork?.sameAgent !== true
+ || proof?.fork?.targetBindingEstablished !== true
+ || proof?.fork?.distinctSessionBinding !== true
+ || proof?.fork?.workerStatus !== 'ready'
+ || proof?.fork?.runStatus !== 'idle'
+ || proof?.fork?.sourceEntryRole !== 'user'
+ || proof?.fork?.targetHydratedBeforeSourceEntry !== true
+ || proof?.fork?.sourceUnchanged !== true
+ || proof?.fork?.promptReplayObserved !== false
|| proof?.providerCompatibility?.developerRoleRequests !== 0
|| proof?.providerCompatibility?.controlledDefiniteRejections !== 1
|| proof?.currentHostTokenUsedBy?.join(',') !== 'parent,child'
@@ -460,6 +470,43 @@ export async function runPackagedProductProof(options) {
}
if (!await composer.isEnabled()) throw new Error('Packaged proxy composer is not editable after the turn');
+ const settledProxy = await evaluateProof(electronApplication, 'proxy.status');
+ if (settledProxy?.proxy?.conversationCount !== 1
+ || settledProxy?.proxy?.activeProviderRequests?.parent !== 0
+ || settledProxy?.proxy?.activeProviderRequests?.child !== 0) {
+ throw new Error(`Packaged proxy source did not settle before fork: ${JSON.stringify(settledProxy?.proxy)}`);
+ }
+ const timeline = page.getByTestId('coding-conversation-timeline');
+ const forkActions = timeline.getByRole('button', { name: '从这里创建新对话分支' });
+ await forkActions.waitFor({ state: 'visible', timeout: 30_000 });
+ if (await forkActions.count() !== 1) {
+ throw new Error(`Packaged proxy timeline exposed ${await forkActions.count()} fork actions for one user and one assistant entry`);
+ }
+ const sourceHeader = await page.getByTestId('coding-conversation-header').innerText();
+ await forkActions.click({ timeout: 30_000 });
+ await page.waitForFunction((previous) => {
+ const header = document.querySelector('[data-testid="coding-conversation-header"]');
+ return Boolean(header?.textContent?.includes('(fork)') && header.textContent !== previous);
+ }, sourceHeader, { timeout: 30_000 });
+ const forkedHeader = await page.getByTestId('coding-conversation-header').innerText();
+ const forkedTitle = forkedHeader.split('\n', 1)[0]?.trim();
+ if (!forkedTitle?.includes('(fork)')) {
+ throw new Error(`Packaged proxy fork target was not selected: ${forkedHeader}`);
+ }
+ const agentConversations = page.getByRole('group', { name: 'Packaged proxy proof agent 的对话' });
+ await agentConversations.getByRole('button', { name: forkedTitle, exact: true })
+ .waitFor({ state: 'visible', timeout: 30_000 });
+ const forkedBody = await page.locator('body').innerText();
+ if (forkedBody.includes('本地编程运行时暂时不可用')
+ || forkedBody.includes('正在重新连接本地 Agent')) {
+ throw new Error(`Packaged user-entry fork exposed a runtime banner: ${forkedBody}`);
+ }
+ if (await timeline.getByText(retryPrompt, { exact: true }).count() !== 0
+ || await timeline.getByText('REAL_PARENT_COMPLETE', { exact: true }).count() !== 0
+ || await timeline.getByRole('button', { name: '从这里创建新对话分支' }).count() !== 0) {
+ throw new Error('Packaged fork target did not hydrate at the path before the selected user entry');
+ }
+
const proxyFinish = await evaluateProof(electronApplication, 'proxy.finish');
proxyActive = false;
assertPackagedMain(proxyFinish);
@@ -653,6 +700,12 @@ export async function runPackagedProductProof(options) {
submissionErrorSafe,
retryAfterEditEnabled,
retrySubmitted: true,
+ assistantForkActionHidden: true,
+ userForkActionAvailable: true,
+ forkTargetSelected: true,
+ sameAgentOwnership: true,
+ sourceUnchanged: proxyFinish.proxy?.fork?.sourceUnchanged === true,
+ uncertainPromptReplayed: proxyFinish.proxy?.fork?.promptReplayObserved === true,
runtimeUnavailableBanner: false,
permanentRecovering: false,
},
diff --git a/src/pages/Chat/CodingConversationTimeline.tsx b/src/pages/Chat/CodingConversationTimeline.tsx
index f83a5f2..892ec39 100644
--- a/src/pages/Chat/CodingConversationTimeline.tsx
+++ b/src/pages/Chat/CodingConversationTimeline.tsx
@@ -187,7 +187,7 @@ const MessageNode = memo(function MessageNode({
本轮已中止。
)}
- {node.sourceEntryId && onFork && node.status !== 'optimistic' && (
+ {user && node.sourceEntryId && onFork && node.status !== 'optimistic' && (