mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-11 10:01:27 -10:00
* feat(runtime): add structured turn protocol and ledger Problem: Turn completion was inferred from provider text, so EOF, interruption, and protocol failures could collapse into the same successful outcome without a durable lifecycle record. Root cause: The transcript doubled as runtime truth and there was no stable turn identity, monotonic event envelope, exactly-once terminal commit, or explicit provider finalization contract. Fix: Add the queued-to-terminal TurnStatus model, append-only .turns.jsonl ledger with torn-tail quarantine and terminal fsync, and a stable host-consumed finish tool with one bounded repair round. Verification: Covered by focused agent, boot, control, event wire, store, and turnevent tests plus the root Go suite. * feat(runtime): route cancellation and prompts by turn Problem: Stop and prompt answers could be routed through whichever tab was currently active, allowing tab switches and overlapping completions to affect the wrong turn or leave work running. Root cause: Desktop admission and Wails entry points did not carry a stable turn ID through start, interrupt, prompt answer, and replay operations. Fix: Add turn-scoped StartTurnForTab, InterruptTurnForTab, AnswerPromptForTab, and TurnEventsForTab contracts; bind cancellation and prompt ownership to the exact session/turn; and keep one foreground turn per session while preserving cross-session concurrency. Verification: Covered by deterministic control and desktop runtime tests, full root and desktop suites, and race tests for agent/control/turnevent. * fix(frontend): project ordered turn events without rollback Problem: A late cancellation reload could replace the full transcript and erase a newer retry, while asynchronous Markdown finalization could move a reader back toward the tail. Root cause: The frontend reconciled cancellation with history replacement instead of an ordered turn projection, and a fresh history fallback could be swapped for a tail-only block window while the user was reading above the bottom. Fix: Project events idempotently by runtime epoch and sequence, replay gaps from the ledger, reject stale revisions, patch only the affected turn, preserve pending-only height estimates, and defer the Markdown fallback-to-block swap until the reader returns to the bottom. Verification: Covered by deterministic controller ordering tests, Markdown history handoff tests, the transcript suite, frontend typecheck/build budgets, and native Windows WebView2 Stop/Ask/parallel/scroll smoke. Co-authored-by: Linearleaf <[email protected]> * test(runtime): update compatibility and cache fixtures Problem: The new lifecycle sidecar, Wails arrays, provider-visible finish schema, and Windows smoke cleanup needed explicit cross-version and deterministic serialization guards. Root cause: Existing fixtures described the legacy tool prefix and did not exercise empty-array bridge encoding, old-session bootstrap, torn-tail recovery, or PowerShell 5.1 cleanup beyond MAX_PATH. Fix: Update bilingual tool-contract documentation and golden provider fixtures, add compatibility/cache/recovery tests, and make the native WebView2 smoke cleanup use extended-length .NET paths with a deterministic long-path self-test. Verification: go test ./..., go test -race ./internal/agent ./internal/control ./internal/turnevent, desktop go test ./..., frontend build and transcript tests, and Windows 11 ARM64 native smoke all pass. * perf(runtime): bound turn ledger writes and retention Problem: the turn ledger opened the sidecar for every event and retained an unbounded event history, while prompt and tool side effects could not observe persistence failures. Root cause: stream coalescing happened downstream from persistence, the ledger had no persistent turn writer or checkpoint format, and prompt resolution removed waiters before durable acknowledgement. Fix: move the single coalescer before the ledger, add checked side-effect barriers, reuse one WAL handle per turn, add schema-v2 checkpoints and projection acknowledgements, make Ask and approval resolution transactional, and expose content-free metrics and capacity benchmarks. Verification: go test ./...; go test -race ./internal/agent ./internal/control ./internal/turnevent ./internal/event; cd desktop && go test ./... * fix(frontend): recover compacted turn-event gaps Problem: a frontend cursor that falls behind a compacted ledger cannot distinguish retention from packet loss, and a full transcript reload can erase an active or optimistic tail. Root cause: the replay API returned only an array and the reducer had no explicit persisted-prefix boundary or runtime-epoch transaction. Fix: return paged replay metadata with reset fencing, queue live events during repair, rebase only the persisted history prefix with stable identities, and retain active turns, optimistic users, and live assistant output. Verification: frontend typecheck, pnpm test:transcript, replay-reset and cancel-reconcile tests, pnpm build, and desktop go test ./... * chore(lint): refresh turn-runtime quality baselines Problem: the repository lint gate still compared the expanded runtime ledger and replay owners against the pre-performance exact baselines. Fix: tighten new comments to the essay limits and refresh the mechanical per-file and repository totals after the two functional commits. This changes no runtime behavior. Verification: go run ./tools/repolint; go test ./internal/boot ./internal/control ./internal/turnevent; cd desktop && go test . -run TestTurnRuntimeAPIRoutesStopAnswerAndReplayByExactTurn -count=1 * fix(runtime): preserve ledger storage failures * chore(lint): align baseline with main-v2 * chore(runtime): satisfy ledger lint contracts * chore(lint): align merged transcript baseline Problem: the merged tree exceeded the test-file baseline by one line. Root cause: main-v2 added one navigation hydration assertion after the turn-runtime branch recorded its own test baseline. Fix: carry the exact combined per-file and repository totals without changing production code. Verification: go run ./tools/repolint reports a clean baseline. --------- Co-authored-by: Linearleaf <[email protected]>
152 lines
4.7 KiB
Go
152 lines
4.7 KiB
Go
package control
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
|
|
"reasonix/internal/event"
|
|
"reasonix/internal/permission"
|
|
)
|
|
|
|
type failingPromptAnswerSink struct{ err error }
|
|
|
|
func (s failingPromptAnswerSink) Emit(event.Event) {}
|
|
func (s failingPromptAnswerSink) EmitChecked(e event.Event) error {
|
|
if e.Kind == event.PromptAnswered {
|
|
return s.err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestApprovalResolutionRemainsPendingWhenPersistenceFails(t *testing.T) {
|
|
m := newApprovalManager(permission.Policy{}, ToolApprovalAsk, 0)
|
|
id, reply := m.register("write_file", "settings.json", "test")
|
|
want := errors.New("ledger unavailable")
|
|
|
|
if _, ok, err := m.resolveAfter(id, func(p pendingApproval) error {
|
|
if p.reply != reply {
|
|
t.Fatal("persistence callback received a different pending approval")
|
|
}
|
|
return want
|
|
}); !errors.Is(err, want) || ok {
|
|
t.Fatalf("first resolve = ok:%v err:%v, want retryable persistence failure", ok, err)
|
|
}
|
|
if got := m.peek(id); got.reply != reply {
|
|
t.Fatal("failed persistence removed the pending approval")
|
|
}
|
|
if got, ok, err := m.resolveAfter(id, nil); err != nil || !ok || got.reply != reply {
|
|
t.Fatalf("retry resolve = pending:%+v ok:%v err:%v", got, ok, err)
|
|
}
|
|
}
|
|
|
|
func TestAskResolutionRemainsPendingWhenPersistenceFails(t *testing.T) {
|
|
m := newApprovalManager(permission.Policy{}, ToolApprovalAsk, 0)
|
|
id, reply := m.registerAsk(askProbeQuestions())
|
|
m.markAskEmitted(id)
|
|
want := errors.New("ledger unavailable")
|
|
|
|
if _, ok, err := m.resolveAskAfter(id, func(p pendingAsk) error {
|
|
if p.reply != reply {
|
|
t.Fatal("persistence callback received a different pending ask")
|
|
}
|
|
return want
|
|
}); !errors.Is(err, want) || ok {
|
|
t.Fatalf("first answer = ok:%v err:%v, want retryable persistence failure", ok, err)
|
|
}
|
|
if _, pending := m.snapshotPrompts(); len(pending) != 1 || pending[0].ID != id {
|
|
t.Fatalf("failed persistence removed ask %q: %+v", id, pending)
|
|
}
|
|
if got, ok, err := m.resolveAskAfter(id, nil); err != nil || !ok || got.reply != reply {
|
|
t.Fatalf("retry answer = pending:%+v ok:%v err:%v", got, ok, err)
|
|
}
|
|
}
|
|
|
|
func TestClearKindDropsResolutionReservation(t *testing.T) {
|
|
m := newApprovalManager(permission.Policy{}, ToolApprovalAsk, 0)
|
|
id, _ := m.registerDecisionKind("recovery", "", "", true, true, "recovery", nil)
|
|
m.approvalResolutions[id] = newPromptResolution()
|
|
|
|
m.clearKind("recovery")
|
|
if _, ok := m.approvalResolutions[id]; ok {
|
|
t.Fatal("clearKind left a stale two-phase resolution reservation")
|
|
}
|
|
}
|
|
|
|
func TestConcurrentDuplicateAskWaitsForSamePersistenceResult(t *testing.T) {
|
|
m := newApprovalManager(permission.Policy{}, ToolApprovalAsk, 0)
|
|
id, _ := m.registerAsk(askProbeQuestions())
|
|
m.markAskEmitted(id)
|
|
want := errors.New("ledger unavailable")
|
|
entered := make(chan struct{})
|
|
release := make(chan struct{})
|
|
firstDone := make(chan error, 1)
|
|
secondDone := make(chan error, 1)
|
|
|
|
go func() {
|
|
_, _, err := m.resolveAskAfter(id, func(pendingAsk) error {
|
|
close(entered)
|
|
<-release
|
|
return want
|
|
})
|
|
firstDone <- err
|
|
}()
|
|
<-entered
|
|
m.mu.Lock()
|
|
attempt := m.askResolutions[id]
|
|
m.mu.Unlock()
|
|
if attempt == nil {
|
|
t.Fatal("first answer did not reserve the prompt")
|
|
}
|
|
go func() {
|
|
_, _, err := m.resolveAskAfter(id, nil)
|
|
secondDone <- err
|
|
}()
|
|
<-attempt.joined
|
|
close(release)
|
|
if err := <-firstDone; !errors.Is(err, want) {
|
|
t.Fatalf("first answer error = %v, want %v", err, want)
|
|
}
|
|
if err := <-secondDone; !errors.Is(err, want) {
|
|
t.Fatalf("duplicate answer error = %v, want same %v", err, want)
|
|
}
|
|
if _, pending := m.snapshotPrompts(); len(pending) != 1 || pending[0].ID != id {
|
|
t.Fatalf("failed duplicate transaction did not remain retryable: %+v", pending)
|
|
}
|
|
}
|
|
|
|
func TestApproveCheckedReturnsPersistenceFailureWithoutReleasingTool(t *testing.T) {
|
|
want := errors.New("ledger unavailable")
|
|
c := &Controller{
|
|
sink: failingPromptAnswerSink{err: want},
|
|
approval: newApprovalManager(permission.Policy{}, ToolApprovalAsk, 0),
|
|
}
|
|
id, reply := c.approval.register("bash", "write output", "test")
|
|
if err := c.approveChecked(id, true, false, false); !errors.Is(err, want) {
|
|
t.Fatalf("approveChecked error = %v, want %v", err, want)
|
|
}
|
|
if got := c.approval.peek(id); got.reply != reply {
|
|
t.Fatal("failed answer persistence removed the pending approval")
|
|
}
|
|
select {
|
|
case <-reply:
|
|
t.Fatal("tool resumed before PromptAnswered was durable")
|
|
default:
|
|
}
|
|
|
|
c.sink = event.Discard
|
|
if err := c.approveChecked(id, true, false, false); err != nil {
|
|
t.Fatalf("retry approveChecked: %v", err)
|
|
}
|
|
select {
|
|
case resolved := <-reply:
|
|
if !resolved.allow {
|
|
t.Fatal("retry did not preserve approval outcome")
|
|
}
|
|
default:
|
|
t.Fatal("durable retry did not release the tool")
|
|
}
|
|
if err := c.approveChecked(id, true, false, false); err != nil {
|
|
t.Fatalf("duplicate approval was not idempotent: %v", err)
|
|
}
|
|
}
|