fix(pi): validate user-entry conversation forks

This commit is contained in:
2026-08-25 17:48:31 +08:00
parent 941b015330
commit 8ba2a6055c
10 changed files with 767 additions and 13 deletions

View File

@@ -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.

View File

@@ -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。 - Makelore 在应用侧按 Session 独立提交、跟踪和隔离运行状态,不使用“当前对话正在回复”的全局界面锁;同一 Session 的后续消息仍按顺序排队。最终产品中的 Pi 运行时会通过受控 Provider-shaped 回环 smoke 验证两个 worker 的重叠执行、状态隔离与凭证引用隔离;真实外部 Provider 的并发、限流、协议兼容和凭证隔离仍是独立风险,未执行真实 Provider 验证时不得标记为 Pass。
- 首次发送会立即生成稳定的乐观用户消息HTTP 202 只表示本地 Agent 已接收。后续失败不会删除已接受消息,不确定交付不会自动重发;准备失败可在目标 Conversation 上手动恢复。 - 首次发送会立即生成稳定的乐观用户消息HTTP 202 只表示本地 Agent 已接收。后续失败不会删除已接受消息,不确定交付不会自动重发;准备失败可在目标 Conversation 上手动恢复。
- 运行中的 Conversation 可把新消息作为 steer 引导当前回答或 follow-up 排到下一轮,并显示队列位置;队列只在 `agent_settled` 后释放用户可中止当前运行。select/confirm/input/editor 交互在输入区上方回答,并明确展示取消或失效结果。 - 运行中的 Conversation 可把新消息作为 steer 引导当前回答或 follow-up 排到下一轮,并显示队列位置;队列只在 `agent_settled` 后释放用户可中止当前运行。select/confirm/input/editor 交互在输入区上方回答,并明确展示取消或失效结果。
- 核心聊天页支持标题、归档、未读、恢复,以及“从这里创建新对话分支”;分支只创建新的 Conversation 历史,不表示文件回滚。右侧编程工具集中展示当前 Conversation 的 changes、项目文件预览、浏览器附件、技能、命令与脱敏运行诊断。分享、待办、全局运行时和 revert/unrevert 不属于该产品界面。 - 核心聊天页支持标题、归档、未读、恢复,以及从已持久化的 user 消息“从这里创建新对话分支”assistant 消息和未持久化消息不提供该动作。分支只创建新的 Conversation 历史,不表示文件回滚。右侧编程工具集中展示当前 Conversation 的 changes、项目文件预览、浏览器附件、技能、命令与脱敏运行诊断。分享、待办、全局运行时和 revert/unrevert 不属于该产品界面。
## 兼容标识 ## 兼容标识

View File

@@ -491,7 +491,27 @@ export class CodingConversationService {
} }
async fork(sourceConversationId: string, sourceEntryId?: string): Promise<CodingConversationV2> { async fork(sourceConversationId: string, sourceEntryId?: string): Promise<CodingConversationV2> {
const entryId = requiredString(sourceEntryId, 'Fork source entry id', 256);
const prepared = await this.ensurePrepared(sourceConversationId); 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 source = await this.projects.findActiveConversation(sourceConversationId);
const store = this.projects.conversationStore(source.project.path); const store = this.projects.conversationStore(source.project.path);
const created = await persist(() => store.create({ const created = await persist(() => store.create({
@@ -503,7 +523,7 @@ export class CodingConversationService {
try { try {
await this.runtime.fork({ await this.runtime.fork({
sourceConversationId, sourceConversationId,
...(sourceEntryId ? { sourceEntryId } : {}), sourceEntryId: entryId,
conversation: { conversation: {
...prepared, ...prepared,
conversationId: created.id, conversationId: created.id,

View File

@@ -104,10 +104,12 @@ type ProxyCompositionRun = {
provider: LocalProofProvider; provider: LocalProofProvider;
projectId: string; projectId: string;
projectPath: string; projectPath: string;
sourceConversationId?: string;
hostToken: string; hostToken: string;
originalAcceptPrompt: CodingProductComposition['conversations']['acceptPrompt']; originalAcceptPrompt: CodingProductComposition['conversations']['acceptPrompt'];
submissionInjection: { definiteRejections: number }; submissionInjection: { definiteRejections: number };
activeStatus?: PiReleaseProxyCompositionStatus; activeStatus?: PiReleaseProxyCompositionStatus;
settledStatus?: PiReleaseProxyCompositionStatus;
}; };
type ResilienceCompositionRun = { type ResilienceCompositionRun = {
@@ -212,6 +214,18 @@ export interface PiReleaseProxyCompositionProof extends PiReleaseProxyCompositio
definiteRejectionObserved: boolean; definiteRejectionObserved: boolean;
retryAccepted: 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[]; currentHostTokenUsedBy: ProofWorkerRole[];
released: { workers: number }; released: { workers: number };
} }
@@ -916,8 +930,12 @@ async function proxyCompositionStatus(
.filter(({ role }) => role === 'child') .filter(({ role }) => role === 'child')
.map(({ processId }) => processId) .map(({ processId }) => processId)
.sort((left, right) => left - right); .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 { return {
conversationCount: (await run.composition.conversations.listConversations(run.projectId)).length, conversationCount: conversations.length,
providerRequests, providerRequests,
activeProviderRequests: run.provider.activeCounts(), activeProviderRequests: run.provider.activeCounts(),
workers: diagnostics.workers, workers: diagnostics.workers,
@@ -1442,6 +1460,12 @@ export async function getFinalAsarProxyCompositionStatus(): Promise<PiReleasePro
&& status.processes.parent.length > 0) { && status.processes.parent.length > 0) {
run.activeStatus = status; run.activeStatus = status;
} }
if (status.conversationCount === 1
&& status.activeProviderRequests.parent === 0
&& status.activeProviderRequests.child === 0
&& status.providerCompatibility.systemRoleRequests >= 2) {
run.settledStatus = status;
}
return status; return status;
} }
@@ -1456,10 +1480,12 @@ export async function finishFinalAsarProxyCompositionProof(): Promise<PiReleaseP
if (!run) throw new Error('PI proxy composition proof is not running'); if (!run) throw new Error('PI proxy composition proof is not running');
try { try {
const conversations = await run.composition.conversations.listConversations(run.projectId); const conversations = await run.composition.conversations.listConversations(run.projectId);
if (conversations.length !== 1) { if (conversations.length !== 2 || !run.sourceConversationId) {
throw new Error(`Expected one first proxy Conversation, received ${conversations.length}`); throw new Error(`Expected source and forked proxy Conversations, received ${conversations.length}`);
} }
const conversation = conversations[0]!; const conversation = conversations.find(({ id }) => 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); let snapshot = await run.composition.conversations.getSnapshot(conversation.id);
const deadline = Date.now() + 30_000; const deadline = Date.now() + 30_000;
while (Date.now() < deadline && ( while (Date.now() < deadline && (
@@ -1473,9 +1499,24 @@ export async function finishFinalAsarProxyCompositionProof(): Promise<PiReleaseP
if (snapshot.run.status !== 'idle' || !serializedNodes.includes('REAL_PARENT_COMPLETE')) { if (snapshot.run.status !== 'idle' || !serializedNodes.includes('REAL_PARENT_COMPLETE')) {
throw new Error('First proxy Conversation did not settle with the controlled parent response'); throw new Error('First proxy Conversation did not settle with the controlled parent response');
} }
const binding = await run.composition.projects.conversationStore(run.projectPath).get(conversation.id); const forkedSnapshot = await run.composition.conversations.getSnapshot(forked.id);
const sourceEntry = snapshot.nodes.find((node) => (
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 finalStatus = await proxyCompositionStatus(run);
const activeStatus = run.activeStatus ?? finalStatus; 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[] = [ const currentHostTokenUsedBy: ProofWorkerRole[] = [
...(activeStatus.providerRequests.parent > 0 ? ['parent' as const] : []), ...(activeStatus.providerRequests.parent > 0 ? ['parent' as const] : []),
...(activeStatus.providerRequests.child > 0 ? ['child' as const] : []), ...(activeStatus.providerRequests.child > 0 ? ['child' as const] : []),
@@ -1486,6 +1527,27 @@ export async function finishFinalAsarProxyCompositionProof(): Promise<PiReleaseP
if (!binding?.piSessionId || !binding.sessionKey) { if (!binding?.piSessionId || !binding.sessionKey) {
throw new Error('First proxy Conversation did not persist its Pi session binding'); throw new Error('First proxy Conversation did not persist its Pi session binding');
} }
if (!forkedBinding?.piSessionId || !forkedBinding.sessionKey) {
throw new Error('Forked proxy Conversation did not persist its Pi session binding');
}
const distinctSessionBinding = binding.piSessionId !== forkedBinding.piSessionId
&& binding.sessionKey !== forkedBinding.sessionKey;
const targetHydratedBeforeSourceEntry = forkedSnapshot.nodes.every((node) => (
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 if (!finalStatus.providerCompatibility.roleContract.developerRejected
|| !finalStatus.providerCompatibility.roleContract.systemAccepted || !finalStatus.providerCompatibility.roleContract.systemAccepted
|| finalStatus.providerCompatibility.developerRoleRequests !== 0 || finalStatus.providerCompatibility.developerRoleRequests !== 0
@@ -1512,7 +1574,7 @@ export async function finishFinalAsarProxyCompositionProof(): Promise<PiReleaseP
tokenSafety, tokenSafety,
providerMode: 'loopback-through-authenticated-host-proxy', providerMode: 'loopback-through-authenticated-host-proxy',
firstConversation: { firstConversation: {
count: conversations.length, count: 1,
bindingEstablished: true, bindingEstablished: true,
workerStatus: snapshot.worker.status, workerStatus: snapshot.worker.status,
runStatus: snapshot.run.status, runStatus: snapshot.run.status,
@@ -1521,6 +1583,20 @@ export async function finishFinalAsarProxyCompositionProof(): Promise<PiReleaseP
definiteRejectionObserved: finalStatus.providerCompatibility.controlledDefiniteRejections === 1, definiteRejectionObserved: finalStatus.providerCompatibility.controlledDefiniteRejections === 1,
retryAccepted: finalStatus.providerCompatibility.systemRoleRequests >= 2, retryAccepted: finalStatus.providerCompatibility.systemRoleRequests >= 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, currentHostTokenUsedBy,
}; };
await cleanupProxyCompositionRun(run); await cleanupProxyCompositionRun(run);

View File

@@ -164,6 +164,16 @@ function assertProxyProof(proof) {
|| proof?.firstConversation?.parentResponseProjected !== true || proof?.firstConversation?.parentResponseProjected !== true
|| proof?.firstConversation?.definiteRejectionObserved !== true || proof?.firstConversation?.definiteRejectionObserved !== true
|| proof?.firstConversation?.retryAccepted !== 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?.developerRoleRequests !== 0
|| proof?.providerCompatibility?.controlledDefiniteRejections !== 1 || proof?.providerCompatibility?.controlledDefiniteRejections !== 1
|| proof?.currentHostTokenUsedBy?.join(',') !== 'parent,child' || 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'); 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'); const proxyFinish = await evaluateProof(electronApplication, 'proxy.finish');
proxyActive = false; proxyActive = false;
assertPackagedMain(proxyFinish); assertPackagedMain(proxyFinish);
@@ -653,6 +700,12 @@ export async function runPackagedProductProof(options) {
submissionErrorSafe, submissionErrorSafe,
retryAfterEditEnabled, retryAfterEditEnabled,
retrySubmitted: true, retrySubmitted: true,
assistantForkActionHidden: true,
userForkActionAvailable: true,
forkTargetSelected: true,
sameAgentOwnership: true,
sourceUnchanged: proxyFinish.proxy?.fork?.sourceUnchanged === true,
uncertainPromptReplayed: proxyFinish.proxy?.fork?.promptReplayObserved === true,
runtimeUnavailableBanner: false, runtimeUnavailableBanner: false,
permanentRecovering: false, permanentRecovering: false,
}, },

View File

@@ -187,7 +187,7 @@ const MessageNode = memo(function MessageNode({
</p> </p>
)} )}
{node.sourceEntryId && onFork && node.status !== 'optimistic' && ( {user && node.sourceEntryId && onFork && node.status !== 'optimistic' && (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"

View File

@@ -152,6 +152,22 @@ async function installCodingFirstChatHost(
model: { model: configuredModel, modelResolution: 'resolved' }, model: { model: configuredModel, modelResolution: 'resolved' },
}, },
nodes: featureComplete ? [ nodes: featureComplete ? [
{
kind: 'message',
id: 'message-user-e2e',
sourceEntryId: 'entry-user-e2e',
role: 'user',
status: 'complete',
blocks: [{ kind: 'text', id: 'message-user-e2e:content:0', text: 'Durable user fork source', status: 'complete' }],
},
{
kind: 'message',
id: 'message-assistant-e2e',
sourceEntryId: 'entry-assistant-e2e',
role: 'assistant',
status: 'complete',
blocks: [{ kind: 'text', id: 'message-assistant-e2e:content:0', text: 'Durable assistant response', status: 'complete' }],
},
{ {
kind: 'subagent', kind: 'subagent',
id: 'subagent-e2e', id: 'subagent-e2e',
@@ -567,6 +583,16 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model
const builderConversations = page.getByRole('group', { name: 'Builder 的对话' }); const builderConversations = page.getByRole('group', { name: 'Builder 的对话' });
await expect(builderConversations).toBeVisible(); await expect(builderConversations).toBeVisible();
const forkActions = page.getByRole('button', { name: '从这里创建新对话分支' });
await expect(forkActions).toHaveCount(1);
await forkActions.click();
await expect(page.getByTestId('coding-conversation-header')).toContainText('Feature UI branch');
await expect(builderConversations.getByRole('button', { name: 'Feature UI branch' })).toBeVisible();
await expect(page.getByText('本地编程运行时暂时不可用')).toHaveCount(0);
await builderConversations.getByRole('button', { name: /^新对话/ }).click();
await expect(page.getByText('Durable user fork source')).toBeVisible();
await expect(page.getByText('Durable assistant response')).toBeVisible();
await expect(page.getByRole('button', { name: '从这里创建新对话分支' })).toHaveCount(1);
await builderConversations.getByRole('button', { name: 'Second Conversation' }).click(); await builderConversations.getByRole('button', { name: 'Second Conversation' }).click();
await expect(page.getByTestId('coding-conversation-header')).toContainText('Second Conversation'); await expect(page.getByTestId('coding-conversation-header')).toContainText('Second Conversation');
await expect(page.getByRole('combobox', { name: '当前对话模型' })).toHaveValue( await expect(page.getByRole('combobox', { name: '当前对话模型' })).toHaveValue(
@@ -589,6 +615,11 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model
expect(state.captured.some((request) => request.path.endsWith('/changes'))).toBe(true); expect(state.captured.some((request) => request.path.endsWith('/changes'))).toBe(true);
expect(state.captured.some((request) => request.path.endsWith('/recover') && request.method === 'POST')).toBe(true); expect(state.captured.some((request) => request.path.endsWith('/recover') && request.method === 'POST')).toBe(true);
expect(state.captured.some((request) => request.path.endsWith('/compact') && request.method === 'POST')).toBe(true); expect(state.captured.some((request) => request.path.endsWith('/compact') && request.method === 'POST')).toBe(true);
expect(state.captured.some((request) => (
request.path === '/api/coding/conversations/conversation-pi-first-chat/fork'
&& request.method === 'POST'
&& request.body?.sourceEntryId === 'entry-user-e2e'
))).toBe(true);
expect(state.captured.some((request) => request.path.includes('legacy-conversation-notice'))).toBe(true); expect(state.captured.some((request) => request.path.includes('legacy-conversation-notice'))).toBe(true);
expect(state.captured.every((request) => !request.path.includes('/api/opencode/share'))).toBe(true); expect(state.captured.every((request) => !request.path.includes('/api/opencode/share'))).toBe(true);
} finally { } finally {

View File

@@ -169,14 +169,38 @@ describe('CodingConversationTimeline', () => {
const snapshot = { const snapshot = {
...base, ...base,
nodes: [ nodes: [
{
kind: 'message' as const,
id: 'message-user-feature',
sourceEntryId: 'entry-user-feature',
role: 'user' as const,
status: 'complete' as const,
blocks: [{ kind: 'text' as const, id: 'text-user-feature', text: 'Build it', status: 'complete' as const }],
},
{ {
kind: 'message' as const, kind: 'message' as const,
id: 'message-feature', id: 'message-feature',
sourceEntryId: 'entry-feature', sourceEntryId: 'entry-assistant-feature',
role: 'assistant' as const, role: 'assistant' as const,
status: 'complete' as const, status: 'complete' as const,
blocks: [{ kind: 'text' as const, id: 'text-feature', text: 'Ready', status: 'complete' as const }], blocks: [{ kind: 'text' as const, id: 'text-feature', text: 'Ready', status: 'complete' as const }],
}, },
{
kind: 'message' as const,
id: 'message-optimistic-feature',
sourceEntryId: 'entry-optimistic-feature',
clientRequestId: 'request-optimistic-feature',
role: 'user' as const,
status: 'optimistic' as const,
blocks: [{ kind: 'text' as const, id: 'text-optimistic-feature', text: 'Pending', status: 'complete' as const }],
},
{
kind: 'message' as const,
id: 'message-without-entry-feature',
role: 'user' as const,
status: 'complete' as const,
blocks: [{ kind: 'text' as const, id: 'text-without-entry-feature', text: 'Local only', status: 'complete' as const }],
},
{ {
kind: 'tool' as const, kind: 'tool' as const,
id: 'tool-feature', id: 'tool-feature',
@@ -249,8 +273,10 @@ describe('CodingConversationTimeline', () => {
render(<CodingConversationTimeline conversationId="conversation-feature-ui" onFork={onFork} />); render(<CodingConversationTimeline conversationId="conversation-feature-ui" onFork={onFork} />);
fireEvent.click(screen.getByRole('button', { name: '从这里创建新对话分支' })); const forkActions = screen.getAllByRole('button', { name: '从这里创建新对话分支' });
expect(onFork).toHaveBeenCalledWith('entry-feature'); expect(forkActions).toHaveLength(1);
fireEvent.click(forkActions[0]!);
expect(onFork).toHaveBeenCalledWith('entry-user-feature');
expect(screen.getByText('并行子任务')).toBeInTheDocument(); expect(screen.getByText('并行子任务')).toBeInTheDocument();
expect(screen.getByText('单个子任务')).toBeInTheDocument(); expect(screen.getByText('单个子任务')).toBeInTheDocument();
expect(screen.getByText('串行子任务')).toBeInTheDocument(); expect(screen.getByText('串行子任务')).toBeInTheDocument();

View File

@@ -28,6 +28,7 @@ import {
createMemoryCodingProjectStorage, createMemoryCodingProjectStorage,
} from '../../electron/coding-projects/project-store'; } from '../../electron/coding-projects/project-store';
import type { import type {
ConversationSnapshot,
ConversationPatchEnvelope, ConversationPatchEnvelope,
PromptConversationInput, PromptConversationInput,
} from '../../electron/coding-runtime/contracts'; } from '../../electron/coding-runtime/contracts';
@@ -802,6 +803,27 @@ describe('PI-100 coding core Host contract', () => {
let bindFork: ((conversationId: string) => Promise<void>) | undefined; let bindFork: ((conversationId: string) => Promise<void>) | undefined;
let forkTargetId = ''; let forkTargetId = '';
class FailingForkRuntime extends InMemoryConversationRuntime { class FailingForkRuntime extends InMemoryConversationRuntime {
override async getSnapshot(conversationId: string): Promise<ConversationSnapshot> {
const snapshot = await super.getSnapshot(conversationId);
return {
...snapshot,
nodes: [{
kind: 'message',
id: 'node-user-fork-cleanup',
sourceEntryId: 'entry-user-fork-cleanup',
role: 'user',
status: 'complete',
blocks: [{
kind: 'text',
id: 'text-user-fork-cleanup',
text: 'Fork before hydration fails',
status: 'complete',
}],
}],
cursor: { ...snapshot.cursor, leafEntryId: 'entry-user-fork-cleanup' },
};
}
override async fork(input: Parameters<InMemoryConversationRuntime['fork']>[0]): Promise<never> { override async fork(input: Parameters<InMemoryConversationRuntime['fork']>[0]): Promise<never> {
forkTargetId = input.conversation.conversationId; forkTargetId = input.conversation.conversationId;
await bindFork?.(forkTargetId); await bindFork?.(forkTargetId);
@@ -827,7 +849,7 @@ describe('PI-100 coding core Host contract', () => {
await conversations.getSnapshot(source.id); await conversations.getSnapshot(source.id);
const dispose = vi.spyOn(runtime, 'dispose'); const dispose = vi.spyOn(runtime, 'dispose');
await expect(conversations.fork(source.id)).rejects.toMatchObject({ await expect(conversations.fork(source.id, 'entry-user-fork-cleanup')).rejects.toMatchObject({
code: 'CODING_SESSION_UNREADABLE', code: 'CODING_SESSION_UNREADABLE',
}); });
expect(dispose).toHaveBeenCalledWith(forkTargetId); expect(dispose).toHaveBeenCalledWith(forkTargetId);
@@ -837,4 +859,110 @@ describe('PI-100 coding core Host contract', () => {
}); });
await expect(store.get(forkTargetId)).resolves.toBeNull(); await expect(store.get(forkTargetId)).resolves.toBeNull();
}); });
it('rejects non-user or inactive fork entries before creating target resources', async () => {
class ForkSourceRuntime extends InMemoryConversationRuntime {
sourceConversationId = '';
readonly forkInputs: Parameters<InMemoryConversationRuntime['fork']>[0][] = [];
override async getSnapshot(conversationId: string): Promise<ConversationSnapshot> {
const snapshot = await super.getSnapshot(conversationId);
if (conversationId !== this.sourceConversationId) return snapshot;
return {
...snapshot,
nodes: [
{
kind: 'message',
id: 'node-user-active',
sourceEntryId: 'entry-user-active',
role: 'user',
status: 'complete',
blocks: [{ kind: 'text', id: 'text-user-active', text: 'Fork here', status: 'complete' }],
},
{
kind: 'message',
id: 'node-assistant-active',
sourceEntryId: 'entry-assistant-active',
role: 'assistant',
status: 'complete',
blocks: [{ kind: 'text', id: 'text-assistant-active', text: 'Do not fork here', status: 'complete' }],
},
],
cursor: { ...snapshot.cursor, leafEntryId: 'entry-assistant-active' },
};
}
override async fork(input: Parameters<InMemoryConversationRuntime['fork']>[0]) {
this.forkInputs.push(structuredClone(input));
await this.prepare(input.conversation);
return {
conversationId: input.conversation.conversationId,
snapshot: await super.getSnapshot(input.conversation.conversationId),
};
}
}
const runtime = new ForkSourceRuntime();
const result = await setup(runtime);
const source = await createConversation(result.conversations);
runtime.sourceConversationId = source.id;
await result.conversations.getSnapshot(source.id);
const store = result.projects.conversationStore(result.root);
const create = vi.spyOn(store, 'create');
create.mockClear();
const dispose = vi.spyOn(runtime, 'dispose');
const archiveSession = vi.fn(async () => undefined);
const conversations = new CodingConversationService(result.projects, runtime, { archiveSession });
for (const sourceEntryId of [
'entry-assistant-active',
'entry-unknown',
'entry-user-stale',
]) {
await expect(conversations.fork(source.id, sourceEntryId)).rejects.toMatchObject({
status: 400,
code: 'CODING_CONVERSATION_REQUEST_INVALID',
});
}
expect(create).not.toHaveBeenCalled();
expect(runtime.forkInputs).toEqual([]);
expect(dispose).not.toHaveBeenCalled();
expect(archiveSession).not.toHaveBeenCalled();
await expect(store.read()).resolves.toMatchObject({
conversations: [expect.objectContaining({ id: source.id })],
});
const invalidResponse = await dispatchHostApiRequest(context({
...result,
conversations,
}), {
path: `/api/coding/conversations/${source.id}/fork`,
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sourceEntryId: 'entry-assistant-active' }),
});
expect(invalidResponse).toMatchObject({
status: 400,
json: {
code: 'CODING_CONVERSATION_REQUEST_INVALID',
error: '对话请求无效,请检查输入。',
},
});
expect(create).not.toHaveBeenCalled();
const forked = await conversations.fork(source.id, 'entry-user-active');
expect(forked.agentId).toBe(source.agentId);
await expect(store.read()).resolves.toMatchObject({
conversations: [
expect.objectContaining({ id: forked.id, agentId: source.agentId }),
expect.objectContaining({ id: source.id, agentId: source.agentId }),
],
});
expect(runtime.forkInputs).toHaveLength(1);
expect(runtime.forkInputs[0]).toMatchObject({
sourceConversationId: source.id,
sourceEntryId: 'entry-user-active',
});
});
}); });

View File

@@ -0,0 +1,229 @@
// @vitest-environment node
import { realpathSync } from 'node:fs';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
import { createCodingConversationStore } from '../../electron/coding-projects/conversation-store';
import { createCodingProjectAgent } from '../../electron/coding-projects/project-config';
import { CodingProjectService } from '../../electron/coding-projects/project-service';
import {
createCodingProjectStore,
createLocalCodingProject,
createMemoryCodingProjectStorage,
} from '../../electron/coding-projects/project-store';
import { CodingConversationService } from '../../electron/coding-runtime/conversation-service';
import { PiManagedExtensionHost } from '../../electron/coding-runtime/pi/extension-host';
import {
buildPiProviderCatalog,
selectPiProviderModel,
} from '../../electron/coding-runtime/pi/provider-config';
import {
createPiManagedWorkerOpener,
PiConversationRuntime,
} from '../../electron/coding-runtime/pi/runtime';
import { PiSessionRegistry } from '../../electron/coding-runtime/pi/session-registry';
import { PiWorkerProcess, type PiWorkerProcessOptions } from '../../electron/coding-runtime/pi/worker-process';
import { PiWorkerPool } from '../../electron/coding-runtime/pi/worker-pool';
import type { ProviderAccount } from '../../electron/shared/providers/types';
const roots: string[] = [];
const NOW = '2026-08-25T08:00:00.000Z';
const SOURCE_SESSION_ID = 'c67b7c7b-8364-4dcb-a7f7-913316e8d735';
function electronExecutable(): string {
const requireFromProject = createRequire(path.resolve('package.json'));
return requireFromProject('electron') as string;
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, {
recursive: true,
force: true,
maxRetries: 3,
})));
});
describe('real managed Pi user-entry fork', () => {
it('forks a persisted Pi 0.84.2 session through the product service and hydrates an independent binding', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-real-fork-'));
roots.push(root);
const projectPath = path.join(root, 'project');
const userDataDir = path.join(root, 'user-data');
const projectStore = createCodingProjectStore(createMemoryCodingProjectStorage(), {
createId: () => 'project-real-fork',
now: () => NOW,
});
const { project } = await createLocalCodingProject({ projectPath, now: NOW }, projectStore);
const model = {
accountId: 'account-real-fork',
modelId: 'model-real-fork',
thinkingLevel: 'off' as const,
};
await createCodingProjectAgent(projectPath, {
id: 'agent-real-fork',
avatarId: 'avatar-01',
roleName: 'Fork verifier',
name: 'Fork verifier',
model,
modelResolution: 'resolved',
responsibility: {
mission: 'Verify a persisted user-entry fork',
owns: [], boundaries: [], collaborators: [], principles: [],
},
skillIds: [],
}, { now: NOW });
const store = createCodingConversationStore(projectPath, {
createId: () => '4ad88774-c373-421b-9c97-5facd368ba66',
now: () => NOW,
});
const source = await store.create({
agentId: 'agent-real-fork',
title: 'Persisted source',
model,
modelResolution: 'resolved',
});
await store.ensureSessionBinding(source.id, async () => ({
piSessionId: SOURCE_SESSION_ID,
sessionKey: SOURCE_SESSION_ID,
}));
const sessionDirectory = path.join(
userDataDir,
'coding-runtime',
'pi',
'sessions',
project.id,
);
await mkdir(sessionDirectory, { recursive: true });
const sessionEntries = [
{ type: 'session', version: 3, id: SOURCE_SESSION_ID, timestamp: NOW, cwd: project.path },
{
type: 'message', id: 'entry-user-real-fork', parentId: null, timestamp: NOW,
message: { role: 'user', content: 'Persisted user fork source', timestamp: 1 },
},
{
type: 'message', id: 'entry-assistant-real-fork', parentId: 'entry-user-real-fork', timestamp: NOW,
message: {
role: 'assistant',
content: [{ type: 'text', text: 'Persisted assistant response' }],
usage: {
input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: 'stop',
timestamp: 2,
},
},
];
await writeFile(
path.join(sessionDirectory, `2026-08-25T08-00-00-000Z_${SOURCE_SESSION_ID}.jsonl`),
`${sessionEntries.map((entry) => JSON.stringify(entry)).join('\n')}\n`,
);
const packageRoot = realpathSync(path.resolve(
'node_modules', '@earendil-works', 'pi-coding-agent',
));
const { SessionManager } = await import(pathToFileURL(
path.join(packageRoot, 'dist', 'core', 'session-manager.js'),
).href) as {
SessionManager: { list(cwd: string, sessionDir: string): Promise<Array<{ id: string }>> };
};
expect(await SessionManager.list(project.path, sessionDirectory)).toEqual([
expect.objectContaining({ id: SOURCE_SESSION_ID }),
]);
const account: ProviderAccount = {
id: model.accountId,
vendorId: 'custom',
label: 'Local fork proof account',
authMode: 'local',
apiProtocol: 'openai-completions',
baseUrl: 'http://127.0.0.1:9/v1',
model: model.modelId,
enabled: true,
isDefault: true,
createdAt: NOW,
updatedAt: NOW,
};
const providerInput = { accounts: [account], modelSummaries: [] };
const registry = new PiSessionRegistry({ projectStore });
const extensionHost = new PiManagedExtensionHost();
const processOptions: PiWorkerProcessOptions[] = [];
const pool = new PiWorkerPool({
maxIdle: 2,
openWorker: createPiManagedWorkerOpener({
registry,
executablePath: electronExecutable(),
cliPath: path.join(packageRoot, 'dist', 'cli.js'),
userDataDir,
bundledSkillsDir: path.resolve('resources/coding-skills'),
extensionHost,
loadProviderInput: async () => providerInput,
resolveCredential: async () => null,
createProcess: (options) => {
processOptions.push(options);
return new PiWorkerProcess(options);
},
}),
});
const runtime = new PiConversationRuntime({
pool,
registry,
extensionHost,
resolveModel: async (candidate) => selectPiProviderModel(
buildPiProviderCatalog(providerInput),
candidate,
),
});
const service = new CodingConversationService(
new CodingProjectService(projectStore),
runtime,
);
try {
const sourceSnapshot = await service.getSnapshot(source.id);
expect(processOptions[0]?.sessionDir).toBe(sessionDirectory);
expect(processOptions[0]?.cwd).toBe(project.path);
expect(sourceSnapshot.nodes).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: 'message', role: 'user', sourceEntryId: 'entry-user-real-fork', status: 'complete',
}),
expect.objectContaining({
kind: 'message', role: 'assistant', sourceEntryId: 'entry-assistant-real-fork', status: 'complete',
}),
]));
const forked = await service.fork(source.id, 'entry-user-real-fork');
const [sourceAfterFork, forkedSnapshot, sourceBinding, forkedBinding] = await Promise.all([
service.getSnapshot(source.id),
service.getSnapshot(forked.id),
store.get(source.id),
store.get(forked.id),
]);
expect(forked.agentId).toBe(source.agentId);
expect(forkedSnapshot).toMatchObject({
conversation: { id: forked.id, agentId: source.agentId },
nodes: [],
run: { status: 'idle' },
worker: { status: 'ready' },
});
expect(sourceAfterFork.nodes).toEqual(sourceSnapshot.nodes);
expect(sourceBinding).toMatchObject({
piSessionId: SOURCE_SESSION_ID,
sessionKey: SOURCE_SESSION_ID,
});
expect(forkedBinding?.piSessionId).toBeTruthy();
expect(forkedBinding?.sessionKey).toBeTruthy();
expect(forkedBinding?.piSessionId).not.toBe(sourceBinding?.piSessionId);
expect(forkedBinding?.sessionKey).not.toBe(sourceBinding?.sessionKey);
expect(runtime.getDiagnostics().workers).toHaveLength(2);
} finally {
await runtime.shutdown();
await extensionHost.close();
}
expect(runtime.getDiagnostics().workers).toHaveLength(0);
}, 30_000);
});