fix(pi): validate user-entry conversation forks
This commit is contained in:
@@ -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.
|
||||
@@ -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 不属于该产品界面。
|
||||
|
||||
## 兼容标识
|
||||
|
||||
|
||||
@@ -491,7 +491,27 @@ export class CodingConversationService {
|
||||
}
|
||||
|
||||
async fork(sourceConversationId: string, sourceEntryId?: string): Promise<CodingConversationV2> {
|
||||
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,
|
||||
|
||||
@@ -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<PiReleasePro
|
||||
&& status.processes.parent.length > 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<PiReleaseP
|
||||
if (!run) throw new Error('PI proxy composition proof is not running');
|
||||
try {
|
||||
const conversations = await run.composition.conversations.listConversations(run.projectId);
|
||||
if (conversations.length !== 1) {
|
||||
throw new Error(`Expected one first proxy Conversation, received ${conversations.length}`);
|
||||
if (conversations.length !== 2 || !run.sourceConversationId) {
|
||||
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);
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline && (
|
||||
@@ -1473,9 +1499,24 @@ export async function finishFinalAsarProxyCompositionProof(): Promise<PiReleaseP
|
||||
if (snapshot.run.status !== 'idle' || !serializedNodes.includes('REAL_PARENT_COMPLETE')) {
|
||||
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 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<PiReleaseP
|
||||
if (!binding?.piSessionId || !binding.sessionKey) {
|
||||
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
|
||||
|| !finalStatus.providerCompatibility.roleContract.systemAccepted
|
||||
|| finalStatus.providerCompatibility.developerRoleRequests !== 0
|
||||
@@ -1512,7 +1574,7 @@ export async function finishFinalAsarProxyCompositionProof(): Promise<PiReleaseP
|
||||
tokenSafety,
|
||||
providerMode: 'loopback-through-authenticated-host-proxy',
|
||||
firstConversation: {
|
||||
count: conversations.length,
|
||||
count: 1,
|
||||
bindingEstablished: true,
|
||||
workerStatus: snapshot.worker.status,
|
||||
runStatus: snapshot.run.status,
|
||||
@@ -1521,6 +1583,20 @@ export async function finishFinalAsarProxyCompositionProof(): Promise<PiReleaseP
|
||||
definiteRejectionObserved: finalStatus.providerCompatibility.controlledDefiniteRejections === 1,
|
||||
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,
|
||||
};
|
||||
await cleanupProxyCompositionRun(run);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -187,7 +187,7 @@ const MessageNode = memo(function MessageNode({
|
||||
本轮已中止。
|
||||
</p>
|
||||
)}
|
||||
{node.sourceEntryId && onFork && node.status !== 'optimistic' && (
|
||||
{user && node.sourceEntryId && onFork && node.status !== 'optimistic' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -152,6 +152,22 @@ async function installCodingFirstChatHost(
|
||||
model: { model: configuredModel, modelResolution: 'resolved' },
|
||||
},
|
||||
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',
|
||||
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 的对话' });
|
||||
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 expect(page.getByTestId('coding-conversation-header')).toContainText('Second Conversation');
|
||||
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('/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 === '/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.every((request) => !request.path.includes('/api/opencode/share'))).toBe(true);
|
||||
} finally {
|
||||
|
||||
@@ -169,14 +169,38 @@ describe('CodingConversationTimeline', () => {
|
||||
const snapshot = {
|
||||
...base,
|
||||
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,
|
||||
id: 'message-feature',
|
||||
sourceEntryId: 'entry-feature',
|
||||
sourceEntryId: 'entry-assistant-feature',
|
||||
role: 'assistant' as const,
|
||||
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,
|
||||
id: 'tool-feature',
|
||||
@@ -249,8 +273,10 @@ describe('CodingConversationTimeline', () => {
|
||||
|
||||
render(<CodingConversationTimeline conversationId="conversation-feature-ui" onFork={onFork} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '从这里创建新对话分支' }));
|
||||
expect(onFork).toHaveBeenCalledWith('entry-feature');
|
||||
const forkActions = screen.getAllByRole('button', { name: '从这里创建新对话分支' });
|
||||
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();
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
createMemoryCodingProjectStorage,
|
||||
} from '../../electron/coding-projects/project-store';
|
||||
import type {
|
||||
ConversationSnapshot,
|
||||
ConversationPatchEnvelope,
|
||||
PromptConversationInput,
|
||||
} from '../../electron/coding-runtime/contracts';
|
||||
@@ -802,6 +803,27 @@ describe('PI-100 coding core Host contract', () => {
|
||||
let bindFork: ((conversationId: string) => Promise<void>) | undefined;
|
||||
let forkTargetId = '';
|
||||
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> {
|
||||
forkTargetId = input.conversation.conversationId;
|
||||
await bindFork?.(forkTargetId);
|
||||
@@ -827,7 +849,7 @@ describe('PI-100 coding core Host contract', () => {
|
||||
await conversations.getSnapshot(source.id);
|
||||
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',
|
||||
});
|
||||
expect(dispose).toHaveBeenCalledWith(forkTargetId);
|
||||
@@ -837,4 +859,110 @@ describe('PI-100 coding core Host contract', () => {
|
||||
});
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
229
tests/unit/pi-managed-user-entry-fork-real.test.ts
Normal file
229
tests/unit/pi-managed-user-entry-fork-real.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user