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。
|
- 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 不属于该产品界面。
|
||||||
|
|
||||||
## 兼容标识
|
## 兼容标识
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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