From cc1f18608212da7db5cf7a785d6c9861a38138a5 Mon Sep 17 00:00:00 2001 From: Salman Ansari Date: Wed, 2 Sep 2026 15:51:24 -0700 Subject: [PATCH 1/6] Add inline steering queue support --- .2119/verdicts/REQ-003.4.1--fdc1dc54680b.json | 8 + .2119/verdicts/REQ-003.4.2--bdf1ed3cc242.json | 8 + .2119/verdicts/REQ-003.4.5--77bc109ed8ea.json | 8 + .2119/verdicts/REQ-003.4.7--ea2a4fa4eaf8.json | 8 + .2119/verdicts/REQ-003.4.8--e7eb0b484688.json | 8 + .2119/verdicts/REQ-003.5.2--0419965f8453.json | 8 + .2119/verdicts/REQ-003.5.4--badf24bf0711.json | 8 + .2119/verdicts/REQ-003.6.4--1fded447f0b8.json | 8 + .2119/verdicts/REQ-003.6.5--f6605f670dbe.json | 8 + .2119/verdicts/REQ-003.6.6--679ab21c5b32.json | 8 + .2119/verdicts/REQ-003.7.1--df912400610a.json | 8 + .../verdicts/REQ-003.7.10--4e9e9e3106db.json | 8 + .../verdicts/REQ-003.7.11--e9ec1d0d542e.json | 8 + .../verdicts/REQ-003.7.12--bfda0832f23b.json | 8 + .../verdicts/REQ-003.7.13--2c7e79895795.json | 8 + .../verdicts/REQ-003.7.14--17175c1f16e5.json | 8 + .../verdicts/REQ-003.7.15--5001c836bccc.json | 8 + .../verdicts/REQ-003.7.16--4120f452f81e.json | 8 + .../verdicts/REQ-003.7.17--0214d6f3ddb3.json | 8 + .../verdicts/REQ-003.7.18--3caa48621001.json | 8 + .../verdicts/REQ-003.7.19--5a0e7c672c12.json | 8 + .2119/verdicts/REQ-003.7.2--0b1b5de4319b.json | 8 + .../verdicts/REQ-003.7.20--7b064ad2add9.json | 8 + .../verdicts/REQ-003.7.21--292318bccfe6.json | 8 + .2119/verdicts/REQ-003.7.7--715c2d1d7041.json | 8 + .2119/verdicts/REQ-003.7.8--30562e26467c.json | 8 + .2119/verdicts/REQ-003.7.9--61211757be53.json | 8 + CHANGELOG.md | 4 + PiNative.xcodeproj/project.pbxproj | 8 +- PiNative/AppModel.swift | 7 +- PiNative/PiConversationModel.swift | 267 ++++++++++++- PiNative/PiConversationView.swift | 62 ++- PiNative/PiRPCClient.swift | 10 + PiNativeTests/ParallelRuntimeTests.swift | 206 +++++++++- .../SteeringDeliveryDeduplicationTests.swift | 148 ++++++++ .../SteeringReplayBoundaryTests.swift | 240 ++++++++++++ PiNativeTests/StopButtonTests.swift | 357 ++++++++++++++++++ .../05-turn-lifecycle.md | 7 + .../06-transcript-and-composer.md | 3 + docs/native-shell-architecture.md | 7 + ...conversation-navigation-and-active-work.md | 30 +- 41 files changed, 1538 insertions(+), 34 deletions(-) create mode 100644 .2119/verdicts/REQ-003.4.1--fdc1dc54680b.json create mode 100644 .2119/verdicts/REQ-003.4.2--bdf1ed3cc242.json create mode 100644 .2119/verdicts/REQ-003.4.5--77bc109ed8ea.json create mode 100644 .2119/verdicts/REQ-003.4.7--ea2a4fa4eaf8.json create mode 100644 .2119/verdicts/REQ-003.4.8--e7eb0b484688.json create mode 100644 .2119/verdicts/REQ-003.5.2--0419965f8453.json create mode 100644 .2119/verdicts/REQ-003.5.4--badf24bf0711.json create mode 100644 .2119/verdicts/REQ-003.6.4--1fded447f0b8.json create mode 100644 .2119/verdicts/REQ-003.6.5--f6605f670dbe.json create mode 100644 .2119/verdicts/REQ-003.6.6--679ab21c5b32.json create mode 100644 .2119/verdicts/REQ-003.7.1--df912400610a.json create mode 100644 .2119/verdicts/REQ-003.7.10--4e9e9e3106db.json create mode 100644 .2119/verdicts/REQ-003.7.11--e9ec1d0d542e.json create mode 100644 .2119/verdicts/REQ-003.7.12--bfda0832f23b.json create mode 100644 .2119/verdicts/REQ-003.7.13--2c7e79895795.json create mode 100644 .2119/verdicts/REQ-003.7.14--17175c1f16e5.json create mode 100644 .2119/verdicts/REQ-003.7.15--5001c836bccc.json create mode 100644 .2119/verdicts/REQ-003.7.16--4120f452f81e.json create mode 100644 .2119/verdicts/REQ-003.7.17--0214d6f3ddb3.json create mode 100644 .2119/verdicts/REQ-003.7.18--3caa48621001.json create mode 100644 .2119/verdicts/REQ-003.7.19--5a0e7c672c12.json create mode 100644 .2119/verdicts/REQ-003.7.2--0b1b5de4319b.json create mode 100644 .2119/verdicts/REQ-003.7.20--7b064ad2add9.json create mode 100644 .2119/verdicts/REQ-003.7.21--292318bccfe6.json create mode 100644 .2119/verdicts/REQ-003.7.7--715c2d1d7041.json create mode 100644 .2119/verdicts/REQ-003.7.8--30562e26467c.json create mode 100644 .2119/verdicts/REQ-003.7.9--61211757be53.json create mode 100644 PiNativeTests/SteeringDeliveryDeduplicationTests.swift create mode 100644 PiNativeTests/SteeringReplayBoundaryTests.swift diff --git a/.2119/verdicts/REQ-003.4.1--fdc1dc54680b.json b/.2119/verdicts/REQ-003.4.1--fdc1dc54680b.json new file mode 100644 index 0000000..c9c42e2 --- /dev/null +++ b/.2119/verdicts/REQ-003.4.1--fdc1dc54680b.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.4.1--fdc1dc54680b", + "requirementId": "REQ-003.4.1", + "hash": "fdc1dc54680b", + "verdict": "pass", + "summary": "testRunningChatDoesNotBlockSelectingAnotherChatOrNewChatSurface exercises the real select()/startNewChat() code path (no mocking of the gate) and asserts selectedSessionID actually changes while the first chat's isConversationRunning stays true, which would fail if a blocking guard (like the archive-flow's blockedNavigationAlert) were added to select().", + "timestamp": "2026-09-02T22:28:30.820Z" +} diff --git a/.2119/verdicts/REQ-003.4.2--bdf1ed3cc242.json b/.2119/verdicts/REQ-003.4.2--bdf1ed3cc242.json new file mode 100644 index 0000000..94d4db0 --- /dev/null +++ b/.2119/verdicts/REQ-003.4.2--bdf1ed3cc242.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.4.2--bdf1ed3cc242", + "requirementId": "REQ-003.4.2", + "hash": "bdf1ed3cc242", + "verdict": "pass", + "summary": "Test starts a running chat in project A, selects a chat in project B, and asserts the selection actually switches (selectedProjectID/selectedSessionID) while project A's conversation remains running — a real behavioral check since select(projectID:) has no running-state guard that could make this pass vacuously.", + "timestamp": "2026-09-02T22:28:30.392Z" +} diff --git a/.2119/verdicts/REQ-003.4.5--77bc109ed8ea.json b/.2119/verdicts/REQ-003.4.5--77bc109ed8ea.json new file mode 100644 index 0000000..82c6919 --- /dev/null +++ b/.2119/verdicts/REQ-003.4.5--77bc109ed8ea.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.4.5--77bc109ed8ea", + "requirementId": "REQ-003.4.5", + "hash": "77bc109ed8ea", + "verdict": "pass", + "summary": "testRuntimeCallbacksRouteOutputToOwningChatAfterSelectionChanges routes a real event through AppModel/ConversationModel while a different chat is selected, asserts the delta appends only as the final item of the owning chat's cachedTranscript (order preserved via dropLast equality) and is absent from the other chat's transcript — genuine, non-mocked coverage of the append-only-to-owning-chat requirement.", + "timestamp": "2026-09-02T22:28:27.839Z" +} diff --git a/.2119/verdicts/REQ-003.4.7--ea2a4fa4eaf8.json b/.2119/verdicts/REQ-003.4.7--ea2a4fa4eaf8.json new file mode 100644 index 0000000..c7247ff --- /dev/null +++ b/.2119/verdicts/REQ-003.4.7--ea2a4fa4eaf8.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.4.7--ea2a4fa4eaf8", + "requirementId": "REQ-003.4.7", + "hash": "ea2a4fa4eaf8", + "verdict": "pass", + "summary": "Test verifies selection actually switches to another chat (selectedSessionID/selectedProjectID change) while a queued-prompt chat and a pending-startup chat both remain in working state; covers both boundary terms (queued prompt, pending startup) with real AppModel state, not mocked-away.", + "timestamp": "2026-09-02T22:28:31.084Z" +} diff --git a/.2119/verdicts/REQ-003.4.8--e7eb0b484688.json b/.2119/verdicts/REQ-003.4.8--e7eb0b484688.json new file mode 100644 index 0000000..8be7c4f --- /dev/null +++ b/.2119/verdicts/REQ-003.4.8--e7eb0b484688.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.4.8--e7eb0b484688", + "requirementId": "REQ-003.4.8", + "hash": "e7eb0b484688", + "verdict": "pass", + "summary": "Test opens new-chat surface and creates+sends a new chat while another session is actively running (isConversationRunning true throughout), verifying via non-mocked AppModel state that neither startNewChat() nor sendNewChatPrompt() are blocked or altered by in-flight work; assertions would fail if a guard were added to gate these on running state.", + "timestamp": "2026-09-02T22:28:51.080Z" +} diff --git a/.2119/verdicts/REQ-003.5.2--0419965f8453.json b/.2119/verdicts/REQ-003.5.2--0419965f8453.json new file mode 100644 index 0000000..58160af --- /dev/null +++ b/.2119/verdicts/REQ-003.5.2--0419965f8453.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.5.2--0419965f8453", + "requirementId": "REQ-003.5.2", + "hash": "0419965f8453", + "verdict": "pass", + "summary": "Tests exercise real RPC race conditions (delayed late deltas/tool starts from stopped and superseded turns, incl. real subprocess) and assert absence of late output in transcript/UI; covers both stop-only and stop-then-new-turn boundary cases genuinely.", + "timestamp": "2026-09-02T22:23:05.454Z" +} diff --git a/.2119/verdicts/REQ-003.5.4--badf24bf0711.json b/.2119/verdicts/REQ-003.5.4--badf24bf0711.json new file mode 100644 index 0000000..52f8129 --- /dev/null +++ b/.2119/verdicts/REQ-003.5.4--badf24bf0711.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.5.4--badf24bf0711", + "requirementId": "REQ-003.5.4", + "hash": "badf24bf0711", + "verdict": "pass", + "summary": "testTwoChatsCanRunIndependentlyAndStopOnlySelectedChat exercises real stopActiveTurn()/isConversationRunning on two concurrently-running conversations and asserts the non-active chat stays running while only the active one stops — a genuine counterexample check, not mocked or tautological.", + "timestamp": "2026-09-02T22:28:54.157Z" +} diff --git a/.2119/verdicts/REQ-003.6.4--1fded447f0b8.json b/.2119/verdicts/REQ-003.6.4--1fded447f0b8.json new file mode 100644 index 0000000..725ccf7 --- /dev/null +++ b/.2119/verdicts/REQ-003.6.4--1fded447f0b8.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.6.4--1fded447f0b8", + "requirementId": "REQ-003.6.4", + "hash": "1fded447f0b8", + "verdict": "pass", + "summary": "Test switches activeConversationModel across quick/project/second-quick sessions via real select() navigation and asserts each retains its own draft (quick-only, project-only, second-quick-only) with explicit non-equality checks — genuinely fails if draft state leaked between sessions.", + "timestamp": "2026-09-02T22:28:20.538Z" +} diff --git a/.2119/verdicts/REQ-003.6.5--f6605f670dbe.json b/.2119/verdicts/REQ-003.6.5--f6605f670dbe.json new file mode 100644 index 0000000..4aa65c7 --- /dev/null +++ b/.2119/verdicts/REQ-003.6.5--f6605f670dbe.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.6.5--f6605f670dbe", + "requirementId": "REQ-003.6.5", + "hash": "f6605f670dbe", + "verdict": "pass", + "summary": "Test starts Quick Chat running (agent_start), then selects and sends a real prompt in a project chat, asserting selection succeeds, project work actually starts (pendingPrompt/isRunning/isLoadingSession + transcript item), and Quick Chat remains running—directly verifying both conjuncts (selecting and starting work) aren't blocked.", + "timestamp": "2026-09-02T22:28:25.893Z" +} diff --git a/.2119/verdicts/REQ-003.6.6--679ab21c5b32.json b/.2119/verdicts/REQ-003.6.6--679ab21c5b32.json new file mode 100644 index 0000000..bf53537 --- /dev/null +++ b/.2119/verdicts/REQ-003.6.6--679ab21c5b32.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.6.6--679ab21c5b32", + "requirementId": "REQ-003.6.6", + "hash": "679ab21c5b32", + "verdict": "pass", + "summary": "Tests verify quick chat output appends only to its own transcript with real event handling, rejecting leakage into a sibling quick chat and into a project chat, plus persistence across navigation via a real RPC fixture.", + "timestamp": "2026-09-02T22:43:52.626Z" +} diff --git a/.2119/verdicts/REQ-003.7.1--df912400610a.json b/.2119/verdicts/REQ-003.7.1--df912400610a.json new file mode 100644 index 0000000..440e0be --- /dev/null +++ b/.2119/verdicts/REQ-003.7.1--df912400610a.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.7.1--df912400610a", + "requirementId": "REQ-003.7.1", + "hash": "df912400610a", + "verdict": "pass", + "summary": "testActiveTurnSubmissionCreatesPendingSteeringInsteadOfUserHistory genuinely drives an active turn via a real agent_start event and real sendDraft(), asserting pendingSteering is created/accepted and excluded from items; empty-content negative case is covered elsewhere (REQ-003.7.8) in the same file.", + "timestamp": "2026-09-02T22:22:53.260Z" +} diff --git a/.2119/verdicts/REQ-003.7.10--4e9e9e3106db.json b/.2119/verdicts/REQ-003.7.10--4e9e9e3106db.json new file mode 100644 index 0000000..61b7915 --- /dev/null +++ b/.2119/verdicts/REQ-003.7.10--4e9e9e3106db.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.7.10--4e9e9e3106db", + "requirementId": "REQ-003.7.10", + "hash": "4e9e9e3106db", + "verdict": "pass", + "summary": "Tests assert pendingSteering non-empty before and empty only after the matching user-message event arrives, and reject premature clearing by an unrelated user event (original prompt) — genuine, non-tautological coverage of the leave-pending requirement.", + "timestamp": "2026-09-02T22:22:35.786Z" +} diff --git a/.2119/verdicts/REQ-003.7.11--e9ec1d0d542e.json b/.2119/verdicts/REQ-003.7.11--e9ec1d0d542e.json new file mode 100644 index 0000000..668c0f2 --- /dev/null +++ b/.2119/verdicts/REQ-003.7.11--e9ec1d0d542e.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.7.11--e9ec1d0d542e", + "requirementId": "REQ-003.7.11", + "hash": "e9ec1d0d542e", + "verdict": "pass", + "summary": "Tests fire duplicate/replayed delivery events through the real event-handling path and assert exactly one resulting user-history entry per accepted steering, rejecting both zero- and duplicate-delivery counterexamples.", + "timestamp": "2026-09-02T22:22:49.505Z" +} diff --git a/.2119/verdicts/REQ-003.7.12--bfda0832f23b.json b/.2119/verdicts/REQ-003.7.12--bfda0832f23b.json new file mode 100644 index 0000000..9c36fb2 --- /dev/null +++ b/.2119/verdicts/REQ-003.7.12--bfda0832f23b.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.7.12--bfda0832f23b", + "requirementId": "REQ-003.7.12", + "hash": "bfda0832f23b", + "verdict": "pass", + "summary": "Test triggers a genuine RPC rejection (client-nil steer failure hits production rejectSteering catch path) and asserts draft is restored into the originating model's composer while a sibling chat's draft is untouched, satisfying the requirement's conjuncts.", + "timestamp": "2026-09-02T22:23:00.445Z" +} diff --git a/.2119/verdicts/REQ-003.7.13--2c7e79895795.json b/.2119/verdicts/REQ-003.7.13--2c7e79895795.json new file mode 100644 index 0000000..6985851 --- /dev/null +++ b/.2119/verdicts/REQ-003.7.13--2c7e79895795.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.7.13--2c7e79895795", + "requirementId": "REQ-003.7.13", + "hash": "2c7e79895795", + "verdict": "pass", + "summary": "testRejectedSteeringRestoresAttachments exercises real rejection path (client.steer throws processNotRunning) and asserts attachments cleared by sendDraft are restored to the originating chat's draftAttachments while an unrelated chat's attachments remain untouched, satisfying scoping and restoration criteria.", + "timestamp": "2026-09-02T22:23:09.222Z" +} diff --git a/.2119/verdicts/REQ-003.7.14--17175c1f16e5.json b/.2119/verdicts/REQ-003.7.14--17175c1f16e5.json new file mode 100644 index 0000000..baf3b67 --- /dev/null +++ b/.2119/verdicts/REQ-003.7.14--17175c1f16e5.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.7.14--17175c1f16e5", + "requirementId": "REQ-003.7.14", + "hash": "17175c1f16e5", + "verdict": "pass", + "summary": "Test exercises real steering-rejection error path (no client running) and asserts exact merged draft ordering (rejected + newer), correctly rejecting an overwrite counterexample; isolation from otherModel also checked.", + "timestamp": "2026-09-02T22:29:02.649Z" +} diff --git a/.2119/verdicts/REQ-003.7.15--5001c836bccc.json b/.2119/verdicts/REQ-003.7.15--5001c836bccc.json new file mode 100644 index 0000000..b9ecae0 --- /dev/null +++ b/.2119/verdicts/REQ-003.7.15--5001c836bccc.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.7.15--5001c836bccc", + "requirementId": "REQ-003.7.15", + "hash": "5001c836bccc", + "verdict": "pass", + "summary": "testStopReplaysFirstSteeringAndRetainsRemainingOrder verifies Stop while steering pending sets isRunning=false and triggers exactly one abort RPC call, with genuine non-tautological assertions against mock RPC call counters.", + "timestamp": "2026-09-02T22:23:21.778Z" +} diff --git a/.2119/verdicts/REQ-003.7.16--4120f452f81e.json b/.2119/verdicts/REQ-003.7.16--4120f452f81e.json new file mode 100644 index 0000000..24294c5 --- /dev/null +++ b/.2119/verdicts/REQ-003.7.16--4120f452f81e.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.7.16--4120f452f81e", + "requirementId": "REQ-003.7.16", + "hash": "4120f452f81e", + "verdict": "pass", + "summary": "Tests verify ordering (stop-completed precedes replay-submitted), positive replay-as-new-user-turn, and the undelivered-vs-already-delivered boundary via real and mock RPC fixtures; genuine, non-tautological coverage.", + "timestamp": "2026-09-02T22:28:52.659Z" +} diff --git a/.2119/verdicts/REQ-003.7.17--0214d6f3ddb3.json b/.2119/verdicts/REQ-003.7.17--0214d6f3ddb3.json new file mode 100644 index 0000000..3430956 --- /dev/null +++ b/.2119/verdicts/REQ-003.7.17--0214d6f3ddb3.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.7.17--0214d6f3ddb3", + "requirementId": "REQ-003.7.17", + "hash": "0214d6f3ddb3", + "verdict": "pass", + "summary": "testStopReplaysFirstSteeringAndRetainsRemainingOrder asserts exact replay order (prompt:first, steer:second) via real RPC callback recording, which would fail if resubmission reordered the originally-submitted messages; genuine coverage of the ordering requirement.", + "timestamp": "2026-09-02T22:23:01.035Z" +} diff --git a/.2119/verdicts/REQ-003.7.18--3caa48621001.json b/.2119/verdicts/REQ-003.7.18--3caa48621001.json new file mode 100644 index 0000000..e3307ab --- /dev/null +++ b/.2119/verdicts/REQ-003.7.18--3caa48621001.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.7.18--3caa48621001", + "requirementId": "REQ-003.7.18", + "hash": "3caa48621001", + "verdict": "pass", + "summary": "Test asserts pendingSteering remains populated immediately after Stop (pre-delivery) and only clears once replayed exactly once, with a late duplicate event not re-adding the message — genuinely covers the MUST-remain-until-delivered/retried requirement.", + "timestamp": "2026-09-02T22:22:40.150Z" +} diff --git a/.2119/verdicts/REQ-003.7.19--5a0e7c672c12.json b/.2119/verdicts/REQ-003.7.19--5a0e7c672c12.json new file mode 100644 index 0000000..b124b0b --- /dev/null +++ b/.2119/verdicts/REQ-003.7.19--5a0e7c672c12.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.7.19--5a0e7c672c12", + "requirementId": "REQ-003.7.19", + "hash": "5a0e7c672c12", + "verdict": "pass", + "summary": "Tests in SteeringDeliveryDeduplicationTests.testLateAcknowledgementsCannotCreateAnyDuplicateSubmissionOrDelivery and StopButtonTests.testStopKeepsUnacknowledgedSteeringUntilExactlyOnceReplayDisposition genuinely exercise late post-Stop acknowledgement events and assert single (not duplicated) submission counts and delivered user items, covering the MUST NOT negative space.", + "timestamp": "2026-09-02T22:22:43.139Z" +} diff --git a/.2119/verdicts/REQ-003.7.2--0b1b5de4319b.json b/.2119/verdicts/REQ-003.7.2--0b1b5de4319b.json new file mode 100644 index 0000000..c05cabe --- /dev/null +++ b/.2119/verdicts/REQ-003.7.2--0b1b5de4319b.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.7.2--0b1b5de4319b", + "requirementId": "REQ-003.7.2", + "hash": "0b1b5de4319b", + "verdict": "pass", + "summary": "testActiveTurnSubmissionCreatesPendingSteeringInsteadOfUserHistory asserts model.isRunning stays true after sendDraft() creates pending steering while agent_start already set it true, genuinely rejecting an implementation that would end the turn.", + "timestamp": "2026-09-02T22:23:01.207Z" +} diff --git a/.2119/verdicts/REQ-003.7.20--7b064ad2add9.json b/.2119/verdicts/REQ-003.7.20--7b064ad2add9.json new file mode 100644 index 0000000..65cadde --- /dev/null +++ b/.2119/verdicts/REQ-003.7.20--7b064ad2add9.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.7.20--7b064ad2add9", + "requirementId": "REQ-003.7.20", + "hash": "7b064ad2add9", + "verdict": "pass", + "summary": "testSteeringQueuesRemainIsolatedPerConversation sends steering draft on 'first' model and asserts 'second' model's pendingSteering stays empty (and vice versa unaffected), a genuine counterexample check for cross-chat leakage.", + "timestamp": "2026-09-02T22:22:54.882Z" +} diff --git a/.2119/verdicts/REQ-003.7.21--292318bccfe6.json b/.2119/verdicts/REQ-003.7.21--292318bccfe6.json new file mode 100644 index 0000000..cd3db1b --- /dev/null +++ b/.2119/verdicts/REQ-003.7.21--292318bccfe6.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.7.21--292318bccfe6", + "requirementId": "REQ-003.7.21", + "hash": "292318bccfe6", + "verdict": "pass", + "summary": "Both evidence tests use two independent PiConversationModel instances with identical steering text and assert the untouched instance's items/pendingSteering are unchanged after delivery to the other — genuinely rejects content-keyed cross-chat leakage, not tautological or mocked.", + "timestamp": "2026-09-02T22:44:02.253Z" +} diff --git a/.2119/verdicts/REQ-003.7.7--715c2d1d7041.json b/.2119/verdicts/REQ-003.7.7--715c2d1d7041.json new file mode 100644 index 0000000..2116f85 --- /dev/null +++ b/.2119/verdicts/REQ-003.7.7--715c2d1d7041.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.7.7--715c2d1d7041", + "requirementId": "REQ-003.7.7", + "hash": "715c2d1d7041", + "verdict": "pass", + "summary": "testAttachmentOnlySteeringPreservesDisplayMetadataAndRPCPayload submits with an empty draft plus only an image attachment during an active turn and asserts via XCTUnwrap that a pendingSteering entry is created with the attachment preserved, genuinely falsifying the requirement if no pending steering message were created.", + "timestamp": "2026-09-02T22:23:06.136Z" +} diff --git a/.2119/verdicts/REQ-003.7.8--30562e26467c.json b/.2119/verdicts/REQ-003.7.8--30562e26467c.json new file mode 100644 index 0000000..2a27a53 --- /dev/null +++ b/.2119/verdicts/REQ-003.7.8--30562e26467c.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.7.8--30562e26467c", + "requirementId": "REQ-003.7.8", + "hash": "30562e26467c", + "verdict": "pass", + "summary": "Test exercises real sendDraft()/PromptAttachmentAssembler.prepare path with whitespace-only draft and no attachments, genuinely asserting no pending steering is created; adjacent attachment-only test confirms the 'without attachments' boundary is honored.", + "timestamp": "2026-09-02T22:23:26.185Z" +} diff --git a/.2119/verdicts/REQ-003.7.9--61211757be53.json b/.2119/verdicts/REQ-003.7.9--61211757be53.json new file mode 100644 index 0000000..1660018 --- /dev/null +++ b/.2119/verdicts/REQ-003.7.9--61211757be53.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.7.9--61211757be53", + "requirementId": "REQ-003.7.9", + "hash": "61211757be53", + "verdict": "pass", + "summary": "testAcceptedSteeringAppearsInConversationHistoryInUserOrder submits two steering messages then delivers their acks out of order with distinct unmatched text, confirming items[] preserves submission order (FIFO) independent of ack arrival order — a genuine, non-tautological behavioral check.", + "timestamp": "2026-09-02T22:28:31.304Z" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index b50336b..de69333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## 2026-09-02 +### Added + +- Added inline steering for active conversations, including ordered per-chat pending messages, attachment support, rejection recovery, and retryable replay after Stop without duplicate delivery. + ### Changed - Scoped official release credentials to a dedicated GitHub environment and replaced the stored temporary-keychain password with a fresh per-run value. diff --git a/PiNative.xcodeproj/project.pbxproj b/PiNative.xcodeproj/project.pbxproj index c1cc5d5..da6784a 100644 --- a/PiNative.xcodeproj/project.pbxproj +++ b/PiNative.xcodeproj/project.pbxproj @@ -19,6 +19,8 @@ B20000000000000000000051 /* ComposerPromptHistoryUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000052 /* ComposerPromptHistoryUnitTests.swift */; }; B20000000000000000000053 /* ComposerPromptHistoryMountedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000054 /* ComposerPromptHistoryMountedTests.swift */; }; B20000000000000000000020 /* StopButtonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000021 /* StopButtonTests.swift */; }; + S70000000000000000000001 /* SteeringDeliveryDeduplicationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = S70000000000000000000002 /* SteeringDeliveryDeduplicationTests.swift */; }; + S70000000000000000000003 /* SteeringReplayBoundaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = S70000000000000000000004 /* SteeringReplayBoundaryTests.swift */; }; B20000000000000000000040 /* PromoteToProjectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000041 /* PromoteToProjectTests.swift */; }; B20000000000000000000044 /* AppModelConversationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000045 /* AppModelConversationTests.swift */; }; B20000000000000000000046 /* ConversationRestorationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000047 /* ConversationRestorationTests.swift */; }; @@ -78,6 +80,8 @@ B20000000000000000000052 /* ComposerPromptHistoryUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerPromptHistoryUnitTests.swift; sourceTree = ""; }; B20000000000000000000054 /* ComposerPromptHistoryMountedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerPromptHistoryMountedTests.swift; sourceTree = ""; }; B20000000000000000000021 /* StopButtonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopButtonTests.swift; sourceTree = ""; }; + S70000000000000000000002 /* SteeringDeliveryDeduplicationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteeringDeliveryDeduplicationTests.swift; sourceTree = ""; }; + S70000000000000000000004 /* SteeringReplayBoundaryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteeringReplayBoundaryTests.swift; sourceTree = ""; }; B20000000000000000000041 /* PromoteToProjectTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromoteToProjectTests.swift; sourceTree = ""; }; B20000000000000000000045 /* AppModelConversationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModelConversationTests.swift; sourceTree = ""; }; B20000000000000000000047 /* ConversationRestorationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationRestorationTests.swift; sourceTree = ""; }; @@ -139,7 +143,7 @@ /* Begin PBXGroup section */ E089F906CDE66AC4DE0B4F15 = {isa = PBXGroup; children = (D0E626B11EE2E26B8A53ED03 /* PiNative */, B20000000000000000000005 /* PiNativeTests */, B10000000000000000000005 /* PiNativeUITests */, BD9F9D28A4CE3701DBFE1F16 /* Products */,); sourceTree = ""; }; D0E626B11EE2E26B8A53ED03 /* PiNative */ = {isa = PBXGroup; children = (A4F4E099E55872B7CD055B17 /* Info.plist */, C84606F68DFF82AAD6188CA7, 5CBE4FCD8A524D79E75AB937, T10000000000000000000002 /* ChatTitleService.swift */, AC1000000000000000000002, P20000000000000000000002 /* PromoteToProject.swift */, M00000000000000000000002 /* ModelSettingsModel.swift */, M00000000000000000000004 /* ModelSettingsView.swift */, M00000000000000000000006 /* SettingsSection.swift */, 39174CAEDA0A24BA7426D974, 639F8CF536299A8279E3BDF3, 4CB86D6D5E6DF695BBFC4648, A22222222222222222222222, A44444444444444444444444, 37BDA846F2D5F231576CC31A, H10000000000000000000002 /* PiHealth.swift */, 6D3854512F688EC89284C386, 2E0FAB1DF804CEE1B91D9B4C, 83244A51E66C4FB7B47C044E, EF1D997813F7495191EA5BAA, 2D3E4F5A6B7C8D9E0F112233, F0A000000000000000000020 /* Resources */, 0AAD256F0C3440739EFB395D /* Components */,); path = PiNative; sourceTree = ""; }; - B20000000000000000000005 /* PiNativeTests */ = {isa = PBXGroup; children = (B20000000000000000000002 /* AttachmentSupportTests.swift */, B20000000000000000000052 /* ComposerPromptHistoryUnitTests.swift */, B20000000000000000000054 /* ComposerPromptHistoryMountedTests.swift */, B20000000000000000000021 /* StopButtonTests.swift */, B20000000000000000000031 /* ChatReadinessTests.swift */, H20000000000000000000002 /* PiStartupHealthTests.swift */, B20000000000000000000047 /* ConversationRestorationTests.swift */, B20000000000000000000045 /* AppModelConversationTests.swift */, B20000000000000000000041 /* PromoteToProjectTests.swift */, B20000000000000000000043 /* ParallelRuntimeTests.swift */, M00000000000000000000008 /* ModelSettingsTests.swift */, T20000000000000000000002 /* ChatTitleTests.swift */,); path = PiNativeTests; sourceTree = ""; }; + B20000000000000000000005 /* PiNativeTests */ = {isa = PBXGroup; children = (B20000000000000000000002 /* AttachmentSupportTests.swift */, B20000000000000000000052 /* ComposerPromptHistoryUnitTests.swift */, B20000000000000000000054 /* ComposerPromptHistoryMountedTests.swift */, B20000000000000000000021 /* StopButtonTests.swift */, S70000000000000000000002 /* SteeringDeliveryDeduplicationTests.swift */, S70000000000000000000004 /* SteeringReplayBoundaryTests.swift */, B20000000000000000000031 /* ChatReadinessTests.swift */, H20000000000000000000002 /* PiStartupHealthTests.swift */, B20000000000000000000047 /* ConversationRestorationTests.swift */, B20000000000000000000045 /* AppModelConversationTests.swift */, B20000000000000000000041 /* PromoteToProjectTests.swift */, B20000000000000000000043 /* ParallelRuntimeTests.swift */, M00000000000000000000008 /* ModelSettingsTests.swift */, T20000000000000000000002 /* ChatTitleTests.swift */,); path = PiNativeTests; sourceTree = ""; }; B10000000000000000000005 /* PiNativeUITests */ = {isa = PBXGroup; children = (B10000000000000000000002 /* PiNativeUITestCase.swift */, C10000000000000000000002 /* ProjectUITests.swift */, C10000000000000000000004 /* ShellChromeUITests.swift */, C10000000000000000000006 /* NewChatUITests.swift */, C10000000000000000000008 /* PromoteToProjectUITests.swift */, C10000000000000000000010 /* ConversationNavigationUITests.swift */, C10000000000000000000012 /* ChatReadinessUITests.swift */, H30000000000000000000002 /* PiStartupHealthUITests.swift */, C10000000000000000000014 /* ActiveWorkUITests.swift */, C10000000000000000000016 /* ModelSettingsUITests.swift */,); path = PiNativeUITests; sourceTree = ""; }; 0AAD256F0C3440739EFB395D /* Components */ = {isa = PBXGroup; children = (7B6CCA67BBC5465080FDEDA3 /* ResizableDividerView.swift */, 2E66A9D075FC494B888EB403 /* ComingSoonPane.swift */, 9A2222222222222222222222 /* NewChatStartView.swift */, 9A4444444444444444444444 /* WindowChromeConfigurator.swift */, 9A6666666666666666666666 /* MapleFont.swift */,); path = Components; sourceTree = ""; }; F0A000000000000000000020 /* Resources */ = {isa = PBXGroup; children = (AA1000000000000000000002 /* Assets.xcassets */, F0A000000000000000000021 /* Fonts */,); path = Resources; sourceTree = ""; }; @@ -162,7 +166,7 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - B20000000000000000000009 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B20000000000000000000001 /* AttachmentSupportTests.swift in Sources */, PH20000000000000000000001 /* AnalyticsTests.swift in Sources */, B20000000000000000000051 /* ComposerPromptHistoryUnitTests.swift in Sources */, B20000000000000000000053 /* ComposerPromptHistoryMountedTests.swift in Sources */, B20000000000000000000020 /* StopButtonTests.swift in Sources */, B20000000000000000000030 /* ChatReadinessTests.swift in Sources */, H20000000000000000000001 /* PiStartupHealthTests.swift in Sources */, B20000000000000000000046 /* ConversationRestorationTests.swift in Sources */, B20000000000000000000044 /* AppModelConversationTests.swift in Sources */, B20000000000000000000040 /* PromoteToProjectTests.swift in Sources */, B20000000000000000000042 /* ParallelRuntimeTests.swift in Sources */, M00000000000000000000007 /* ModelSettingsTests.swift in Sources */, T20000000000000000000001 /* ChatTitleTests.swift in Sources */,); runOnlyForDeploymentPostprocessing = 0; }; + B20000000000000000000009 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B20000000000000000000001 /* AttachmentSupportTests.swift in Sources */, PH20000000000000000000001 /* AnalyticsTests.swift in Sources */, B20000000000000000000051 /* ComposerPromptHistoryUnitTests.swift in Sources */, B20000000000000000000053 /* ComposerPromptHistoryMountedTests.swift in Sources */, B20000000000000000000020 /* StopButtonTests.swift in Sources */, S70000000000000000000001 /* SteeringDeliveryDeduplicationTests.swift in Sources */, S70000000000000000000003 /* SteeringReplayBoundaryTests.swift in Sources */, B20000000000000000000030 /* ChatReadinessTests.swift in Sources */, H20000000000000000000001 /* PiStartupHealthTests.swift in Sources */, B20000000000000000000046 /* ConversationRestorationTests.swift in Sources */, B20000000000000000000044 /* AppModelConversationTests.swift in Sources */, B20000000000000000000040 /* PromoteToProjectTests.swift in Sources */, B20000000000000000000042 /* ParallelRuntimeTests.swift in Sources */, M00000000000000000000007 /* ModelSettingsTests.swift in Sources */, T20000000000000000000001 /* ChatTitleTests.swift in Sources */,); runOnlyForDeploymentPostprocessing = 0; }; B10000000000000000000009 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000001 /* PiNativeUITestCase.swift in Sources */, C10000000000000000000001 /* ProjectUITests.swift in Sources */, C10000000000000000000003 /* ShellChromeUITests.swift in Sources */, C10000000000000000000005 /* NewChatUITests.swift in Sources */, C10000000000000000000007 /* PromoteToProjectUITests.swift in Sources */, C10000000000000000000009 /* ConversationNavigationUITests.swift in Sources */, C10000000000000000000011 /* ChatReadinessUITests.swift in Sources */, H30000000000000000000001 /* PiStartupHealthUITests.swift in Sources */, C10000000000000000000013 /* ActiveWorkUITests.swift in Sources */, C10000000000000000000015 /* ModelSettingsUITests.swift in Sources */,); runOnlyForDeploymentPostprocessing = 0; }; 3B7F5E0625A26093DFD7689A /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (C91A821BBD8FC19135EFCBCA, PH10000000000000000000001 /* Analytics.swift in Sources */, 94A8D39EFF7FB95895DA2429, AC1000000000000000000001, P20000000000000000000001 /* PromoteToProject.swift in Sources */, 235685A27DDE01B5D736BB3C, B6B0755D2911C382985AF503, 0A05B88A079DBE985586E196, A11111111111111111111111, A33333333333333333333333, F9929FC807AAF9F82196B366, H10000000000000000000001 /* PiHealth.swift in Sources */, 48688F523F9A83A0E9442735, 5276C82553DEF1BA0318E7F2, 4A910114936B41909D5C475F, B2CF10EA35FC4681BB02C530, 8DAC23A1E9A942EFB74071DB, C8BCB535833E41F6AD37358B, 9A1111111111111111111111, 9A3333333333333333333333, 9A5555555555555555555555, 1D2E3F4A5B6C7D8E9F001122, M00000000000000000000001 /* ModelSettingsModel.swift in Sources */, M00000000000000000000003 /* ModelSettingsView.swift in Sources */, M00000000000000000000005 /* SettingsSection.swift in Sources */, T10000000000000000000001 /* ChatTitleService.swift in Sources */,); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ diff --git a/PiNative/AppModel.swift b/PiNative/AppModel.swift index 36a154e..0837a30 100644 --- a/PiNative/AppModel.swift +++ b/PiNative/AppModel.swift @@ -170,6 +170,7 @@ final class AppModel: ObservableObject { private var globalModifierMonitor: Any? private let modelCatalogLoaderOverride: (() async throws -> [PiModelOption])? private let chatTitleGenerator: any ChatTitleGenerating + private let conversationModelFactory: (ModelSettingsModel) -> PiConversationModel private let piHealthCheckOperation: () async -> PiHealthCheckResult private let piTerminalRecoveryOperation: (PiCommand?) async throws -> Void private var hasCompletedPiHealthCheck = false @@ -261,7 +262,8 @@ final class AppModel: ObservableObject { chatTitleGenerator: (any ChatTitleGenerating)? = nil, analytics: any AnalyticsControlling = AppAnalytics.shared, piHealthCheck: (() async -> PiHealthCheckResult)? = nil, - piTerminalRecovery: ((PiCommand?) async throws -> Void)? = nil + piTerminalRecovery: ((PiCommand?) async throws -> Void)? = nil, + conversationModelFactory: ((ModelSettingsModel) -> PiConversationModel)? = nil ) { self.analytics = analytics self.analyticsEnabled = analytics.isEnabled @@ -278,6 +280,7 @@ final class AppModel: ObservableObject { storage: storage, configuredDefaultModel: ModelSettingsModel.configuredDefaultModel(environment: environment) ) + self.conversationModelFactory = conversationModelFactory ?? { PiConversationModel(modelSettings: $0) } let isResettingForUITests = environment["PI_NATIVE_RESET_PROJECTS"] == "1" self.isLeftPaneVisible = isResettingForUITests ? true : (defaults.object(forKey: Self.leftPaneVisibleKey) as? Bool ?? true) let defaultCodeFolder = environment["PI_NATIVE_TEST_PROMOTE_CODE_FOLDER"] ?? defaults.string(forKey: Self.promoteDefaultCodeFolderKey) ?? Self.defaultProjectFolderPath @@ -1087,7 +1090,7 @@ final class AppModel: ObservableObject { return runtime } - let model = PiConversationModel(modelSettings: modelSettings) + let model = conversationModelFactory(modelSettings) let runtime = ConversationRuntime(key: key, model: model) conversationRuntimes[key] = runtime conversationRuntimeStates[key] = ConversationRuntimeState() diff --git a/PiNative/PiConversationModel.swift b/PiNative/PiConversationModel.swift index 7936afb..6577f88 100644 --- a/PiNative/PiConversationModel.swift +++ b/PiNative/PiConversationModel.swift @@ -20,6 +20,7 @@ final class PiConversationModel: ObservableObject { @Published var availableModels: [PiModelOption] = [] @Published var currentThinkingLevel: PiThinkingLevel? @Published var availableThinkingLevels: [PiThinkingLevel] = [] + @Published private(set) var pendingSteering: [SteeringMessage] = [] var onSessionPathResolved: ((String) -> Void)? var onUserMessageSent: ((String) -> Void)? @@ -48,17 +49,22 @@ final class PiConversationModel: ObservableObject { private var interactiveAttentionNotice: String? private var isPlanningMode = false private var activeLocalTurnID: UUID? + private var isAwaitingInitialPromptUserEvent = false /// True after the user presses Stop until the next prompt starts. Pi may /// still emit a few late events while abort/termination races the active /// turn; suppress those so a stopped turn cannot keep appending output or /// flip the composer back into running state. private var isSuppressingStoppedTurnEvents = false - private var mockResponse: String? { ProcessInfo.processInfo.environment["PI_NATIVE_MOCK_RPC_RESPONSE"] } + private var mockResponse: String? { + mockResponseOverrideForTesting ?? ProcessInfo.processInfo.environment["PI_NATIVE_MOCK_RPC_RESPONSE"] + } private var mockResponseDelayNanoseconds: UInt64 { let milliseconds = UInt64(ProcessInfo.processInfo.environment["PI_NATIVE_MOCK_RPC_RESPONSE_DELAY_MS"] ?? "300") ?? 300 return milliseconds * 1_000_000 } - private var shouldStallRPCForTesting: Bool { ProcessInfo.processInfo.environment["PI_NATIVE_TEST_RPC_STALL"] == "1" } + private var shouldStallRPCForTesting: Bool { + shouldStallRPCOverrideForTesting ?? (ProcessInfo.processInfo.environment["PI_NATIVE_TEST_RPC_STALL"] == "1") + } private var shouldFailRPCForTesting: Bool { ProcessInfo.processInfo.environment["PI_NATIVE_TEST_RPC_CATASTROPHIC_FAILURE"] == "1" } private let piCommandOverride: PiCommand? private let modelSettings: ModelSettingsModel? @@ -67,6 +73,17 @@ final class PiConversationModel: ObservableObject { private var pendingThinkingLevelSelection: PiThinkingLevel? private var thinkingLevelBeforePendingSelection: PiThinkingLevel? private var selectionMutationTask: Task? + private var steeringSubmissionTask: Task? + private var steeringOperationGeneration = 0 + private var shouldReplaySteeringAfterStop = false +#if DEBUG + var shouldStallRPCOverrideForTesting: Bool? + var mockResponseOverrideForTesting: String? + var onSteeringRPCForTesting: ((String) -> Void)? + var onPromptRPCForTesting: ((String) -> Void)? + var onAbortRPCForTesting: (() -> Void)? + var onStopCompletionForTesting: (() -> Void)? +#endif private static let defaultThinkingLevels: [PiThinkingLevel] = [.low, .medium, .high] init(piCommand: PiCommand? = nil, modelSettings: ModelSettingsModel? = nil) { @@ -261,6 +278,7 @@ final class PiConversationModel: ObservableObject { self.errorMessage = error.localizedDescription self.reportPiLoadFailure(stage: .processStart, error: error) let notice = self.rpcFailureNotice("Failed to start pi", error: error) + self.failReplayingSteeringIfNeeded(notice) self.sessionLoadNotice = notice self.rpcStatusMessage = notice self.isCatastrophicRPCFailure = true @@ -284,6 +302,9 @@ final class PiConversationModel: ObservableObject { } func stop() { + steeringOperationGeneration += 1 + steeringSubmissionTask?.cancel() + steeringSubmissionTask = nil let oldClient = client client = nil processGeneration += 1 @@ -302,9 +323,15 @@ final class PiConversationModel: ObservableObject { func sendDraft() { guard canSubmitWithSelection else { return } guard let prepared = PromptAttachmentAssembler.prepare(draft: draft, attachments: draftAttachments) else { return } + let submittedDraft = draft + let submittedAttachments = draftAttachments draft = "" draftAttachments = [] onPromptSubmitted?() + if isRunning { + queueSteering(prepared, composerText: submittedDraft, composerAttachments: submittedAttachments) + return + } guard isSessionReady, client != nil || mockResponse != nil else { pendingPrompt = PendingPrompt(prepared: prepared, shouldAppendUserMessage: false) appendUserMessage(for: prepared) @@ -336,14 +363,41 @@ final class PiConversationModel: ObservableObject { draftAttachments.removeAll { $0.id == id } } + func retrySteering(_ id: UUID) { + guard let index = pendingSteering.firstIndex(where: { $0.id == id }), + pendingSteering[index].state == .failed + else { return } + if isRunning { + pendingSteering[index].state = .submitting + scheduleSteeringSubmission() + } else { + pendingSteering[index].state = .replaying + shouldReplaySteeringAfterStop = true + if !isSessionReady, client == nil, mockResponse == nil { + shouldReplaySteeringAfterStop = false + failPendingSteeringReplay("Couldn’t resume steering after Stop: pi is not running.") + return + } + replaySteeringAfterStopIfNeeded() + } + } + /// Stops the active turn from the user's perspective immediately, then /// attempts a server-side abort. If Pi does not acknowledge quickly, /// terminate/restart the RPC process so work is actually interrupted. func stopActiveTurn() { guard isRunning || pendingPrompt != nil else { return } + steeringOperationGeneration += 1 + steeringSubmissionTask?.cancel() + steeringSubmissionTask = nil + shouldReplaySteeringAfterStop = !pendingSteering.isEmpty + for index in pendingSteering.indices { + pendingSteering[index].state = .replaying + } pendingPrompt = nil isRunning = false activeLocalTurnID = nil + isAwaitingInitialPromptUserEvent = false isSuppressingStoppedTurnEvents = true onAgentRunAbandoned?() assistantBufferID = nil @@ -351,10 +405,16 @@ final class PiConversationModel: ObservableObject { items.append(.notice("Stopped.")) let clientToAbort = client - client = nil - isSessionReady = false - processGeneration += 1 - lastStartKey = nil +#if DEBUG + onAbortRPCForTesting?() +#endif + + if mockResponse == nil { + client = nil + isSessionReady = false + processGeneration += 1 + lastStartKey = nil + } let stoppedGeneration = processGeneration let workingDirectory = currentWorkingDirectory let sessionPath = currentSessionPath @@ -363,6 +423,18 @@ final class PiConversationModel: ObservableObject { Task { _ = try? await clientToAbort?.abort(timeoutSeconds: 1.25) await MainActor.run { +#if DEBUG + self.onStopCompletionForTesting?() +#endif + if self.mockResponse != nil { + self.isSuppressingStoppedTurnEvents = false + self.replaySteeringAfterStopIfNeeded() + return + } + guard clientToAbort != nil else { + self.failReplayingSteeringIfNeeded("Couldn’t resume steering after Stop: pi is not running.") + return + } guard self.processGeneration == stoppedGeneration, self.client == nil else { return } self.start( workingDirectory: workingDirectory, @@ -370,6 +442,7 @@ final class PiConversationModel: ObservableObject { cachedItems: cachedItems, planningMode: planningMode ) + self.replaySteeringAfterStopIfNeeded() } } } @@ -429,6 +502,7 @@ final class PiConversationModel: ObservableObject { restoreInteractiveAttentionNoticeIfNeeded() isCatastrophicRPCFailure = false flushPendingPromptIfNeeded() + replaySteeringAfterStopIfNeeded() } catch { guard generation == sessionGeneration, requestProcessGeneration == processGeneration else { return } if !hasRetriedAfterRestart, shouldRestartClient(after: error) { @@ -442,6 +516,7 @@ final class PiConversationModel: ObservableObject { isLoadingSession = false reportPiLoadFailure(stage: .sessionLoad, error: error) let notice = rpcFailureNotice("Failed to load session", error: error) + failReplayingSteeringIfNeeded(notice) sessionLoadNotice = notice rpcStatusMessage = notice isCatastrophicRPCFailure = true @@ -491,6 +566,7 @@ final class PiConversationModel: ObservableObject { isLoadingSession = false reportPiLoadFailure(stage: .sessionLoad, error: error) let notice = rpcFailureNotice("Failed to load session", error: error) + failReplayingSteeringIfNeeded(notice) sessionLoadNotice = notice rpcStatusMessage = notice isCatastrophicRPCFailure = true @@ -683,6 +759,76 @@ final class PiConversationModel: ObservableObject { onUserMessageSent?(prepared.summaryText) } + private func queueSteering(_ prepared: PreparedPrompt, composerText: String, composerAttachments: [ComposerAttachment]) { + let message = SteeringMessage( + prepared: prepared, + composerText: composerText, + composerAttachments: composerAttachments, + state: .submitting + ) + pendingSteering.append(message) + + if mockResponse != nil { + setSteeringState(id: message.id, state: .accepted) + } else { + scheduleSteeringSubmission() + } + } + + /// Serializes steer RPC calls so rapid Return presses reach Pi in the same + /// order as the visible inline queue. + private func scheduleSteeringSubmission() { + guard steeringSubmissionTask == nil else { return } + let generation = steeringOperationGeneration + steeringSubmissionTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled, + generation == self.steeringOperationGeneration, + let next = self.pendingSteering.first(where: { $0.state == .submitting }) { + do { +#if DEBUG + self.onSteeringRPCForTesting?(next.prepared.summaryText) +#endif + guard let client = self.client else { throw PiRPCClient.ClientError.processNotRunning } + _ = try await client.steer(self.promptMessage(for: next.prepared), images: next.prepared.images) + guard generation == self.steeringOperationGeneration else { break } + self.setSteeringState(id: next.id, state: .accepted) + } catch is CancellationError { + break + } catch { + guard generation == self.steeringOperationGeneration else { break } + self.rejectSteering(id: next.id, error: error) + } + } + guard generation == self.steeringOperationGeneration else { return } + self.steeringSubmissionTask = nil + } + } + + private func setSteeringState(id: UUID, state: SteeringMessage.State) { + guard let index = pendingSteering.firstIndex(where: { $0.id == id }) else { return } + pendingSteering[index].state = state + } + + private func rejectSteering(id: UUID, error: Error) { + guard let index = pendingSteering.firstIndex(where: { $0.id == id }) else { return } + let rejected = pendingSteering.remove(at: index) + let currentDraft = draft.trimmingCharacters(in: .whitespacesAndNewlines) + let rejectedDraft = rejected.composerText.trimmingCharacters(in: .whitespacesAndNewlines) + draft = [rejectedDraft, currentDraft].filter { !$0.isEmpty }.joined(separator: "\n\n") + addDraftAttachments(rejected.composerAttachments) + let notice = rpcFailureNotice("Couldn’t queue steering", error: error) + errorMessage = error.localizedDescription + items.append(.notice(notice)) + } + + private func consumeDeliveredSteeringIfPresent() { + guard !pendingSteering.isEmpty else { return } + let delivered = pendingSteering.removeFirst() + closeCurrentActivityGroup() + appendUserMessage(for: delivered.prepared) + } + private func send(_ prepared: PreparedPrompt?, shouldAppendUserMessage: Bool) { guard canSubmitWithSelection, let prepared else { return } if client == nil, mockResponse == nil { @@ -696,6 +842,7 @@ final class PiConversationModel: ObservableObject { } runningStartedAt = Date() isRunning = true + isAwaitingInitialPromptUserEvent = true let localTurnID = UUID() activeLocalTurnID = localTurnID @@ -905,12 +1052,21 @@ final class PiConversationModel: ObservableObject { case "agent_settled": isRunning = false activeLocalTurnID = nil + isAwaitingInitialPromptUserEvent = false assistantBufferID = nil closeCurrentActivityGroup() onAgentSettled?(items) case "turn_end": assistantBufferID = nil closeCurrentActivityGroup() + case "message_start": + if event["message"]?.objectValue?["role"]?.stringValue == "user" { + if isAwaitingInitialPromptUserEvent { + isAwaitingInitialPromptUserEvent = false + } else { + consumeDeliveredSteeringIfPresent() + } + } case "message_update": handleMessageUpdate(event) case "message_end": @@ -934,22 +1090,86 @@ final class PiConversationModel: ObservableObject { private static func isTurnEvent(_ type: String) -> Bool { switch type { - case "agent_start", "agent_end", "agent_settled", "turn_end", "message_update", "message_end", "tool_execution_start", "tool_execution_update", "tool_execution_end", "compaction_start", "compaction_end", "extension_ui_request": + case "agent_start", "agent_end", "agent_settled", "turn_end", "message_start", "message_end", "message_update", "tool_execution_start", "tool_execution_update", "tool_execution_end", "compaction_start", "compaction_end", "extension_ui_request": return true default: return false } } - private func restartClientAfterForcedStop() { - guard client != nil else { return } - let workingDirectory = currentWorkingDirectory - let sessionPath = currentSessionPath - let cachedItems = items - let planningMode = isPlanningMode - stop() - isSuppressingStoppedTurnEvents = false - start(workingDirectory: workingDirectory, sessionPath: sessionPath, cachedItems: cachedItems, planningMode: planningMode) + private func replaySteeringAfterStopIfNeeded() { + guard shouldReplaySteeringAfterStop, isSessionReady, !pendingSteering.isEmpty else { return } + shouldReplaySteeringAfterStop = false + let generation = steeringOperationGeneration + + if mockResponse != nil { + let first = pendingSteering.removeFirst() +#if DEBUG + onPromptRPCForTesting?(first.prepared.summaryText) +#endif + send(first.prepared, shouldAppendUserMessage: true) + for index in pendingSteering.indices { +#if DEBUG + onSteeringRPCForTesting?(pendingSteering[index].prepared.summaryText) +#endif + pendingSteering[index].state = .accepted + } + return + } + + guard client != nil else { + failPendingSteeringReplay("Couldn’t resume steering after Stop: pi is not running.") + return + } + + runningStartedAt = Date() + isRunning = true + isAwaitingInitialPromptUserEvent = true + activeLocalTurnID = UUID() + + Task { [weak self] in + guard let self, let client = self.client, let first = self.pendingSteering.first else { return } + do { +#if DEBUG + self.onPromptRPCForTesting?(first.prepared.summaryText) +#endif + _ = try await client.prompt(self.promptMessage(for: first.prepared), images: first.prepared.images) + guard generation == self.steeringOperationGeneration else { return } + if self.pendingSteering.first?.id == first.id { + self.pendingSteering.removeFirst() + self.appendUserMessage(for: first.prepared) + } + + let remainingIDs = self.pendingSteering.map(\.id) + for id in remainingIDs { + guard generation == self.steeringOperationGeneration, + let entry = self.pendingSteering.first(where: { $0.id == id }) + else { return } + _ = try await client.steer(self.promptMessage(for: entry.prepared), images: entry.prepared.images) + guard generation == self.steeringOperationGeneration else { return } + self.setSteeringState(id: id, state: .accepted) + } + } catch { + guard generation == self.steeringOperationGeneration else { return } + self.isRunning = false + self.isAwaitingInitialPromptUserEvent = false + self.activeLocalTurnID = nil + self.failReplayingSteeringIfNeeded(self.rpcFailureNotice("Couldn’t resume steering after Stop", error: error)) + } + } + } + + private func failReplayingSteeringIfNeeded(_ notice: String) { + guard shouldReplaySteeringAfterStop || pendingSteering.contains(where: { $0.state == .replaying }) else { return } + shouldReplaySteeringAfterStop = false + failPendingSteeringReplay(notice) + } + + private func failPendingSteeringReplay(_ notice: String) { + for index in pendingSteering.indices where pendingSteering[index].state == .replaying { + pendingSteering[index].state = .failed + } + items.append(.notice(notice)) } private func handleExtensionUIRequest(_ event: RPCEnvelope) { @@ -1169,6 +1389,21 @@ private struct PendingPrompt { var shouldAppendUserMessage: Bool } +struct SteeringMessage: Identifiable, Hashable { + enum State: Hashable { + case submitting + case accepted + case replaying + case failed + } + + var id = UUID() + var prepared: PreparedPrompt + var composerText: String + var composerAttachments: [ComposerAttachment] + var state: State +} + struct UserMessagePayload: Hashable, Codable { var text: String var attachments: [ComposerAttachment] diff --git a/PiNative/PiConversationView.swift b/PiNative/PiConversationView.swift index 33d56f0..af0d83a 100644 --- a/PiNative/PiConversationView.swift +++ b/PiNative/PiConversationView.swift @@ -125,7 +125,13 @@ struct PiConversationView: View { .id(item.id) } - if !model.items.isEmpty { + ForEach(model.pendingSteering) { message in + PendingSteeringRow(message: message, onRetry: { model.retrySteering(message.id) }) + .id(message.id) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } + + if !model.items.isEmpty || !model.pendingSteering.isEmpty { PiNativeTranscriptEndMark(isRunning: model.isRunning, startedAt: model.runningStartedAt) } } @@ -148,7 +154,7 @@ struct PiConversationView: View { scrollToBottom(proxy, animated: false) } .onChange(of: transcriptContentID) { _, _ in - guard !model.items.isEmpty else { return } + guard !model.items.isEmpty || !model.pendingSteering.isEmpty else { return } scrollToBottom(proxy, animated: true) } } @@ -165,7 +171,8 @@ struct PiConversationView: View { private var transcriptContentID: String { let itemFingerprint = model.items.map { "\($0.id.uuidString):\($0.hashValue)" }.joined(separator: ",") - return "\(itemFingerprint)|running:\(model.isRunning)|loading:\(model.isLoadingSession)|notice:\(model.sessionLoadNotice ?? "")" + let steeringFingerprint = model.pendingSteering.map { "\($0.id.uuidString):\($0.hashValue)" }.joined(separator: ",") + return "\(itemFingerprint)|steering:\(steeringFingerprint)|running:\(model.isRunning)|loading:\(model.isLoadingSession)|notice:\(model.sessionLoadNotice ?? "")" } @ViewBuilder @@ -812,6 +819,55 @@ private struct ComposerSendButton: View { } } +private struct PendingSteeringRow: View { + let message: SteeringMessage + let onRetry: () -> Void + + private var stateLabel: String { + switch message.state { + case .submitting: "Queueing" + case .accepted: "Steering" + case .replaying: "Sending after Stop" + case .failed: "Steering failed" + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text("\(stateLabel):") + .font(ChatTypography.caption(weight: .semibold)) + Text(message.prepared.summaryText) + .font(ChatTypography.caption()) + .lineLimit(2) + .truncationMode(.tail) + } + + if !message.prepared.displayAttachments.isEmpty { + HStack(spacing: 8) { + ForEach(message.prepared.displayAttachments) { attachment in + Label(attachment.displayName, systemImage: "paperclip") + .lineLimit(1) + } + } + .font(ChatTypography.micro()) + } + + if message.state == .failed { + Button("Retry", action: onRetry) + .buttonStyle(.plain) + .font(ChatTypography.caption(weight: .semibold)) + .foregroundStyle(Color.primary.opacity(0.72)) + } + } + .foregroundStyle(Color.secondary.opacity(0.82)) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .contain) + .accessibilityLabel("\(stateLabel): \(message.prepared.summaryText)") + .accessibilityIdentifier("transcript.pendingSteering") + } +} + /// Right-aligned, dark, rounded bubble with **no avatar/role label** — /// matches the reference layout's user-turn treatment exactly (found by /// adversarial review: the original version had a "You" label and a light diff --git a/PiNative/PiRPCClient.swift b/PiNative/PiRPCClient.swift index d00116d..c63d473 100644 --- a/PiNative/PiRPCClient.swift +++ b/PiNative/PiRPCClient.swift @@ -291,6 +291,16 @@ actor PiRPCClient { try await send(command: "prompt", fields: Self.promptFields(message: message, images: images), timeoutSeconds: timeoutSeconds) } + /// Queue input for the next model turn while the agent is active. The + /// response acknowledges queueing; Pi owns the actual delivery boundary. + func steer(_ message: String, images: [RPCImageContent] = [], timeoutSeconds: TimeInterval = 15) async throws -> RPCEnvelope { + try await send(command: "steer", fields: Self.steerFields(message: message, images: images), timeoutSeconds: timeoutSeconds) + } + + static func steerFields(message: String, images: [RPCImageContent] = []) -> [String: JSONValue] { + promptFields(message: message, images: images) + } + static func promptFields(message: String, images: [RPCImageContent] = []) -> [String: JSONValue] { var fields: [String: JSONValue] = ["message": .string(message)] if !images.isEmpty { diff --git a/PiNativeTests/ParallelRuntimeTests.swift b/PiNativeTests/ParallelRuntimeTests.swift index f190d5d..391ddd3 100644 --- a/PiNativeTests/ParallelRuntimeTests.swift +++ b/PiNativeTests/ParallelRuntimeTests.swift @@ -1,8 +1,38 @@ +import Combine +import Darwin import XCTest @testable import PiNative @MainActor final class ParallelRuntimeTests: XCTestCase { + func testPendingSteeringRemainsWithChatAcrossNavigation() throws { + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "later response", 1) + defer { unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") } + let appModel = testAppModel() + let projectID = appModel.projects[0].id + let first = appModel.projects[0].sessions[0] + let second = appModel.projects[0].sessions[1] + appModel.select(sessionID: first.id, in: projectID) + let firstModel = try XCTUnwrap(appModel.activeConversationModel) + firstModel.currentModel = PiModelOption(provider: "test", id: "selected", name: "Selected") + firstModel.currentThinkingLevel = .medium + firstModel.start(workingDirectory: nil, sessionPath: nil) + firstModel.handleEventForTesting(try Self.event(type: "agent_start")) + firstModel.draft = "stay with first chat" + firstModel.sendDraft() + firstModel.draft = "stay second" + firstModel.sendDraft() + firstModel.draft = "stay third" + firstModel.sendDraft() + + appModel.select(sessionID: second.id, in: projectID) + firstModel.handleEventForTesting(try Self.userMessageStart("stay with first chat")) + appModel.select(sessionID: first.id, in: projectID) + + // 2119: REQ-003.7.23 + XCTAssertEqual(appModel.activeConversationModel?.pendingSteering.map(\.prepared.summaryText), ["stay second", "stay third"]) + } + func testRunningChatDoesNotBlockSelectingAnotherChatOrNewChatSurface() throws { let appModel = testAppModel() let first = appModel.projects[0].sessions[0] @@ -37,6 +67,8 @@ final class ParallelRuntimeTests: XCTestCase { appModel.select(sessionID: first.id, in: projectID) let firstModel = try XCTUnwrap(appModel.activeConversationModel) + firstModel.handleEventForTesting(try Self.event(type: "agent_start")) + let firstTranscriptBeforeOutput = firstModel.items appModel.select(sessionID: second.id, in: projectID) // 2119: REQ-003.4.5 @@ -44,10 +76,12 @@ final class ParallelRuntimeTests: XCTestCase { let updatedFirst = try XCTUnwrap(appModel.projects[0].sessions.first { $0.id == first.id }) let updatedSecond = try XCTUnwrap(appModel.projects[0].sessions.first { $0.id == second.id }) - XCTAssertTrue(updatedFirst.cachedTranscript.contains { item in - if case .assistantText(_, let text) = item { return text.contains("output for first only") } - return false - }) + XCTAssertEqual(updatedFirst.cachedTranscript.count, firstTranscriptBeforeOutput.count + 1) + XCTAssertEqual(Array(updatedFirst.cachedTranscript.dropLast()), firstTranscriptBeforeOutput) + guard case .assistantText(_, let appendedText) = updatedFirst.cachedTranscript.last else { + return XCTFail("Expected in-flight output to append as the final owning-chat item") + } + XCTAssertEqual(appendedText, "output for first only") XCTAssertFalse(updatedSecond.cachedTranscript.contains { item in if case .assistantText(_, let text) = item { return text.contains("output for first only") } return false @@ -143,14 +177,20 @@ final class ParallelRuntimeTests: XCTestCase { XCTAssertTrue(appModel.isConversationRunning(sessionID: second.id, projectID: projectID)) // 2119: REQ-003.5.4 - secondModel.stopActiveTurn() + XCTAssertTrue(appModel.activeConversationModel === secondModel) + appModel.activeConversationModel?.stopActiveTurn() XCTAssertTrue(appModel.isConversationRunning(sessionID: first.id, projectID: projectID)) XCTAssertFalse(appModel.isConversationRunning(sessionID: second.id, projectID: projectID)) } func testQuickChatCreationNavigationDraftsAndOutputAreIsolatedFromProjectChats() throws { + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "parallel response", 1) + setenv("PI_NATIVE_MOCK_RPC_RESPONSE_DELAY_MS", "2500", 1) + defer { + unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") + unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE_DELAY_MS") + } let appModel = testAppModel() - appModel.automaticallyStartsPendingRuntimes = false let projectID = appModel.projects[0].id let projectChat = appModel.projects[0].sessions[0] @@ -167,12 +207,16 @@ final class ParallelRuntimeTests: XCTestCase { appModel.select(sessionID: projectChat.id, in: projectID) let projectModel = try XCTUnwrap(appModel.activeConversationModel) + // 2119: REQ-003.6.4 + XCTAssertEqual(projectModel.draft, "") projectModel.draft = "project-only draft" appModel.startNewChat() appModel.sendNewChatPrompt(PreparedPrompt(message: "second quick plan", images: [], displayAttachments: [])) let secondQuickChat = try XCTUnwrap(appModel.standaloneSessions.first { $0.id != quickChat.id }) let secondQuickModel = try XCTUnwrap(appModel.activeConversationModel) + // 2119: REQ-003.6.4 + XCTAssertEqual(secondQuickModel.draft, "") secondQuickModel.draft = "second-quick-only draft" appModel.select(sessionID: quickChat.id, in: nil) @@ -208,15 +252,81 @@ final class ParallelRuntimeTests: XCTestCase { XCTAssertTrue(appModel.isConversationRunning(sessionID: quickChat.id, projectID: nil)) // 2119: REQ-003.6.6 + let quickTranscriptBeforeOutput = quickModel.items quickModel.handleEventForTesting(try Self.textDelta("quick output only")) let updatedQuick = try XCTUnwrap(appModel.standaloneSessions.first { $0.id == quickChat.id }) let updatedSecondQuick = try XCTUnwrap(appModel.standaloneSessions.first { $0.id == secondQuickChat.id }) let updatedProject = try XCTUnwrap(appModel.projects[0].sessions.first { $0.id == projectChat.id }) - XCTAssertTrue(updatedQuick.cachedTranscript.contains { item in + XCTAssertEqual(updatedQuick.cachedTranscript.count, quickTranscriptBeforeOutput.count + 1) + XCTAssertEqual(Array(updatedQuick.cachedTranscript.dropLast()), quickTranscriptBeforeOutput) + guard case .assistantText(_, let appendedText) = updatedQuick.cachedTranscript.last else { + return XCTFail("Expected Quick Chat output to append as the final transcript item") + } + XCTAssertEqual(appendedText, "quick output only") + XCTAssertFalse(updatedSecondQuick.cachedTranscript.contains { item in if case .assistantText(_, let text) = item { return text.contains("quick output only") } return false }) - XCTAssertFalse(updatedSecondQuick.cachedTranscript.contains { item in + XCTAssertFalse(updatedProject.cachedTranscript.contains { item in + if case .assistantText(_, let text) = item { return text.contains("quick output only") } + return false + }) + } + + func testQuickChatRPCOutputPersistsOnlyToQuickChatAfterNavigation() async throws { + unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") + let fixture = try QuickChatOutputRPCFixture() + defer { fixture.cleanup() } + let appModel = testAppModel { modelSettings in + let model = PiConversationModel( + piCommand: PiCommand(executable: fixture.executable.path, arguments: []), + modelSettings: modelSettings + ) + model.shouldStallRPCOverrideForTesting = false + return model + } + defer { appModel.stopAllRuntimes() } + let projectID = appModel.projects[0].id + let projectChat = appModel.projects[0].sessions[0] + + appModel.startNewChat() + appModel.sendNewChatPrompt(PreparedPrompt(message: "quick RPC output", images: [], displayAttachments: [])) + let quickChat = try XCTUnwrap(appModel.standaloneSessions.first) + let quickModel = try XCTUnwrap(appModel.activeConversationModel) + + var cancellables: Set = [] + if !quickModel.isRunning { + let running = expectation(description: "Quick Chat RPC turn starts") + quickModel.$isRunning + .filter { $0 } + .prefix(1) + .sink { _ in running.fulfill() } + .store(in: &cancellables) + await fulfillment(of: [running], timeout: 3) + guard quickModel.isRunning else { return } + } + + appModel.select(sessionID: projectChat.id, in: projectID) + XCTAssertEqual(appModel.selectedSessionID, projectChat.id) + let outputPersisted = expectation(description: "Quick Chat RPC output is persisted") + appModel.$standaloneSessions + .filter { sessions in + sessions.first(where: { $0.id == quickChat.id })?.cachedTranscript.contains { item in + if case .assistantText(_, let text) = item { return text.contains("quick output only") } + return false + } == true + } + .prefix(1) + .sink { _ in outputPersisted.fulfill() } + .store(in: &cancellables) + + try fixture.releaseOutput() + await fulfillment(of: [outputPersisted], timeout: 3) + + // 2119: REQ-003.6.6 + let updatedQuick = try XCTUnwrap(appModel.standaloneSessions.first { $0.id == quickChat.id }) + let updatedProject = try XCTUnwrap(appModel.projects[0].sessions.first { $0.id == projectChat.id }) + XCTAssertTrue(updatedQuick.cachedTranscript.contains { item in if case .assistantText(_, let text) = item { return text.contains("quick output only") } return false }) @@ -224,6 +334,7 @@ final class ParallelRuntimeTests: XCTestCase { if case .assistantText(_, let text) = item { return text.contains("quick output only") } return false }) + XCTAssertTrue(appModel.activeConversationModel !== quickModel) } func testPromotingQuickChatArchivesSourceAndCleansRuntime() throws { @@ -291,8 +402,12 @@ final class ParallelRuntimeTests: XCTestCase { XCTAssertFalse(appModel.activeConversationIsRunning) } - private func testAppModel(projectCount: Int = 1, firstCachedTranscript: [TranscriptItem] = [.user(UserMessagePayload(text: "first seed"))]) -> AppModel { - let appModel = AppModel() + private func testAppModel( + projectCount: Int = 1, + firstCachedTranscript: [TranscriptItem] = [.user(UserMessagePayload(text: "first seed"))], + conversationModelFactory: ((ModelSettingsModel) -> PiConversationModel)? = nil + ) -> AppModel { + let appModel = AppModel(conversationModelFactory: conversationModelFactory) let first = Session( name: "first chat", status: .idle, @@ -340,8 +455,79 @@ final class ParallelRuntimeTests: XCTestCase { ]) } + private static func userMessageStart(_ text: String) throws -> RPCEnvelope { + try envelope([ + "type": .string("message_start"), + "message": .object([ + "role": .string("user"), + "content": .array([.object(["type": .string("text"), "text": .string(text)])]) + ]) + ]) + } + private static func envelope(_ raw: [String: JSONValue]) throws -> RPCEnvelope { let data = try JSONEncoder().encode(JSONValue.object(raw)) return try JSONDecoder().decode(RPCEnvelope.self, from: data) } } + +private struct QuickChatOutputRPCFixture { + let directory: URL + let executable: URL + let outputGate: URL + + init() throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("PiNativeQuickChatOutput-\(UUID().uuidString)", isDirectory: true) + executable = directory.appendingPathComponent("fake-pi-rpc.sh") + outputGate = directory.appendingPathComponent("output-gate") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + guard mkfifo(outputGate.path, 0o600) == 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + try Self.script(outputGate: outputGate).write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + } + + func releaseOutput() throws { + let handle = try FileHandle(forWritingTo: outputGate) + try handle.write(contentsOf: Data("release\n".utf8)) + try handle.close() + } + + func cleanup() { + try? FileManager.default.removeItem(at: directory) + } + + private static func script(outputGate: URL) -> String { + """ + #!/bin/sh + output_gate='\(outputGate.path)' + while IFS= read -r line; do + id=$(printf '%s' "$line" | sed -E 's/.*"id":([0-9]+).*/\\1/') + case "$line" in + *'"type":"prompt"'*) + exec 3<> "$output_gate" + printf '%s\n' '{"type":"agent_start"}' + printf '{"id":%s,"type":"response","success":true,"data":{}}\n' "$id" + IFS= read -r _ <&3 + exec 3>&- + printf '%s\n' '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"quick output only"}}' + ;; + *'"type":"get_state"'*) + printf '{"id":%s,"type":"response","success":true,"data":{"model":{"provider":"test","id":"selected","name":"Selected"},"thinkingLevel":"medium"}}\n' "$id" + ;; + *'"type":"get_available_models"'*) + printf '{"id":%s,"type":"response","success":true,"data":{"models":[{"provider":"test","id":"selected","name":"Selected"}]}}\n' "$id" + ;; + *'"type":"get_available_thinking_levels"'*) + printf '{"id":%s,"type":"response","success":true,"data":{"levels":["low","medium","high"]}}\n' "$id" + ;; + *) + printf '{"id":%s,"type":"response","success":true,"data":{}}\n' "$id" + ;; + esac + done + """ + } +} diff --git a/PiNativeTests/SteeringDeliveryDeduplicationTests.swift b/PiNativeTests/SteeringDeliveryDeduplicationTests.swift new file mode 100644 index 0000000..7ae8eef --- /dev/null +++ b/PiNativeTests/SteeringDeliveryDeduplicationTests.swift @@ -0,0 +1,148 @@ +import XCTest +@testable import PiNative + +@MainActor +final class SteeringDeliveryDeduplicationTests: XCTestCase { + func testMatchingSteeringDeliveryInOneChatCannotMutateAnotherChat() throws { + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "later response", 1) + defer { unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") } + let first = readyModel() + let second = readyModel() + first.handleEventForTesting(try envelope(["type": .string("agent_start")])) + second.handleEventForTesting(try envelope(["type": .string("agent_start")])) + first.draft = "identical steering text" + second.draft = "identical steering text" + first.sendDraft() + second.sendDraft() + let secondItemsBeforeDelivery = second.items + + // 2119: REQ-003.7.21 + first.handleEventForTesting(try userDelivery("identical steering text")) + + XCTAssertTrue(first.pendingSteering.isEmpty) + XCTAssertEqual(second.pendingSteering.map(\.prepared.summaryText), ["identical steering text"]) + XCTAssertEqual(second.items, secondItemsBeforeDelivery) + } + + func testLateAcknowledgementsCannotCreateAnyDuplicateSubmissionOrDelivery() async throws { + let model = PiConversationModel() + model.mockResponseOverrideForTesting = "bootstrap" + model.currentModel = PiModelOption(provider: "test", id: "selected", name: "Selected") + model.currentThinkingLevel = .medium + model.start(workingDirectory: nil, sessionPath: nil) + model.mockResponseOverrideForTesting = nil + model.handleEventForTesting(try envelope(["type": .string("agent_start")])) + var submissions: [String] = [] + model.onSteeringRPCForTesting = { _ in submissions.append("steer") } + model.onPromptRPCForTesting = { _ in submissions.append("prompt") } + model.draft = "ack race without duplicates" + model.sendDraft() + model.mockResponseOverrideForTesting = "replacement response" + + // 2119: REQ-003.7.19 + model.stopActiveTurn() + model.handleEventForTesting(try userDelivery("server-expanded late acknowledgement before replay")) + try await Task.sleep(nanoseconds: 60_000_000) + model.handleEventForTesting(try userDelivery("different server-expanded late acknowledgement after replay")) + + XCTAssertEqual(submissions, ["prompt"]) + XCTAssertEqual(model.items.filter { item in + if case .user = item { return true } + return false + }.count, 1) + XCTAssertTrue(model.pendingSteering.isEmpty) + } + + func testAcceptedSteeringReplayStartsOnlyAfterStopCompletes() async throws { + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "later response", 1) + defer { unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") } + let model = PiConversationModel() + model.currentModel = PiModelOption(provider: "test", id: "selected", name: "Selected") + model.currentThinkingLevel = .medium + model.start(workingDirectory: nil, sessionPath: nil) + model.handleEventForTesting(try envelope(["type": .string("agent_start")])) + model.draft = "replay after stop" + model.sendDraft() + XCTAssertEqual(model.pendingSteering.count, 1) + + var lifecycle: [String] = [] + model.onStopCompletionForTesting = { + lifecycle.append("stop-completed") + } + model.onPromptRPCForTesting = { _ in + lifecycle.append("replay-submitted") + } + + // 2119: REQ-003.7.16 + model.stopActiveTurn() + try await Task.sleep(nanoseconds: 60_000_000) + + XCTAssertEqual(lifecycle, ["stop-completed", "replay-submitted"]) + XCTAssertTrue(model.items.contains { item in + if case .user(_, let payload) = item { + return payload.text == "replay after stop" + } + return false + }) + } + + func testRepeatedDeliveryEventCreatesOneHistoryEntryForOneAcceptedSteeringMessage() throws { + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "later response", 1) + defer { unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") } + let model = PiConversationModel() + model.currentModel = PiModelOption(provider: "test", id: "selected", name: "Selected") + model.currentThinkingLevel = .medium + model.start(workingDirectory: nil, sessionPath: nil) + model.handleEventForTesting(try envelope(["type": .string("agent_start")])) + model.draft = "deliver exactly once" + model.sendDraft() + XCTAssertEqual(model.pendingSteering.count, 1) + + let delivery = try envelope([ + "type": .string("message_start"), + "message": .object([ + "role": .string("user"), + "content": .array([.object([ + "type": .string("text"), + "text": .string("deliver exactly once") + ])]) + ]) + ]) + + // 2119: REQ-003.7.11 + model.handleEventForTesting(delivery) + model.handleEventForTesting(delivery) + + let deliveredCount = model.items.filter { item in + if case .user(_, let payload) = item { + return payload.text == "deliver exactly once" + } + return false + }.count + XCTAssertEqual(deliveredCount, 1) + XCTAssertTrue(model.pendingSteering.isEmpty) + } + + private func envelope(_ raw: [String: JSONValue]) throws -> RPCEnvelope { + let data = try JSONEncoder().encode(JSONValue.object(raw)) + return try JSONDecoder().decode(RPCEnvelope.self, from: data) + } + + private func readyModel() -> PiConversationModel { + let model = PiConversationModel() + model.currentModel = PiModelOption(provider: "test", id: "selected", name: "Selected") + model.currentThinkingLevel = .medium + model.start(workingDirectory: nil, sessionPath: nil) + return model + } + + private func userDelivery(_ text: String) throws -> RPCEnvelope { + try envelope([ + "type": .string("message_start"), + "message": .object([ + "role": .string("user"), + "content": .array([.object(["type": .string("text"), "text": .string(text)])]) + ]) + ]) + } +} diff --git a/PiNativeTests/SteeringReplayBoundaryTests.swift b/PiNativeTests/SteeringReplayBoundaryTests.swift new file mode 100644 index 0000000..8baab1b --- /dev/null +++ b/PiNativeTests/SteeringReplayBoundaryTests.swift @@ -0,0 +1,240 @@ +import XCTest +@testable import PiNative + +@MainActor +final class SteeringReplayBoundaryTests: XCTestCase { + func testStopDoesNotReplayAcceptedSteeringThatWasAlreadyDelivered() async throws { + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "later response", 1) + defer { unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") } + let model = PiConversationModel() + model.currentModel = PiModelOption(provider: "test", id: "selected", name: "Selected") + model.currentThinkingLevel = .medium + model.start(workingDirectory: nil, sessionPath: nil) + model.handleEventForTesting(try event(type: "agent_start")) + model.draft = "already delivered steering" + model.sendDraft() + XCTAssertEqual(model.pendingSteering.count, 1) + model.handleEventForTesting(try userDelivery("already delivered steering")) + XCTAssertTrue(model.pendingSteering.isEmpty) + var replayedPrompts: [String] = [] + model.onPromptRPCForTesting = { replayedPrompts.append($0) } + + // 2119: REQ-003.7.16 + model.stopActiveTurn() + try await Task.sleep(nanoseconds: 60_000_000) + + XCTAssertTrue(replayedPrompts.isEmpty) + XCTAssertEqual(model.items.filter { item in + if case .user(_, let payload) = item { + return payload.text == "already delivered steering" + } + return false + }.count, 1) + } + + func testStoppingPendingPromptWithoutClientDoesNotReportSteeringFailure() async throws { + setenv("PI_NATIVE_TEST_RPC_STALL", "1", 1) + defer { unsetenv("PI_NATIVE_TEST_RPC_STALL") } + let model = PiConversationModel() + model.currentModel = PiModelOption(provider: "test", id: "selected", name: "Selected") + model.currentThinkingLevel = .medium + model.start(workingDirectory: nil, sessionPath: nil) + model.draft = "pending initial prompt" + model.sendDraft() + XCTAssertTrue(model.hasPendingPrompt) + XCTAssertTrue(model.pendingSteering.isEmpty) + var stopCompleted = false + model.onStopCompletionForTesting = { stopCompleted = true } + + model.stopActiveTurn() + try await waitForSteeringCondition { stopCompleted } + + XCTAssertFalse(model.items.contains { item in + guard case .notice(_, let text) = item else { return false } + return text.contains("resume steering") + }) + } + + func testRealProcessReplayIsRunningBeforePromptSubmission() async throws { + unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") + let fixture = try ReplayRPCFixture() + defer { fixture.cleanup() } + let model = fixture.makeModel() + model.start(workingDirectory: fixture.directory.path, sessionPath: nil) + model.draft = "original real-process turn" + model.sendDraft() + try await waitForSteeringCondition { model.isRunning } + + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "accept steering synchronously", 1) + model.draft = "replay through real process" + model.sendDraft() + unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") + XCTAssertEqual(model.pendingSteering.first?.state, .accepted) + + var wasRunningWhenReplaySubmitted: Bool? + model.onPromptRPCForTesting = { _ in + wasRunningWhenReplaySubmitted = model.isRunning + } + + // 2119: REQ-003.7.16 + model.stopActiveTurn() + try await waitForSteeringCondition { wasRunningWhenReplaySubmitted != nil } + + XCTAssertEqual(wasRunningWhenReplaySubmitted, true) + } + + func testReplacementProcessSpawnFailureMakesReplayingSteeringRetryable() async throws { + unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") + let fixture = try ReplayRPCFixture() + defer { fixture.cleanup() } + let model = fixture.makeModel() + model.start(workingDirectory: fixture.directory.path, sessionPath: nil) + model.draft = "original real-process turn" + model.sendDraft() + try await waitForSteeringCondition { model.isRunning } + + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "accept steering synchronously", 1) + model.draft = "retain after spawn failure" + model.sendDraft() + unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") + XCTAssertEqual(model.pendingSteering.first?.state, .accepted) + try FileManager.default.removeItem(at: fixture.executable) + + // 2119: REQ-003.7.22 + model.stopActiveTurn() + try await waitForSteeringCondition { + model.pendingSteering.first?.state == .failed && model.isCatastrophicRPCFailure + } + + XCTAssertEqual(model.pendingSteering.map(\.prepared.summaryText), ["retain after spawn failure"]) + XCTAssertTrue(model.sessionLoadNotice?.contains("Failed to start pi") == true) + } + + func testReplacementSessionFailureMakesReplayingSteeringRetryable() async throws { + unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") + let fixture = try ReplayRPCFixture() + defer { fixture.cleanup() } + let model = fixture.makeModel() + model.start(workingDirectory: fixture.directory.path, sessionPath: nil) + model.draft = "original real-process turn" + model.sendDraft() + try await waitForSteeringCondition { model.isRunning } + + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "accept steering synchronously", 1) + model.draft = "retain after restart failure" + model.sendDraft() + unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") + XCTAssertEqual(model.pendingSteering.first?.state, .accepted) + try Data().write(to: fixture.failReplacementMarker) + + // 2119: REQ-003.7.22 + model.stopActiveTurn() + try await waitForSteeringCondition { + model.pendingSteering.first?.state == .failed && model.isCatastrophicRPCFailure + } + + XCTAssertEqual(model.pendingSteering.map(\.prepared.summaryText), ["retain after restart failure"]) + XCTAssertTrue(model.sessionLoadNotice?.contains("Failed to load session") == true) + } + + private func event(type: String) throws -> RPCEnvelope { + try envelope(["type": .string(type)]) + } + + private func userDelivery(_ text: String) throws -> RPCEnvelope { + try envelope([ + "type": .string("message_start"), + "message": .object([ + "role": .string("user"), + "content": .array([.object(["type": .string("text"), "text": .string(text)])]) + ]) + ]) + } + + private func envelope(_ raw: [String: JSONValue]) throws -> RPCEnvelope { + let data = try JSONEncoder().encode(JSONValue.object(raw)) + return try JSONDecoder().decode(RPCEnvelope.self, from: data) + } +} + +private struct ReplayRPCFixture { + let directory: URL + let executable: URL + let launchCountFile: URL + let failReplacementMarker: URL + + init() throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("PiNativeSteeringReplay-\(UUID().uuidString)", isDirectory: true) + executable = directory.appendingPathComponent("fake-pi-rpc.sh") + launchCountFile = directory.appendingPathComponent("launch-count.txt") + failReplacementMarker = directory.appendingPathComponent("fail-replacements") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Self.script(launchCountFile: launchCountFile, failReplacementMarker: failReplacementMarker) + .write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + } + + @MainActor + func makeModel() -> PiConversationModel { + let model = PiConversationModel(piCommand: PiCommand(executable: executable.path, arguments: [])) + model.currentModel = PiModelOption(provider: "test", id: "selected", name: "Selected") + model.currentThinkingLevel = .medium + return model + } + + func cleanup() { + unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") + try? FileManager.default.removeItem(at: directory) + } + + private static func script(launchCountFile: URL, failReplacementMarker: URL) -> String { + """ + #!/bin/sh + count_file='\(launchCountFile.path)' + fail_marker='\(failReplacementMarker.path)' + launch_count=0 + [ -f "$count_file" ] && launch_count=$(cat "$count_file") + launch_count=$((launch_count + 1)) + printf '%s' "$launch_count" > "$count_file" + if [ "$launch_count" -gt 1 ] && [ -f "$fail_marker" ]; then + rm -f "$0" + exit 17 + fi + while IFS= read -r line; do + id=$(printf '%s' "$line" | sed -E 's/.*"id":([0-9]+).*/\\1/') + case "$line" in + *'"type":"prompt"'*) + printf '%s\n' '{"type":"agent_start"}' + printf '{"id":%s,"type":"response","success":true,"data":{}}\n' "$id" + ;; + *'"type":"get_state"'*) + printf '{"id":%s,"type":"response","success":true,"data":{"model":{"provider":"test","id":"selected","name":"Selected"},"thinkingLevel":"medium"}}\n' "$id" + ;; + *'"type":"get_available_models"'*) + printf '{"id":%s,"type":"response","success":true,"data":{"models":[{"provider":"test","id":"selected","name":"Selected"}]}}\n' "$id" + ;; + *'"type":"get_available_thinking_levels"'*) + printf '{"id":%s,"type":"response","success":true,"data":{"levels":["low","medium","high"]}}\n' "$id" + ;; + *) + printf '{"id":%s,"type":"response","success":true,"data":{}}\n' "$id" + ;; + esac + done + """ + } +} + +@MainActor +private func waitForSteeringCondition( + timeout: TimeInterval = 3, + condition: @escaping @MainActor () -> Bool +) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { return } + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTFail("Timed out waiting for steering condition") +} diff --git a/PiNativeTests/StopButtonTests.swift b/PiNativeTests/StopButtonTests.swift index 31ef968..6029d6e 100644 --- a/PiNativeTests/StopButtonTests.swift +++ b/PiNativeTests/StopButtonTests.swift @@ -3,6 +3,342 @@ import XCTest @MainActor final class StopButtonTests: XCTestCase { + func testActiveTurnSubmissionCreatesPendingSteeringInsteadOfUserHistory() throws { + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "later response", 1) + defer { unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") } + let model = steeringReadyModel() + model.handleEventForTesting(try Self.event(type: "agent_start")) + model.draft = " MiXeD spacing\nnext café 👩🏽‍💻 " + + // 2119: REQ-003.7.1 + // 2119: REQ-003.7.2 + // 2119: REQ-003.7.3 + // 2119: REQ-003.7.5 + model.sendDraft() + + XCTAssertTrue(model.isRunning) + XCTAssertEqual(model.pendingSteering.map(\.prepared.summaryText), ["MiXeD spacing\nnext café 👩🏽‍💻"]) + XCTAssertEqual(model.pendingSteering.first?.state, .accepted) + XCTAssertFalse(model.items.contains { item in + if case .user(_, let payload) = item { return payload.text == "MiXeD spacing\nnext café 👩🏽‍💻" } + return false + }) + } + + func testAttachmentOnlySteeringPreservesDisplayMetadataAndRPCPayload() throws { + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "later response", 1) + defer { unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") } + let model = steeringReadyModel() + model.handleEventForTesting(try Self.event(type: "agent_start")) + let image = ComposerAttachment(kind: .image(ImageAttachment( + data: Data([1, 2, 3]), mimeType: "image/png", displayName: "direction.png", + sourceURL: nil, pixelWidth: 1, pixelHeight: 1 + ))) + model.addDraftAttachments([image]) + + // 2119: REQ-003.7.6 + // 2119: REQ-003.7.7 + model.sendDraft() + + let pending = try XCTUnwrap(model.pendingSteering.first) + XCTAssertEqual(pending.prepared.displayAttachments, [image]) + XCTAssertEqual( + pending.prepared.displayAttachments, + UserMessagePayload(text: pending.prepared.summaryText, attachments: [image]).attachments + ) + XCTAssertEqual(pending.prepared.images.first?.data, Data([1, 2, 3]).base64EncodedString()) + XCTAssertTrue(model.draftAttachments.isEmpty) + + let fields = PiRPCClient.steerFields(message: pending.prepared.message, images: pending.prepared.images) + XCTAssertEqual(fields["message"]?.stringValue, pending.prepared.message) + XCTAssertEqual(fields["images"]?.arrayValue?.count, 1) + } + + func testEmptyActiveTurnSubmissionDoesNotCreateSteering() throws { + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "later response", 1) + defer { unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") } + let model = steeringReadyModel() + model.handleEventForTesting(try Self.event(type: "agent_start")) + model.sendDraft() + XCTAssertTrue(model.pendingSteering.isEmpty) + model.draft = " \n" + + // 2119: REQ-003.7.8 + model.sendDraft() + + XCTAssertTrue(model.pendingSteering.isEmpty) + } + + func testAcceptedSteeringAppearsInConversationHistoryInUserOrder() throws { + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "later response", 1) + defer { unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") } + let model = steeringReadyModel() + model.handleEventForTesting(try Self.event(type: "agent_start")) + model.draft = "first distinct direction" + model.sendDraft() + model.draft = "second distinct direction" + model.sendDraft() + + // 2119: REQ-003.7.9 + model.handleEventForTesting(try Self.userMessageStart("second server acknowledgement arrives first")) + model.handleEventForTesting(try Self.userMessageStart("first server acknowledgement arrives second")) + + let deliveredUserMessages = model.items.compactMap { item -> String? in + guard case .user(_, let payload) = item else { return nil } + return payload.text + } + XCTAssertEqual(deliveredUserMessages, ["first distinct direction", "second distinct direction"]) + } + + func testPiUserEventPromotesOldestSteeringExactlyOnce() throws { + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "later response", 1) + defer { unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") } + let model = steeringReadyModel() + let otherModel = steeringReadyModel() + model.handleEventForTesting(try Self.event(type: "agent_start")) + model.draft = "first duplicate" + model.sendDraft() + model.draft = "first duplicate" + model.sendDraft() + + let delivery = try Self.userMessageStart("expanded server-side text") + // 2119: REQ-003.7.10 + // 2119: REQ-003.7.11 + model.handleEventForTesting(delivery) + + XCTAssertEqual(model.pendingSteering.count, 1) + XCTAssertEqual(model.pendingSteering.first?.prepared.summaryText, "first duplicate") + XCTAssertEqual(model.items.filter { item in + if case .user(_, let payload) = item { return payload.text == "first duplicate" } + return false + }.count, 1) + + model.handleEventForTesting(try Self.event(type: "message_end")) + model.handleEventForTesting(delivery) + model.handleEventForTesting(try Self.event(type: "message_end")) + XCTAssertTrue(model.pendingSteering.isEmpty) + XCTAssertEqual(model.items.filter { if case .user = $0 { return true }; return false }.count, 2) + XCTAssertEqual(model.items.filter { item in + if case .user(_, let payload) = item { return payload.text == "first duplicate" } + return false + }.count, 2) + XCTAssertFalse(otherModel.items.contains { if case .user = $0 { return true }; return false }) + } + + func testOriginalPromptUserEventCannotPrematurelyDeliverQueuedSteering() throws { + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "later response", 1) + defer { unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") } + let model = steeringReadyModel() + model.draft = "original prompt" + model.sendDraft() + model.draft = "steer after original" + model.sendDraft() + + // The original prompt's user event may arrive after steering was + // accepted; it is not the steering-delivery boundary. + // 2119: REQ-003.7.10 + // 2119: REQ-003.7.11 + model.handleEventForTesting(try Self.userMessageStart("original prompt")) + XCTAssertEqual(model.pendingSteering.map(\.prepared.summaryText), ["steer after original"]) + XCTAssertEqual(model.items.filter { item in + if case .user(_, let payload) = item { return payload.text == "steer after original" } + return false + }.count, 0) + + model.handleEventForTesting(try Self.userMessageStart("steer after original")) + XCTAssertTrue(model.pendingSteering.isEmpty) + XCTAssertEqual(model.items.filter { item in + if case .user(_, let payload) = item { return payload.text == "steer after original" } + return false + }.count, 1) + } + + func testRejectedSteeringRestoresOriginalContentWithoutOverwritingNewDraft() async throws { + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "bootstrap", 1) + let model = steeringReadyModel() + let otherModel = steeringReadyModel() + otherModel.draft = "other chat draft" + unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") + model.handleEventForTesting(try Self.event(type: "agent_start")) + model.draft = "rejected direction" + model.sendDraft() + model.draft = "newer draft" + + // 2119: REQ-003.7.12 + // 2119: REQ-003.7.14 + try await Task.sleep(nanoseconds: 80_000_000) + + XCTAssertTrue(model.pendingSteering.isEmpty) + XCTAssertEqual(model.draft, "rejected direction\n\nnewer draft") + XCTAssertEqual(otherModel.draft, "other chat draft") + } + + func testRejectedSteeringRestoresAttachments() async throws { + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "bootstrap", 1) + let model = steeringReadyModel() + let otherModel = steeringReadyModel() + let otherAttachment = ComposerAttachment(kind: .fileReference(FileReferenceAttachment( + url: URL(fileURLWithPath: "/tmp/other.txt"), displayName: "other.txt", fileSize: 12 + ))) + otherModel.addDraftAttachments([otherAttachment]) + unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") + model.handleEventForTesting(try Self.event(type: "agent_start")) + let file = ComposerAttachment(kind: .fileReference(FileReferenceAttachment( + url: URL(fileURLWithPath: "/tmp/rejected.txt"), + displayName: "rejected.txt", + fileSize: nil + ))) + let secondFile = ComposerAttachment(kind: .fileReference(FileReferenceAttachment( + url: URL(fileURLWithPath: "/tmp/rejected-two.txt"), + displayName: "rejected-two.txt", + fileSize: 42 + ))) + model.addDraftAttachments([file, secondFile]) + model.sendDraft() + + // 2119: REQ-003.7.13 + try await Task.sleep(nanoseconds: 80_000_000) + + XCTAssertTrue(model.pendingSteering.isEmpty) + XCTAssertEqual(model.draftAttachments, [file, secondFile]) + XCTAssertEqual(otherModel.draftAttachments, [otherAttachment]) + } + + func testStopReplaysFirstSteeringAndRetainsRemainingOrder() async throws { + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "old stopped response", 1) + setenv("PI_NATIVE_MOCK_RPC_RESPONSE_DELAY_MS", "200", 1) + defer { + unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") + unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE_DELAY_MS") + } + let model = steeringReadyModel() + model.draft = "active request" + model.sendDraft() + XCTAssertTrue(model.isRunning) + model.draft = "first after stop" + model.sendDraft() + model.draft = "second after stop" + model.sendDraft() + var replayOrder: [String] = [] + var abortCount = 0 + model.onPromptRPCForTesting = { replayOrder.append("prompt:\($0)") } + model.onSteeringRPCForTesting = { replayOrder.append("steer:\($0)") } + model.onAbortRPCForTesting = { abortCount += 1 } + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "replacement response", 1) + + // 2119: REQ-003.7.15 + // 2119: REQ-003.7.16 + // 2119: REQ-003.7.17 + model.stopActiveTurn() + XCTAssertFalse(model.isRunning) + try await Task.sleep(nanoseconds: 40_000_000) + + XCTAssertEqual(abortCount, 1) + XCTAssertEqual(replayOrder, ["prompt:first after stop", "steer:second after stop"]) + XCTAssertTrue(model.items.contains { item in + if case .user(_, let payload) = item { return payload.text == "first after stop" } + return false + }) + XCTAssertEqual(model.pendingSteering.map(\.prepared.summaryText), ["second after stop"]) + try await Task.sleep(nanoseconds: 220_000_000) + XCTAssertFalse(model.items.contains { item in + if case .assistantText(_, let text) = item { return text.contains("old stopped response") } + return false + }) + } + + func testStopKeepsUnacknowledgedSteeringUntilExactlyOnceReplayDisposition() async throws { + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "bootstrap", 1) + defer { unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") } + let model = steeringReadyModel() + unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") + model.handleEventForTesting(try Self.event(type: "agent_start")) + model.draft = "acknowledgement race" + model.sendDraft() + var replayedPrompts: [String] = [] + model.onPromptRPCForTesting = { replayedPrompts.append($0) } + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "replacement response", 1) + + // 2119: REQ-003.7.18 + // 2119: REQ-003.7.19 + model.stopActiveTurn() + model.handleEventForTesting(try Self.userMessageStart("late old-process delivery")) + + XCTAssertEqual(model.pendingSteering.map(\.prepared.summaryText), ["acknowledgement race"]) + XCTAssertFalse(model.items.contains { item in + if case .user(_, let payload) = item { return payload.text.contains("acknowledgement race") } + return false + }) + + try await Task.sleep(nanoseconds: 60_000_000) + XCTAssertTrue(model.pendingSteering.isEmpty) + XCTAssertEqual(replayedPrompts, ["acknowledgement race"]) + XCTAssertEqual(model.items.filter { item in + if case .user(_, let payload) = item { return payload.text == "acknowledgement race" } + return false + }.count, 1) + + model.handleEventForTesting(try Self.userMessageStart("late acceptance after replay")) + XCTAssertEqual(replayedPrompts, ["acknowledgement race"]) + XCTAssertEqual(model.items.filter { item in + if case .user(_, let payload) = item { return payload.text == "acknowledgement race" } + return false + }.count, 1) + } + + func testSteeringQueuesRemainIsolatedPerConversation() throws { + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "later response", 1) + defer { unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") } + let first = steeringReadyModel() + let second = steeringReadyModel() + first.handleEventForTesting(try Self.event(type: "agent_start")) + second.handleEventForTesting(try Self.event(type: "agent_start")) + first.draft = "only first" + let firstItemsBefore = first.items + let secondItemsBefore = second.items + + // 2119: REQ-003.7.20 + // 2119: REQ-003.7.21 + first.sendDraft() + + XCTAssertEqual(first.pendingSteering.map(\.prepared.summaryText), ["only first"]) + XCTAssertTrue(second.pendingSteering.isEmpty) + second.handleEventForTesting(try Self.userMessageStart("unrelated")) + XCTAssertEqual(first.pendingSteering.map(\.prepared.summaryText), ["only first"]) + XCTAssertEqual(first.items, firstItemsBefore) + XCTAssertEqual(second.items, secondItemsBefore) + } + + func testFailedSteeringEntryRemainsCompleteAndRetryable() async throws { + setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "bootstrap", 1) + let model = steeringReadyModel() + unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") + model.handleEventForTesting(try Self.event(type: "agent_start")) + model.draft = "retain me" + let attachment = ComposerAttachment(kind: .fileReference(FileReferenceAttachment( + url: URL(fileURLWithPath: "/tmp/retain-me.txt"), displayName: "retain-me.txt", fileSize: 99 + ))) + model.addDraftAttachments([attachment]) + model.sendDraft() + model.stopActiveTurn() + + // 2119: REQ-003.7.22 + try await Task.sleep(nanoseconds: 40_000_000) + let retained = try XCTUnwrap(model.pendingSteering.first) + XCTAssertEqual(retained.state, .failed) + XCTAssertEqual(retained.prepared.summaryText, "retain me") + XCTAssertEqual(retained.composerText, "retain me") + XCTAssertEqual(retained.composerAttachments, [attachment]) + XCTAssertEqual(retained.prepared.displayAttachments, [attachment]) + + model.retrySteering(retained.id) + try await Task.sleep(nanoseconds: 20_000_000) + let retainedAfterRetry = try XCTUnwrap(model.pendingSteering.first) + XCTAssertEqual(retainedAfterRetry.id, retained.id) + XCTAssertEqual(retainedAfterRetry.state, .failed) + XCTAssertEqual(retainedAfterRetry.composerAttachments, [attachment]) + } + func testSendingWhileSessionLoadsShowsUserMessageImmediately() throws { let model = PiConversationModel() model.currentModel = PiModelOption(provider: "test", id: "selected", name: "Selected") @@ -199,6 +535,27 @@ final class StopButtonTests: XCTestCase { XCTAssertFalse(model.isRunning) } + private func steeringReadyModel() -> PiConversationModel { + let model = PiConversationModel() + model.currentModel = PiModelOption(provider: "test", id: "selected", name: "Selected") + model.currentThinkingLevel = .medium + model.start(workingDirectory: nil, sessionPath: nil) + return model + } + + private static func event(type: String) throws -> RPCEnvelope { + try RPCEnvelope.testEnvelope(["type": .string(type)]) + } + + private static func userMessageStart(_ text: String) throws -> RPCEnvelope { + try RPCEnvelope.testEnvelope([ + "type": .string("message_start"), + "message": .object([ + "role": .string("user"), + "content": .array([.object(["type": .string("text"), "text": .string(text)])]) + ]) + ]) + } // 2119: REQ-003.5.2 func testRealRPCProcessLateStoppedOutputIsSuppressedAfterLaterTurnStarts() async throws { unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") diff --git a/docs/code-architecture-walkthrough/05-turn-lifecycle.md b/docs/code-architecture-walkthrough/05-turn-lifecycle.md index a7a3e21..fece112 100644 --- a/docs/code-architecture-walkthrough/05-turn-lifecycle.md +++ b/docs/code-architecture-walkthrough/05-turn-lifecycle.md @@ -95,6 +95,12 @@ stdin command #42 ─────► Pi - Streaming text arrives through `message_update` events and mutates one stable assistant transcript item incrementally. - Tools can interleave with text and trigger additional provider turns before the final `agent_end`. +## Steering an active turn + +When Enter is pressed while `isRunning` is true, `sendDraft()` retains the full prepared payload in the conversation's ordered `pendingSteering` queue and sends Pi's `steer` RPC command instead of appending a delivered user transcript item. The view renders those entries inline after delivered chat content as subdued gray `Steering:` rows. Pi decides the next model-turn boundary and configured one-at-a-time/all delivery behavior. + +When Pi emits `message_start` for the queued user input, the model removes the oldest pending entry and appends exactly one normal user transcript item using the locally retained display payload. Steering RPC calls are serialized, and generation checks prevent an acknowledgement from an obsolete process from changing a replacement runtime's queue. + ## First message and restored-chat variations - **Brand-new chat:** `AppModel` creates a local `Session` with `pendingInitialPrompt`; the model starts Pi, sends `new_session`, asks `get_state` for the resolved session file, then flushes the prompt. @@ -108,4 +114,5 @@ stdin command #42 ─────► Pi - Process-exited/not-running failures get one client restart plus session rehydrate attempt. - Catastrophic startup/session failure becomes visible chat status and disables the composer; transient notices are not persisted. - Stop clears selected-chat UI state immediately, sends Pi's real `abort` command, suppresses late events from that turn, and restarts the client after a short abort window. +- If steering is pending, Stop retains it across replacement: the first entry becomes the replacement prompt and the remaining entries are re-enqueued with `steer` in original order. Failed replay remains visible and retryable rather than silently losing input. - Because every chat has its own model/client/process, stopping one chat does not interrupt another. diff --git a/docs/code-architecture-walkthrough/06-transcript-and-composer.md b/docs/code-architecture-walkthrough/06-transcript-and-composer.md index d1c5800..1b07dee 100644 --- a/docs/code-architecture-walkthrough/06-transcript-and-composer.md +++ b/docs/code-architecture-walkthrough/06-transcript-and-composer.md @@ -9,6 +9,8 @@ - `activity(ActivityGroup)` — one or more correlated tool calls. - `notice` — loading, failure, compaction, Stop, and extension feedback. +Pending steering is intentionally not a fifth persisted `TranscriptItem`: it is transient per-conversation runtime state until Pi represents it as delivered user input. It is nevertheless rendered inline at the bottom of the chronological chat flow, matching Pi's gray `Steering:` treatment rather than a delivered user bubble. + ## Live and historical reconstruction - Live `text_delta` events append to the current assistant buffer. @@ -25,3 +27,4 @@ - Recalled prompts copy both text and ordered attachments into the draft. A user edit ends history browsing, and changing the selected chat resets the browsing position so navigation state cannot cross chat boundaries. - Model and effort are loaded and changed with real Pi RPC commands. - The composer remains editable while session hydration is pending; only catastrophic Pi startup/session failure disables it. +- Enter during active work queues text and attachments as steering while the Stop control remains available. Rejected steering is restored ahead of any newer draft content, and pending entries remain owned by their chat across navigation. diff --git a/docs/native-shell-architecture.md b/docs/native-shell-architecture.md index 34a02d7..618f833 100644 --- a/docs/native-shell-architecture.md +++ b/docs/native-shell-architecture.md @@ -103,6 +103,13 @@ switching right-pane modes away and back. `PiConversationModel.items: [TranscriptItem]` — `.user`, `.assistantText`, `.activity(ActivityGroup)`, `.notice`. +`PiConversationModel.pendingSteering: [SteeringMessage]` is ordered, transient +runtime state for active-turn input. It retains the prepared RPC payload and +composer/display attachment metadata, but is not persisted as delivered +history. Pi `message_start` promotes the oldest queued entry into one `.user` +item. Stop preserves this queue while replacing the selected runtime, uses the +first entry as the new prompt, and sends the remainder back through `steer`. + ```swift struct ActivityGroup: Identifiable, Hashable { var id: UUID diff --git a/specs/REQ-003-conversation-navigation-and-active-work.md b/specs/REQ-003-conversation-navigation-and-active-work.md index 5bad74c..d90cd6d 100644 --- a/specs/REQ-003-conversation-navigation-and-active-work.md +++ b/specs/REQ-003-conversation-navigation-and-active-work.md @@ -2,7 +2,7 @@ ## Overview -PiNative conversation navigation must feel stable and native even while Pi work is active. Users should be able to move between projects and chats predictably, return to existing transcripts without blank states or unexpected scroll resets, and interrupt active work when needed. +PiNative conversation navigation must feel stable and native even while Pi work is active. Users should be able to move between projects and chats predictably, return to existing transcripts without blank states or unexpected scroll resets, steer active work without losing messages, and interrupt active work when needed. This spec captures the next testing focus for the shell and conversation lifecycle. It intentionally states observable outcomes rather than prescribing whether the implementation uses one conversation model, per-chat models, process isolation, cached transcripts, or another mechanism. @@ -54,6 +54,32 @@ This spec captures the next testing focus for the shell and conversation lifecyc 5. In-flight work in a Quick Chat MUST NOT prevent selecting or starting work in a project chat. 6. Output produced by in-flight work in a Quick Chat MUST append only to that Quick Chat's transcript. +### REQ-003.7: Steering active work + +1. When the selected chat has an active turn, submitting composer content that would be valid for a new turn MUST create a pending steering message for that chat. +2. Submitting a pending steering message MUST leave the current turn active. +3. A pending steering message MUST appear inline after the latest delivered conversation item. [manual] +4. A pending steering message MUST be visually distinguishable from a delivered user message. [manual] +5. A pending steering message MUST display the prepared user text without further modification. [manual] +6. A pending steering message with attachments MUST display the same attachment metadata shown for newly submitted user input. [manual] +7. An attachment-only composer submission during an active turn MUST create a pending steering message. +8. Empty composer content without attachments MUST NOT create a pending steering message. +9. Accepted steering messages for the same active turn MUST appear in the originating conversation history in user-submission order. +10. After Pi accepts a steering message into conversation history as user input, that message MUST leave the pending presentation. +11. Each accepted steering submission MUST produce exactly one delivered user-message entry in its originating conversation history. +12. When Pi rejects a steering request, PiNative MUST restore its text to the originating chat's composer. +13. When Pi rejects a steering request, PiNative MUST restore its attachments to the originating chat's composer. +14. Restoring a rejected steering request MUST preserve content entered in the composer after that request was submitted. +15. Pressing Stop while steering messages are pending MUST abort the active turn. +16. After that stop completes, accepted but undelivered steering messages MUST appear as new user input in a subsequent turn. +17. Steering messages resubmitted after Stop MUST retain their original user-submission order. +18. If Stop is pressed before a steering request is acknowledged, its pending presentation MUST remain until the message is delivered, resubmitted, or retained for retry. +19. A late steering acknowledgement after Stop MUST NOT cause duplicate submission or delivery. +20. A pending steering message MUST be displayed only in its originating chat. +21. Steering delivery events for one chat MUST NOT modify another chat's transcript or pending steering state. +22. If post-stop delivery fails, the complete undelivered steering entry MUST remain visible in its originating chat for retry. [manual] +23. Returning to a chat after navigation MUST display that chat's currently undelivered steering messages in their original order. [manual] + ## Manual acceptance criteria - The selected chat row should use the send-button accent color while remaining readable. @@ -61,6 +87,7 @@ This spec captures the next testing focus for the shell and conversation lifecyc - Project and chat row hover controls should not create dead click zones when hidden. - Diff pills and project action icons should align visually. - Nested project chat guide lines should align with project folder icons and leave horizontal breathing room before chat rows. +- Pending steering should follow Pi's presentation: subdued gray inline `Steering:` rows at the bottom of chat history, without a delivered-user bubble. ## Implementation notes @@ -68,6 +95,7 @@ This spec captures the next testing focus for the shell and conversation lifecyc - Tests for hidden controls should verify the hidden archive, diff, and new-chat affordances do not intercept row selection. - Tests for active work navigation should start work in one chat, switch to another chat, then verify both the target chat selection and the original chat working indicator. - Tests for Stop should cover late output suppression after the stop action, not only immediate local state changes. +- Tests for steering should cover multiple queued messages, attachment payloads, RPC rejection, delivery events, Stop races, runtime replacement, and cross-chat isolation. ## Non-goals From b371872b05406c2fb19d04bfe7ad769c4cb6f704 Mon Sep 17 00:00:00 2001 From: Salman Ansari Date: Tue, 8 Sep 2026 14:15:07 -0700 Subject: [PATCH 2/6] Harden steering recovery after Stop --- .2119/verdicts/REQ-003.5.2--09c4e4ebdbbc.json | 8 +++ CHANGELOG.md | 7 +++ PiNative/PiConversationModel.swift | 19 ++++++- PiNative/PiRPCClient.swift | 12 +++++ .../SteeringReplayBoundaryTests.swift | 52 +++++++++++++++++-- PiNativeTests/StopButtonTests.swift | 6 ++- 6 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 .2119/verdicts/REQ-003.5.2--09c4e4ebdbbc.json diff --git a/.2119/verdicts/REQ-003.5.2--09c4e4ebdbbc.json b/.2119/verdicts/REQ-003.5.2--09c4e4ebdbbc.json new file mode 100644 index 0000000..2bc1c67 --- /dev/null +++ b/.2119/verdicts/REQ-003.5.2--09c4e4ebdbbc.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.5.2--09c4e4ebdbbc", + "requirementId": "REQ-003.5.2", + "hash": "09c4e4ebdbbc", + "verdict": "pass", + "summary": "Genuine coverage: mocked and real-RPC-process tests confirm late/delayed turn output after Stop (and after a subsequent new turn starts) never appends to transcript, both at model and UI level.", + "timestamp": "2026-09-08T20:42:56.451Z" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index de69333..f0520bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 2026-09-08 + + +### Fixed + +- Made steering recovery after Stop deterministic by clearing queued server input before abort, waiting for the old Pi process to terminate before restart, and safely falling back to termination when `clear_queue` is unavailable. + ## 2026-09-02 diff --git a/PiNative/PiConversationModel.swift b/PiNative/PiConversationModel.swift index 6577f88..937e1b7 100644 --- a/PiNative/PiConversationModel.swift +++ b/PiNative/PiConversationModel.swift @@ -76,6 +76,9 @@ final class PiConversationModel: ObservableObject { private var steeringSubmissionTask: Task? private var steeringOperationGeneration = 0 private var shouldReplaySteeringAfterStop = false + /// Holds process startup while Stop clears server-side queued input, + /// aborts the turn, and fully terminates the old RPC process. + private var isRestartingAfterStop = false #if DEBUG var shouldStallRPCOverrideForTesting: Bool? var mockResponseOverrideForTesting: String? @@ -150,6 +153,7 @@ final class PiConversationModel: ObservableObject { } func startProcessIfNeeded() { + guard !isRestartingAfterStop else { return } guard client == nil || mockResponse != nil else { flushPendingPromptIfNeeded() return @@ -311,6 +315,7 @@ final class PiConversationModel: ObservableObject { lastStartKey = nil pendingPrompt = nil isRunning = false + isRestartingAfterStop = false // Capture the client into a local before clearing the property — // `client?.stop()` inside the Task would otherwise always read `nil`, // since the synchronous assignment above runs before the Task body @@ -412,6 +417,7 @@ final class PiConversationModel: ObservableObject { if mockResponse == nil { client = nil isSessionReady = false + isRestartingAfterStop = clientToAbort != nil processGeneration += 1 lastStartKey = nil } @@ -421,8 +427,19 @@ final class PiConversationModel: ObservableObject { let cachedItems = items let planningMode = isPlanningMode Task { - _ = try? await clientToAbort?.abort(timeoutSeconds: 1.25) + if let clientToAbort { + // Pi continues accepted queue entries after abort unless they + // are cleared first. Keep the local queue as the replay source + // of truth, then wait for full process termination before a + // replacement can start. + let queueWasCleared = (try? await clientToAbort.clearQueue(timeoutSeconds: 1.25)) != nil + if queueWasCleared { + _ = try? await clientToAbort.abort(timeoutSeconds: 1.25) + } + await clientToAbort.stop() + } await MainActor.run { + self.isRestartingAfterStop = false #if DEBUG self.onStopCompletionForTesting?() #endif diff --git a/PiNative/PiRPCClient.swift b/PiNative/PiRPCClient.swift index c63d473..d1e1934 100644 --- a/PiNative/PiRPCClient.swift +++ b/PiNative/PiRPCClient.swift @@ -335,6 +335,18 @@ actor PiRPCClient { try await send(command: "new_session", fields: [:], timeoutSeconds: timeoutSeconds) } + /// Remove queued steering and follow-up input before aborting. Pi otherwise + /// continues accepted queue entries after `abort`, which would race the + /// client's post-stop replay and risk duplicate delivery. + func clearQueue(timeoutSeconds: TimeInterval = 15) async throws -> RPCEnvelope { + let response = try await send(command: "clear_queue", fields: [:], timeoutSeconds: timeoutSeconds) + guard response.success != false else { + log("clear_queue rejected; caller must fall back to process termination") + throw ClientError.invalidResponse(response.error?.stringValue ?? "Pi rejected clear_queue.") + } + return response + } + /// Genuine server-side turn cancellation (`session.abort()` in pi's RPC /// mode) — not a client-side give-up. See implementation plan §G. func abort(timeoutSeconds: TimeInterval = 15) async throws -> RPCEnvelope { diff --git a/PiNativeTests/SteeringReplayBoundaryTests.swift b/PiNativeTests/SteeringReplayBoundaryTests.swift index 8baab1b..61c741c 100644 --- a/PiNativeTests/SteeringReplayBoundaryTests.swift +++ b/PiNativeTests/SteeringReplayBoundaryTests.swift @@ -110,6 +110,30 @@ final class SteeringReplayBoundaryTests: XCTestCase { XCTAssertTrue(model.sessionLoadNotice?.contains("Failed to start pi") == true) } + func testUnsupportedClearQueueFallsBackToTerminationAndRestartsPendingPrompt() async throws { + unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") + let fixture = try ReplayRPCFixture(rejectsClearQueue: true) + defer { fixture.cleanup() } + let model = fixture.makeModel() + model.start(workingDirectory: fixture.directory.path, sessionPath: nil) + model.draft = "first prompt" + model.sendDraft() + try await waitForSteeringCondition { model.isRunning } + + model.stopActiveTurn() + model.draft = "prompt held during stop" + model.sendDraft() + try await waitForSteeringCondition { + let log = try? String(contentsOf: fixture.commandLogFile, encoding: .utf8) + return log?.contains("\"message\":\"prompt held during stop\"") == true + } + + let commandLog = try String(contentsOf: fixture.commandLogFile, encoding: .utf8) + XCTAssertTrue(commandLog.contains("\"type\":\"clear_queue\""), commandLog) + XCTAssertFalse(commandLog.contains("\"type\":\"abort\""), commandLog) + XCTAssertEqual(try String(contentsOf: fixture.launchCountFile, encoding: .utf8), "2") + } + func testReplacementSessionFailureMakesReplayingSteeringRetryable() async throws { unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") let fixture = try ReplayRPCFixture() @@ -162,15 +186,22 @@ private struct ReplayRPCFixture { let executable: URL let launchCountFile: URL let failReplacementMarker: URL + let commandLogFile: URL - init() throws { + init(rejectsClearQueue: Bool = false) throws { directory = FileManager.default.temporaryDirectory .appendingPathComponent("PiNativeSteeringReplay-\(UUID().uuidString)", isDirectory: true) executable = directory.appendingPathComponent("fake-pi-rpc.sh") launchCountFile = directory.appendingPathComponent("launch-count.txt") failReplacementMarker = directory.appendingPathComponent("fail-replacements") + commandLogFile = directory.appendingPathComponent("commands.log") try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - try Self.script(launchCountFile: launchCountFile, failReplacementMarker: failReplacementMarker) + try Self.script( + launchCountFile: launchCountFile, + failReplacementMarker: failReplacementMarker, + commandLogFile: commandLogFile, + rejectsClearQueue: rejectsClearQueue + ) .write(to: executable, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) } @@ -188,11 +219,18 @@ private struct ReplayRPCFixture { try? FileManager.default.removeItem(at: directory) } - private static func script(launchCountFile: URL, failReplacementMarker: URL) -> String { + private static func script( + launchCountFile: URL, + failReplacementMarker: URL, + commandLogFile: URL, + rejectsClearQueue: Bool + ) -> String { """ #!/bin/sh count_file='\(launchCountFile.path)' fail_marker='\(failReplacementMarker.path)' + log_file='\(commandLogFile.path)' + reject_clear_queue='\(rejectsClearQueue ? "1" : "0")' launch_count=0 [ -f "$count_file" ] && launch_count=$(cat "$count_file") launch_count=$((launch_count + 1)) @@ -202,8 +240,16 @@ private struct ReplayRPCFixture { exit 17 fi while IFS= read -r line; do + printf '%s\n' "$line" >> "$log_file" id=$(printf '%s' "$line" | sed -E 's/.*"id":([0-9]+).*/\\1/') case "$line" in + *'"type":"clear_queue"'*) + if [ "$reject_clear_queue" = "1" ]; then + printf '{"id":%s,"type":"response","success":false,"error":"Unknown command: clear_queue"}\n' "$id" + else + printf '{"id":%s,"type":"response","success":true,"data":{"steering":[],"followUp":[]}}\n' "$id" + fi + ;; *'"type":"prompt"'*) printf '%s\n' '{"type":"agent_start"}' printf '{"id":%s,"type":"response","success":true,"data":{}}\n' "$id" diff --git a/PiNativeTests/StopButtonTests.swift b/PiNativeTests/StopButtonTests.swift index 6029d6e..2a85d8d 100644 --- a/PiNativeTests/StopButtonTests.swift +++ b/PiNativeTests/StopButtonTests.swift @@ -646,7 +646,11 @@ final class StopButtonTests: XCTestCase { let commandLog = (try? String(contentsOf: commandLogFile, encoding: .utf8)) ?? "" let transcript = String(describing: model.items) - XCTAssertTrue(commandLog.contains("\"message\":\"second prompt\""), commandLog) + let clearQueuePosition = try XCTUnwrap(commandLog.range(of: "\"type\":\"clear_queue\"")) + let abortPosition = try XCTUnwrap(commandLog.range(of: "\"type\":\"abort\"")) + let secondPromptPosition = try XCTUnwrap(commandLog.range(of: "\"message\":\"second prompt\"")) + XCTAssertLessThan(clearQueuePosition.lowerBound, abortPosition.lowerBound, commandLog) + XCTAssertLessThan(abortPosition.lowerBound, secondPromptPosition.lowerBound, commandLog) XCTAssertTrue(transcript.contains("fresh second-turn output"), "Commands:\n\(commandLog)\nTranscript:\n\(transcript)") try await Task.sleep(nanoseconds: 450_000_000) From a1f55a96e3e46659b2b3847a1b572d5c8ee52f69 Mon Sep 17 00:00:00 2001 From: Salman Ansari Date: Tue, 8 Sep 2026 14:27:28 -0700 Subject: [PATCH 3/6] Wait for session readiness after Stop --- .2119/verdicts/REQ-003.5.2--06c255642356.json | 8 ++++++++ CHANGELOG.md | 1 + PiNative/PiConversationModel.swift | 4 +++- PiNativeTests/StopButtonTests.swift | 11 ++++++++++- 4 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 .2119/verdicts/REQ-003.5.2--06c255642356.json diff --git a/.2119/verdicts/REQ-003.5.2--06c255642356.json b/.2119/verdicts/REQ-003.5.2--06c255642356.json new file mode 100644 index 0000000..c7f53b3 --- /dev/null +++ b/.2119/verdicts/REQ-003.5.2--06c255642356.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.5.2--06c255642356", + "requirementId": "REQ-003.5.2", + "hash": "06c255642356", + "verdict": "pass", + "summary": "Genuine coverage: unit tests use real timing races and a real fake-RPC subprocess to confirm late/stopped-turn output never appends while distinct later-turn output does append; UI test confirms no assistant transcript node after Stop before new send.", + "timestamp": "2026-09-08T21:25:03.765Z" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index f0520bd..d2cf7b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed +- Held prompts until replacement Pi sessions finish loading after Stop, preventing prompts from being sent before session initialization and making restart ordering reliable on slower systems. - Made steering recovery after Stop deterministic by clearing queued server input before abort, waiting for the old Pi process to terminate before restart, and safely falling back to termination when `clear_queue` is unavailable. ## 2026-09-02 diff --git a/PiNative/PiConversationModel.swift b/PiNative/PiConversationModel.swift index 937e1b7..e26045a 100644 --- a/PiNative/PiConversationModel.swift +++ b/PiNative/PiConversationModel.swift @@ -155,7 +155,9 @@ final class PiConversationModel: ObservableObject { func startProcessIfNeeded() { guard !isRestartingAfterStop else { return } guard client == nil || mockResponse != nil else { - flushPendingPromptIfNeeded() + if isSessionReady { + flushPendingPromptIfNeeded() + } return } start( diff --git a/PiNativeTests/StopButtonTests.swift b/PiNativeTests/StopButtonTests.swift index 2a85d8d..940b755 100644 --- a/PiNativeTests/StopButtonTests.swift +++ b/PiNativeTests/StopButtonTests.swift @@ -628,7 +628,10 @@ final class StopButtonTests: XCTestCase { (try? String(contentsOf: promptCountFile, encoding: .utf8)) == "1" } XCTAssertTrue(model.isRunning) + let stopCompleted = expectation(description: "Stop teardown completed") + model.onStopCompletionForTesting = { stopCompleted.fulfill() } model.stopActiveTurn() + await fulfillment(of: [stopCompleted], timeout: 6) model.draft = "second prompt" model.sendDraft() @@ -648,9 +651,15 @@ final class StopButtonTests: XCTestCase { let transcript = String(describing: model.items) let clearQueuePosition = try XCTUnwrap(commandLog.range(of: "\"type\":\"clear_queue\"")) let abortPosition = try XCTUnwrap(commandLog.range(of: "\"type\":\"abort\"")) + let firstNewSessionPosition = try XCTUnwrap(commandLog.range(of: "\"type\":\"new_session\"")) + let replacementNewSessionPosition = try XCTUnwrap(commandLog.range( + of: "\"type\":\"new_session\"", + range: firstNewSessionPosition.upperBound.. Date: Tue, 8 Sep 2026 14:47:33 -0700 Subject: [PATCH 4/6] Make Stop lifecycle tests event driven --- .2119/verdicts/REQ-003.5.2--54bbe4b5b1ee.json | 8 ++ CHANGELOG.md | 4 + PiNative/PiConversationModel.swift | 7 +- PiNativeTests/StopButtonTests.swift | 90 +++++++++---------- 4 files changed, 60 insertions(+), 49 deletions(-) create mode 100644 .2119/verdicts/REQ-003.5.2--54bbe4b5b1ee.json diff --git a/.2119/verdicts/REQ-003.5.2--54bbe4b5b1ee.json b/.2119/verdicts/REQ-003.5.2--54bbe4b5b1ee.json new file mode 100644 index 0000000..dab922c --- /dev/null +++ b/.2119/verdicts/REQ-003.5.2--54bbe4b5b1ee.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.5.2--54bbe4b5b1ee", + "requirementId": "REQ-003.5.2", + "hash": "54bbe4b5b1ee", + "verdict": "pass", + "summary": "Unit tests use real RPC subprocesses to distinguish stale vs fresh turn output by process identity and assert the stale text never appears in the transcript while fresh output does; UI test waits after Stop to confirm no assistant message renders from delayed late output. Genuine negative-space coverage, not tautological.", + "timestamp": "2026-09-08T21:46:08.378Z" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index d2cf7b9..7a423c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## 2026-09-08 +### Changed + +- Made Stop/restart lifecycle validation event-driven, removing polling and fixed-delay assumptions from real Pi process tests. + ### Fixed - Held prompts until replacement Pi sessions finish loading after Stop, preventing prompts from being sent before session initialization and making restart ordering reliable on slower systems. diff --git a/PiNative/PiConversationModel.swift b/PiNative/PiConversationModel.swift index e26045a..e0fb1b0 100644 --- a/PiNative/PiConversationModel.swift +++ b/PiNative/PiConversationModel.swift @@ -86,6 +86,7 @@ final class PiConversationModel: ObservableObject { var onPromptRPCForTesting: ((String) -> Void)? var onAbortRPCForTesting: (() -> Void)? var onStopCompletionForTesting: (() -> Void)? + var onRPCEventReceivedForTesting: ((RPCEnvelope, Bool) -> Void)? #endif private static let defaultThinkingLevels: [PiThinkingLevel] = [.low, .medium, .high] @@ -1048,7 +1049,11 @@ final class PiConversationModel: ObservableObject { #endif private func handle(_ event: RPCEnvelope, processGeneration: Int? = nil) { - if let processGeneration, processGeneration != self.processGeneration { return } + let belongsToCurrentProcess = processGeneration.map { $0 == self.processGeneration } ?? true +#if DEBUG + onRPCEventReceivedForTesting?(event, belongsToCurrentProcess) +#endif + guard belongsToCurrentProcess else { return } guard let type = event.type else { return } if type == "extension_ui_request" { handleExtensionUIRequest(event) diff --git a/PiNativeTests/StopButtonTests.swift b/PiNativeTests/StopButtonTests.swift index 940b755..7c4773f 100644 --- a/PiNativeTests/StopButtonTests.swift +++ b/PiNativeTests/StopButtonTests.swift @@ -498,13 +498,9 @@ final class StopButtonTests: XCTestCase { *'"type":"prompt"'*) printf '%s\\n' '{"type":"agent_start"}' printf '{"id":%s,"type":"response","success":true,"data":{}}\\n' "$id" - ( - sleep 0.20 - printf '%s\\n' '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"late output from real rpc process"}}' - ) & ;; *'"type":"abort"'*) - sleep 0.45 + printf '%s\\n' '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"late output from real rpc process"}}' printf '{"id":%s,"type":"response","success":true,"data":{}}\\n' "$id" ;; *) @@ -516,16 +512,30 @@ final class StopButtonTests: XCTestCase { try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.path) let model = PiConversationModel(piCommand: PiCommand(executable: script.path, arguments: [])) + let agentStarted = expectation(description: "Real RPC turn started") + let staleEventReceived = expectation(description: "Stopped process emitted post-Stop output") + let stopCompleted = expectation(description: "Stop teardown completed") + model.onRPCEventReceivedForTesting = { event, belongsToCurrentProcess in + if event.type == "agent_start", belongsToCurrentProcess { + agentStarted.fulfill() + } + if event["assistantMessageEvent"]?.objectValue?["delta"]?.stringValue == "late output from real rpc process" { + XCTAssertFalse(belongsToCurrentProcess) + staleEventReceived.fulfill() + } + } + model.onStopCompletionForTesting = { stopCompleted.fulfill() } model.currentModel = PiModelOption(provider: "test", id: "selected", name: "Selected") model.currentThinkingLevel = .medium model.start(workingDirectory: sandbox.path, sessionPath: nil) model.draft = "start a turn through the real rpc process" model.sendDraft() - try await waitUntil(timeout: 1) { model.isRunning } + await fulfillment(of: [agentStarted], timeout: 6) + XCTAssertTrue(model.isRunning) model.stopActiveTurn() + await fulfillment(of: [staleEventReceived, stopCompleted], timeout: 6) - try await Task.sleep(nanoseconds: 350_000_000) XCTAssertFalse(model.items.contains { item in if case .assistantText(_, let text) = item { return text.contains("late output from real rpc process") @@ -568,8 +578,8 @@ final class StopButtonTests: XCTestCase { let script = sandbox.appendingPathComponent("fake-pi-rpc.sh") try """ #!/bin/sh - count_file="$PI_NATIVE_TEST_PROMPT_COUNT_FILE" - log_file="$PI_NATIVE_TEST_COMMAND_LOG_FILE" + count_file="\(promptCountFile.path)" + log_file="\(commandLogFile.path)" while IFS= read -r line; do printf '%s\n' "$line" >> "$log_file" id=$(printf '%s' "$line" | sed -E 's/.*"id":([0-9]+).*/\\1/') @@ -581,17 +591,13 @@ final class StopButtonTests: XCTestCase { printf '%s' "$count" > "$count_file" printf '%s\\n' '{"type":"agent_start"}' printf '{"id":%s,"type":"response","success":true,"data":{}}\\n' "$id" - if [ "$count" -eq 1 ]; then - ( - sleep 0.35 - printf '%s\\n' '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"stale first-turn output after second start"}}' - ) & - else + if [ "$count" -ne 1 ]; then printf '%s\\n' '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"fresh second-turn output"}}' printf '%s\\n' '{"type":"agent_end"}' fi ;; *'"type":"abort"'*) + printf '%s\\n' '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"stale first-turn output after second start"}}' printf '{"id":%s,"type":"response","success":true,"data":{}}\\n' "$id" ;; *'"type":"get_state"'*) @@ -610,42 +616,41 @@ final class StopButtonTests: XCTestCase { done """.write(to: script, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.path) - setenv("PI_NATIVE_TEST_PROMPT_COUNT_FILE", promptCountFile.path, 1) - setenv("PI_NATIVE_TEST_COMMAND_LOG_FILE", commandLogFile.path, 1) - defer { - unsetenv("PI_NATIVE_TEST_PROMPT_COUNT_FILE") - unsetenv("PI_NATIVE_TEST_COMMAND_LOG_FILE") - } - let model = PiConversationModel(piCommand: PiCommand(executable: script.path, arguments: [])) + let firstTurnStarted = expectation(description: "First real RPC turn started") + let staleFirstTurnEventReceived = expectation(description: "First process emitted stale output") + let freshSecondTurnEventReceived = expectation(description: "Replacement process emitted fresh output") + var didObserveFirstTurnStart = false + model.onRPCEventReceivedForTesting = { event, belongsToCurrentProcess in + if event.type == "agent_start", belongsToCurrentProcess, !didObserveFirstTurnStart { + didObserveFirstTurnStart = true + firstTurnStarted.fulfill() + } + let delta = event["assistantMessageEvent"]?.objectValue?["delta"]?.stringValue + if delta == "stale first-turn output after second start" { + XCTAssertFalse(belongsToCurrentProcess) + staleFirstTurnEventReceived.fulfill() + } else if delta == "fresh second-turn output" { + XCTAssertTrue(belongsToCurrentProcess) + freshSecondTurnEventReceived.fulfill() + } + } model.currentModel = PiModelOption(provider: "test", id: "selected", name: "Selected") model.currentThinkingLevel = .medium model.start(workingDirectory: sandbox.path, sessionPath: nil) model.draft = "first prompt" model.sendDraft() - try await waitUntil(timeout: 1) { - (try? String(contentsOf: promptCountFile, encoding: .utf8)) == "1" - } + await fulfillment(of: [firstTurnStarted], timeout: 6) XCTAssertTrue(model.isRunning) let stopCompleted = expectation(description: "Stop teardown completed") model.onStopCompletionForTesting = { stopCompleted.fulfill() } model.stopActiveTurn() - await fulfillment(of: [stopCompleted], timeout: 6) + await fulfillment(of: [staleFirstTurnEventReceived, stopCompleted], timeout: 6) model.draft = "second prompt" model.sendDraft() - try await waitUntil(timeout: 3) { - (try? String(contentsOf: promptCountFile, encoding: .utf8)) == "2" - } - try await waitUntil(timeout: 3) { - model.items.contains { item in - if case .assistantText(_, let text) = item { - return text.contains("fresh second-turn output") - } - return false - } - } + await fulfillment(of: [freshSecondTurnEventReceived], timeout: 6) let commandLog = (try? String(contentsOf: commandLogFile, encoding: .utf8)) ?? "" let transcript = String(describing: model.items) @@ -662,7 +667,6 @@ final class StopButtonTests: XCTestCase { XCTAssertLessThan(replacementNewSessionPosition.lowerBound, secondPromptPosition.lowerBound, commandLog) XCTAssertTrue(transcript.contains("fresh second-turn output"), "Commands:\n\(commandLog)\nTranscript:\n\(transcript)") - try await Task.sleep(nanoseconds: 450_000_000) XCTAssertFalse(model.items.contains { item in if case .assistantText(_, let text) = item { return text.contains("stale first-turn output after second start") @@ -672,16 +676,6 @@ final class StopButtonTests: XCTestCase { } } -@MainActor -private func waitUntil(timeout: TimeInterval, condition: @escaping @MainActor () -> Bool) async throws { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if condition() { return } - try await Task.sleep(nanoseconds: 20_000_000) - } - XCTFail("Timed out waiting for condition") -} - private extension RPCEnvelope { static func testEnvelope(_ raw: [String: JSONValue]) throws -> RPCEnvelope { let data = try JSONEncoder().encode(JSONValue.object(raw)) From dfeb46e50078711b36493e6f6e74e866f289a0bb Mon Sep 17 00:00:00 2001 From: Salman Ansari Date: Tue, 8 Sep 2026 15:04:41 -0700 Subject: [PATCH 5/6] Isolate real RPC tests from shared environment --- .2119/verdicts/REQ-003.5.2--177331f1f38e.json | 8 ++++++++ CHANGELOG.md | 1 + PiNative/PiConversationModel.swift | 15 +++++++++++++-- PiNativeTests/StopButtonTests.swift | 8 ++++++-- 4 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 .2119/verdicts/REQ-003.5.2--177331f1f38e.json diff --git a/.2119/verdicts/REQ-003.5.2--177331f1f38e.json b/.2119/verdicts/REQ-003.5.2--177331f1f38e.json new file mode 100644 index 0000000..ea2eba6 --- /dev/null +++ b/.2119/verdicts/REQ-003.5.2--177331f1f38e.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.5.2--177331f1f38e", + "requirementId": "REQ-003.5.2", + "hash": "177331f1f38e", + "verdict": "pass", + "summary": "Tests genuinely assert stale output from a stopped turn is excluded from transcript items after stop, both immediately and after a later turn starts, using mocked and real (non-mocked) RPC subprocess paths; negative assertions would fail under the violation.", + "timestamp": "2026-09-08T22:04:05.193Z" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index d5cd49d..95eedb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Changed +- Isolated real-process Stop lifecycle tests from process-wide mock, stall, and failure settings used by concurrently running tests. - Made Stop/restart lifecycle validation event-driven, removing polling and fixed-delay assumptions from real Pi process tests. ### Fixed diff --git a/PiNative/PiConversationModel.swift b/PiNative/PiConversationModel.swift index e0fb1b0..df8c4d6 100644 --- a/PiNative/PiConversationModel.swift +++ b/PiNative/PiConversationModel.swift @@ -56,7 +56,11 @@ final class PiConversationModel: ObservableObject { /// flip the composer back into running state. private var isSuppressingStoppedTurnEvents = false private var mockResponse: String? { - mockResponseOverrideForTesting ?? ProcessInfo.processInfo.environment["PI_NATIVE_MOCK_RPC_RESPONSE"] +#if DEBUG + if let mockResponseOverrideForTesting { return mockResponseOverrideForTesting } + if !usesMockResponseEnvironmentForTesting { return nil } +#endif + return ProcessInfo.processInfo.environment["PI_NATIVE_MOCK_RPC_RESPONSE"] } private var mockResponseDelayNanoseconds: UInt64 { let milliseconds = UInt64(ProcessInfo.processInfo.environment["PI_NATIVE_MOCK_RPC_RESPONSE_DELAY_MS"] ?? "300") ?? 300 @@ -65,7 +69,12 @@ final class PiConversationModel: ObservableObject { private var shouldStallRPCForTesting: Bool { shouldStallRPCOverrideForTesting ?? (ProcessInfo.processInfo.environment["PI_NATIVE_TEST_RPC_STALL"] == "1") } - private var shouldFailRPCForTesting: Bool { ProcessInfo.processInfo.environment["PI_NATIVE_TEST_RPC_CATASTROPHIC_FAILURE"] == "1" } + private var shouldFailRPCForTesting: Bool { +#if DEBUG + if let shouldFailRPCOverrideForTesting { return shouldFailRPCOverrideForTesting } +#endif + return ProcessInfo.processInfo.environment["PI_NATIVE_TEST_RPC_CATASTROPHIC_FAILURE"] == "1" + } private let piCommandOverride: PiCommand? private let modelSettings: ModelSettingsModel? private var pendingModelSelection: PiModelOption? @@ -81,7 +90,9 @@ final class PiConversationModel: ObservableObject { private var isRestartingAfterStop = false #if DEBUG var shouldStallRPCOverrideForTesting: Bool? + var shouldFailRPCOverrideForTesting: Bool? var mockResponseOverrideForTesting: String? + var usesMockResponseEnvironmentForTesting = true var onSteeringRPCForTesting: ((String) -> Void)? var onPromptRPCForTesting: ((String) -> Void)? var onAbortRPCForTesting: (() -> Void)? diff --git a/PiNativeTests/StopButtonTests.swift b/PiNativeTests/StopButtonTests.swift index 7c4773f..efd8ee8 100644 --- a/PiNativeTests/StopButtonTests.swift +++ b/PiNativeTests/StopButtonTests.swift @@ -484,7 +484,6 @@ final class StopButtonTests: XCTestCase { // 2119: REQ-003.5.2 func testRealRPCProcessLateOutputIsSuppressedAfterStop() async throws { - unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") let sandbox = FileManager.default.temporaryDirectory .appendingPathComponent("PiNativeStopRPC-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: sandbox, withIntermediateDirectories: true) @@ -512,6 +511,9 @@ final class StopButtonTests: XCTestCase { try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.path) let model = PiConversationModel(piCommand: PiCommand(executable: script.path, arguments: [])) + model.usesMockResponseEnvironmentForTesting = false + model.shouldStallRPCOverrideForTesting = false + model.shouldFailRPCOverrideForTesting = false let agentStarted = expectation(description: "Real RPC turn started") let staleEventReceived = expectation(description: "Stopped process emitted post-Stop output") let stopCompleted = expectation(description: "Stop teardown completed") @@ -568,7 +570,6 @@ final class StopButtonTests: XCTestCase { } // 2119: REQ-003.5.2 func testRealRPCProcessLateStoppedOutputIsSuppressedAfterLaterTurnStarts() async throws { - unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") let sandbox = FileManager.default.temporaryDirectory .appendingPathComponent("PiNativeStopThenRestartRPC-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: sandbox, withIntermediateDirectories: true) @@ -617,6 +618,9 @@ final class StopButtonTests: XCTestCase { """.write(to: script, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.path) let model = PiConversationModel(piCommand: PiCommand(executable: script.path, arguments: [])) + model.usesMockResponseEnvironmentForTesting = false + model.shouldStallRPCOverrideForTesting = false + model.shouldFailRPCOverrideForTesting = false let firstTurnStarted = expectation(description: "First real RPC turn started") let staleFirstTurnEventReceived = expectation(description: "First process emitted stale output") let freshSecondTurnEventReceived = expectation(description: "Replacement process emitted fresh output") From dbf5831d61683a936247401f74fa1a3fcbae7a45 Mon Sep 17 00:00:00 2001 From: Salman Ansari Date: Tue, 8 Sep 2026 15:24:48 -0700 Subject: [PATCH 6/6] Stop real RPC fixtures deterministically --- .2119/verdicts/REQ-003.5.2--014fb242ad97.json | 8 +++++++ .../verdicts/REQ-003.7.16--14b2201ffe75.json | 8 +++++++ CHANGELOG.md | 1 + PiNative/PiConversationModel.swift | 22 ++++++++++++++----- .../SteeringReplayBoundaryTests.swift | 20 +++++++++++++---- PiNativeTests/StopButtonTests.swift | 10 +++++++-- 6 files changed, 58 insertions(+), 11 deletions(-) create mode 100644 .2119/verdicts/REQ-003.5.2--014fb242ad97.json create mode 100644 .2119/verdicts/REQ-003.7.16--14b2201ffe75.json diff --git a/.2119/verdicts/REQ-003.5.2--014fb242ad97.json b/.2119/verdicts/REQ-003.5.2--014fb242ad97.json new file mode 100644 index 0000000..9959654 --- /dev/null +++ b/.2119/verdicts/REQ-003.5.2--014fb242ad97.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.5.2--014fb242ad97", + "requirementId": "REQ-003.5.2", + "hash": "014fb242ad97", + "verdict": "pass", + "summary": "Unit tests (mock delay + real fake-RPC subprocess) and UI test verify late text_delta/tool_execution_start events after Stop and after a later prompt starts do not append to transcript, while new-turn output still appends; negative-space (forbidden late output) and positive-space (allowed new output) both covered without over-mocking.", + "timestamp": "2026-09-08T22:20:16.554Z" +} diff --git a/.2119/verdicts/REQ-003.7.16--14b2201ffe75.json b/.2119/verdicts/REQ-003.7.16--14b2201ffe75.json new file mode 100644 index 0000000..287a671 --- /dev/null +++ b/.2119/verdicts/REQ-003.7.16--14b2201ffe75.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.7.16--14b2201ffe75", + "requirementId": "REQ-003.7.16", + "hash": "14b2201ffe75", + "verdict": "pass", + "summary": "Tests verify ordering (stop-completed before replay-submitted), replay as new prompt RPC producing a .user item with matching text, first-in-first-out ordering across multiple pending steering messages, exclusion of already-delivered steering (negative-space boundary), and a real-process (non-mocked) variant confirming replay occurs as a new turn while running.", + "timestamp": "2026-09-08T22:20:32.109Z" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 95eedb1..ad1e372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed +- Made real-process steering and Stop lifecycle tests shut down their RPC subprocesses deterministically, preventing leaked readers from starving later tests on constrained CI runners. - Held prompts until replacement Pi sessions finish loading after Stop, preventing prompts from being sent before session initialization and making restart ordering reliable on slower systems. - Made steering recovery after Stop deterministic by clearing queued server input before abort, waiting for the old Pi process to terminate before restart, and safely falling back to termination when `clear_queue` is unavailable. diff --git a/PiNative/PiConversationModel.swift b/PiNative/PiConversationModel.swift index df8c4d6..00870a1 100644 --- a/PiNative/PiConversationModel.swift +++ b/PiNative/PiConversationModel.swift @@ -320,6 +320,15 @@ final class PiConversationModel: ObservableObject { } func stop() { + let oldClient = detachClientForStop() + // Capture the client into a local before clearing the property — + // `client?.stop()` inside the Task would otherwise always read `nil`, + // since the synchronous assignment above runs before the Task body + // gets a chance to execute. + Task { await oldClient?.stop() } + } + + private func detachClientForStop() -> PiRPCClient? { steeringOperationGeneration += 1 steeringSubmissionTask?.cancel() steeringSubmissionTask = nil @@ -330,12 +339,15 @@ final class PiConversationModel: ObservableObject { pendingPrompt = nil isRunning = false isRestartingAfterStop = false - // Capture the client into a local before clearing the property — - // `client?.stop()` inside the Task would otherwise always read `nil`, - // since the synchronous assignment above runs before the Task body - // gets a chance to execute. - Task { await oldClient?.stop() } + return oldClient + } + +#if DEBUG + func stopAndWaitForTesting() async { + let oldClient = detachClientForStop() + await oldClient?.stop() } +#endif private var canSubmitWithSelection: Bool { currentModel != nil && currentThinkingLevel != nil } diff --git a/PiNativeTests/SteeringReplayBoundaryTests.swift b/PiNativeTests/SteeringReplayBoundaryTests.swift index 61c741c..791b0ab 100644 --- a/PiNativeTests/SteeringReplayBoundaryTests.swift +++ b/PiNativeTests/SteeringReplayBoundaryTests.swift @@ -58,8 +58,11 @@ final class SteeringReplayBoundaryTests: XCTestCase { func testRealProcessReplayIsRunningBeforePromptSubmission() async throws { unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") let fixture = try ReplayRPCFixture() - defer { fixture.cleanup() } let model = fixture.makeModel() + addTeardownBlock { @MainActor in + await model.stopAndWaitForTesting() + fixture.cleanup() + } model.start(workingDirectory: fixture.directory.path, sessionPath: nil) model.draft = "original real-process turn" model.sendDraft() @@ -86,8 +89,11 @@ final class SteeringReplayBoundaryTests: XCTestCase { func testReplacementProcessSpawnFailureMakesReplayingSteeringRetryable() async throws { unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") let fixture = try ReplayRPCFixture() - defer { fixture.cleanup() } let model = fixture.makeModel() + addTeardownBlock { @MainActor in + await model.stopAndWaitForTesting() + fixture.cleanup() + } model.start(workingDirectory: fixture.directory.path, sessionPath: nil) model.draft = "original real-process turn" model.sendDraft() @@ -113,8 +119,11 @@ final class SteeringReplayBoundaryTests: XCTestCase { func testUnsupportedClearQueueFallsBackToTerminationAndRestartsPendingPrompt() async throws { unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") let fixture = try ReplayRPCFixture(rejectsClearQueue: true) - defer { fixture.cleanup() } let model = fixture.makeModel() + addTeardownBlock { @MainActor in + await model.stopAndWaitForTesting() + fixture.cleanup() + } model.start(workingDirectory: fixture.directory.path, sessionPath: nil) model.draft = "first prompt" model.sendDraft() @@ -137,8 +146,11 @@ final class SteeringReplayBoundaryTests: XCTestCase { func testReplacementSessionFailureMakesReplayingSteeringRetryable() async throws { unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") let fixture = try ReplayRPCFixture() - defer { fixture.cleanup() } let model = fixture.makeModel() + addTeardownBlock { @MainActor in + await model.stopAndWaitForTesting() + fixture.cleanup() + } model.start(workingDirectory: fixture.directory.path, sessionPath: nil) model.draft = "original real-process turn" model.sendDraft() diff --git a/PiNativeTests/StopButtonTests.swift b/PiNativeTests/StopButtonTests.swift index efd8ee8..2876798 100644 --- a/PiNativeTests/StopButtonTests.swift +++ b/PiNativeTests/StopButtonTests.swift @@ -487,7 +487,6 @@ final class StopButtonTests: XCTestCase { let sandbox = FileManager.default.temporaryDirectory .appendingPathComponent("PiNativeStopRPC-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: sandbox, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: sandbox) } let script = sandbox.appendingPathComponent("fake-pi-rpc.sh") try """ #!/bin/sh @@ -511,6 +510,10 @@ final class StopButtonTests: XCTestCase { try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.path) let model = PiConversationModel(piCommand: PiCommand(executable: script.path, arguments: [])) + addTeardownBlock { @MainActor in + await model.stopAndWaitForTesting() + try? FileManager.default.removeItem(at: sandbox) + } model.usesMockResponseEnvironmentForTesting = false model.shouldStallRPCOverrideForTesting = false model.shouldFailRPCOverrideForTesting = false @@ -573,7 +576,6 @@ final class StopButtonTests: XCTestCase { let sandbox = FileManager.default.temporaryDirectory .appendingPathComponent("PiNativeStopThenRestartRPC-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: sandbox, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: sandbox) } let promptCountFile = sandbox.appendingPathComponent("prompt-count.txt") let commandLogFile = sandbox.appendingPathComponent("commands.log") let script = sandbox.appendingPathComponent("fake-pi-rpc.sh") @@ -618,6 +620,10 @@ final class StopButtonTests: XCTestCase { """.write(to: script, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.path) let model = PiConversationModel(piCommand: PiCommand(executable: script.path, arguments: [])) + addTeardownBlock { @MainActor in + await model.stopAndWaitForTesting() + try? FileManager.default.removeItem(at: sandbox) + } model.usesMockResponseEnvironmentForTesting = false model.shouldStallRPCOverrideForTesting = false model.shouldFailRPCOverrideForTesting = false