Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions packages/cli/src/repl/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3180,11 +3180,9 @@ export function sidekickChatRows(
// is the one that attempted it in this chat, and otherwise the saved
// turn's own identity. A response name is never *recovered* from a
// turn, and never carried across chats: the
// owner names a response from the history's own next ordinal, which
// counts saved requests as well as attempted turns, and Core numbers
// its turns independently — so an interrupted message is all it takes
// for the two to differ, and inferring one from the other hands this
// attempt somebody else's row.
// owner names a response for where it begins in the history, and Core
// numbers its turns independently — so inferring one from the other
// hands this attempt somebody else's row.
key:
addressed !== undefined &&
addressed.conversation === item.conversation &&
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/repl/sidekick/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1205,6 +1205,11 @@ export class SidekickWriter {
this.records = [...retained];
}

/** Records acknowledged so far, including the history loaded on reopen. */
get recordCount(): number {
return this.records.length;
}

/** Every record retained so far, in append order. */
// deno-lint-ignore require-yield
*readAll(): Operation<SidekickRecord[]> {
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/repl/sidekick/owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2219,7 +2219,15 @@ export function useSidekickOwner(input: SidekickOwnerInput): Operation<SidekickO
return current;
}
const turn = nextTurn(current.value, conversation);
const response = `response:${turn}`;
// Named for where this response begins in the history rather than for
// the message ordinal above or the turn it becomes. Neither number is
// unique to a response: an interrupted message keeps its ordinal while
// Core never counts its turn, and feedback adds turns and no messages.
// Every response that used its name retained a record after this point,
// so the next one starts further on — an unfinished one's controls
// included — and the `journal:` part keeps it apart from the numbered
// names earlier histories hold.
const response = `response:journal:${paired.writer.recordCount}`;
const at: ResponseAt = { conversation, response };
const written = controlJournal(paired.writer, at);
/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# 1234 legacy-orphan fixture — writer receipt

A paired Sidekick chat whose only response is numbered differently from the
canonical turn it ran in, produced by the writer that numbered responses from
the host's message ordinal.

- Writer SHA: `a45f6f1e3bf8af328e33294f9235b1e3c7981ae6`
- How: the production storage path, through four real subprocess phases of
`packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts` over a real
repository root — `chat-open`; `chat-say` with `stop: "turn"`, so the first
message is saved and its canonical Prompt is never written; `chat-say` with
`stopAtQuestion`; then a cold `chat-read`. Nothing was appended by hand.
- Source and transport are test-authored throughout: agent `stub`, native
conversation `native-1234-legacy`. No saved conversation of the user's and no
native credential is involved, and neither file names a host path.

## What it holds

`sidekick.jsonl` — 17 records, 29697 bytes, sha256
`620c73e51ec01f0ce0041617fcaa46cfe95085727024ea85c21e5e1709388889`

`entries.jsonl` — empty, sha256
`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`. The chat
submitted no entry.

Two saved messages, ordinals 1 (`interrupted request`, no canonical turn) and 2
(`prepare something`, canonical `turn:1`). The second message's reply filled
the draft with `<REPL.EntryInput>Legacy draft.</REPL.EntryInput>` and then asked
`<Elicit …>Which one?</Elicit>`; the process ended with the question pending.
So canonical `turn:1` holds, under `response:2`:

| record | order | component | outcome |
| --- | --- | --- | --- |
| `control` | 0 | `REPL.EntryInput` | `control-outcome` ok |
| `control` | 1 | `Elicit` | none |

There is no `response` record: the evaluation never finished. Its root
evaluation is `generated:response:2` on `generated_root:1`.

Read cold at the writer SHA, it is readable, asks the provider nothing and
appends nothing:

```
chat person(1, turn null) / person(2, turn 1) / saved(turn 1)
operations [{ response: "response:2", order: 0, component: "REPL.EntryInput", status: "ok" }]
unfinished [["Elicit", "unfinished"]]
```

A response named after its actual turn would call the next canonical turn's
response `response:2` and file its first control in this orphan's order-0
slot, which is why the fixture has to come from the old writer rather than
from the one under test.
Empty file.

Large diffs are not rendered by default.

110 changes: 110 additions & 0 deletions packages/cli/tests/repl-sidekick-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { parseDurableEvent } from "@executablemd/durable-streams";
import { AGENT_PROMPT } from "@executablemd/core/host";
import { coldReservations, retainResolution } from "../src/repl/sidekick/admission.ts";
import type { SidekickRecord } from "../src/repl/sidekick/history.ts";
import { projectSidekick } from "../src/repl/sidekick/model.ts";
import {
createSidekickAdmission,
SidekickOrigin,
Expand Down Expand Up @@ -1017,6 +1018,115 @@ describe("1151 — ordinary Send after a joined human Stop", () => {
expect(next.snapshot.message?.conversation).toBe(conversation);
});

it("A1b (1234): a post-Stop instruction that reads gets its own response, and its feedback names it", function* () {
const { held, effects } = yield* counted();
held.fake.script({ reply: SUBMIT });
held.fake.script({ deltas: ["<Output>Thinking", " further.</Output>"], holdAfter: 1 });
const dispatch = yield* stopping(held);
const first = yield* send(dispatch, "Run the counted effect.");
yield* held.fake.streamingTurns(1);
dispatch.hold();
yield* first.cancel("human");
expect((yield* first.completion).ok).toBe(false);
const stopped = yield* records(held.execution);
const slot = (one: { response: string; order: number }) => `${one.response}\0${one.order}`;
const oldSlots = stopped.flatMap((one) => (one.kind === "control" ? [slot(one)] : []));
const oldResponses = new Set(
stopped.flatMap((one) =>
one.kind === "control" || one.kind === "response" ? [one.response] : [],
),
);
expect(oldSlots.length).toBeGreaterThan(0);
expect(held.session.model.entries).toHaveLength(1);
expect(effects()).toBe(1);
const reading = held.owner.reading;
if (!reading.ok) {
throw reading.error;
}
const conversation = reading.value.active;
expect(reading.value.unestablished).toEqual([{ conversation, turn: null }]);

// The post-Stop instruction reads, and the automatic answer to that read
// asks for nothing.
const next = yield* send(dispatch, "Read the bindings after the counted result.");
expect(next.snapshot.state).toBe("queued");
held.fake.script({ reply: "<REPL.Bindings />" });
held.fake.script({ reply: "<Output>Read them.</Output>" });
expect((yield* dispatch.resume()).ok).toBe(true);
const sent = yield* next.completion;
if (!sent.ok) {
throw sent.error;
}
expect(held.fake.prompts).toHaveLength(4);

const after = yield* records(held.execution);
// The effect ran once and nothing was submitted again.
expect(held.session.model.entries).toHaveLength(1);
expect(effects()).toBe(1);

// The recovery decision is the existing one, used once, and it selected
// only the stopped response's controls.
const decisions = after.flatMap((one) => (one.kind === "recovery-decision" ? [one] : []));
expect(decisions.map((one) => [one.action, one.basis])).toEqual([
["continue-original", "joined-owned-exchange"],
]);
for (const control of decisions[0]?.controls ?? []) {
expect(oldSlots).toContain(slot(control));
}

// The read is the new instruction's response's own order-0 control: a name
// no stopped response used, at a slot no control already holds.
const reads = after.flatMap((one) =>
one.kind === "control" && one.component === "REPL.Bindings" ? [one] : [],
);
expect(reads).toHaveLength(1);
const reader = reads[0]!.response;
expect(reads[0]!.order).toBe(0);
expect(oldResponses.has(reader)).toBe(false);
const allSlots = after.flatMap((one) => (one.kind === "control" ? [slot(one)] : []));
expect(new Set(allSlots).size).toBe(allSlots.length);

// The new responses are attributed to the canonical turns they ran in, and
// the read's automatic feedback was delivered into the turn after it,
// against the response that read.
const responses = after.flatMap((one) =>
one.kind === "response" ? [{ response: one.response, turn: one.turn }] : [],
);
const fresh = responses.filter((one) => !oldResponses.has(one.response));
expect(fresh).toHaveLength(2);
expect(fresh[0]?.response).toBe(reader);
expect(fresh[1]?.response).not.toBe(reader);
expect(fresh[1]!.turn).toBe(fresh[0]!.turn + 1);
const claims = after.flatMap((one) =>
one.kind === "feedback" && one.turn !== null ? [[one.response, one.turn]] : [],
);
expect(claims.at(-1)).toEqual([reader, fresh[1]!.turn]);
expect(
after.every((one) => !("conversation" in one) || one.conversation === conversation),
).toBe(true);

// The old delivery stays unknown; the new one is established.
const settled = held.owner.reading;
if (!settled.ok) {
throw settled.error;
}
expect(settled.value.recoveries.map((one) => one.used)).toEqual([true]);
expect(settled.value.unestablished).toEqual([{ conversation, turn: null }]);
expect(settled.value.delivered).toEqual([{ conversation, turn: fresh[1]!.turn }]);

// Read again from the retained records alone, the same operations stand,
// and reading admitted nothing and asked the provider nothing.
const reopened = projectSidekick(after);
if (!reopened.ok) {
throw reopened.error;
}
expect(reopened.value.operations).toEqual(settled.value.operations);
expect(yield* records(held.execution)).toEqual(after);
expect(held.fake.prompts).toHaveLength(4);
expect(held.session.model.entries).toHaveLength(1);
expect(effects()).toBe(1);
});

it("A3: Resume lends no Stop consent to older UI or ACP work and keeps FIFO", function* () {
const { held } = yield* counted();
held.fake.script({ reply: SUBMIT, held: true });
Expand Down
51 changes: 32 additions & 19 deletions packages/cli/tests/repl-sidekick-journey.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,15 @@ function olderOrientation(
};
}

/** The one response a history attributes to this canonical turn, by its own name. */
function responseFor(all: readonly SidekickRecord[], turn: number): string {
const named = all.flatMap((one) =>
one.kind === "response" && one.turn === turn ? [one.response] : [],
);
expect(named).toHaveLength(1);
return named[0]!;
}

describe("Tier R5 — one message becomes one turn, and its reply runs", () => {
it("R5: the first turn of a conversation carries the orientation it needs", function* () {
const held = yield* useHarness();
Expand Down Expand Up @@ -1343,9 +1352,7 @@ describe("Tier PV — a response in flight, shown as it arrives", () => {
const after = yield* settledReading(repl);
// One response record for the turn that was being previewed, and exactly
// one: the item a person watched arrive is the item they are reading.
const answered = after.chat.filter(
(record) => record.kind === "response" && record.response === "response:1",
);
const answered = after.chat.filter((record) => record.kind === "response" && record.turn === 1);
expect(answered).toHaveLength(1);
expect(answered[0]).toMatchObject({ output: "Half a thought.", hasOutput: true });
// The entry really ran, and its feedback went back as the next turn's
Expand Down Expand Up @@ -2671,6 +2678,11 @@ describe("Tier R5 — one capture, and each response's own records", () => {
if (!reading.ok) {
throw reading.error;
}
// The failing response ran as turn 1, and the next message's as turn 3.
const retained = yield* records(held.execution);
const failing = responseFor(retained, 1);
const following = responseFor(retained, 3);
expect(following).not.toBe(failing);
// Each pair under the response that ran it, in the order that response
// admitted it — read off the retained history rather than counted from the
// prompts, because what is under test is which response a record belongs
Expand All @@ -2686,12 +2698,12 @@ describe("Tier R5 — one capture, and each response's own records", () => {
// Both of the failing response's own pairs, in the order it admitted
// them: the read that happened, then the fill that failed. A failure is
// recorded as the outcome it was rather than dropped.
{ response: "response:1", order: 0, component: "REPL.Bindings", status: "ok" },
{ response: "response:1", order: 1, component: "REPL.EntryInput", status: "failed" },
{ response: failing, order: 0, component: "REPL.Bindings", status: "ok" },
{ response: failing, order: 1, component: "REPL.EntryInput", status: "failed" },
// And the next response's control, independently associated and ordered
// from zero again — its own response rather than a continuation of the
// one that failed.
{ response: "response:3", order: 0, component: "REPL.Environment", status: "ok" },
{ response: following, order: 0, component: "REPL.Environment", status: "ok" },
]);
// One conversation throughout, so these are three responses of the same
// chat rather than of two chats.
Expand All @@ -2700,7 +2712,7 @@ describe("Tier R5 — one capture, and each response's own records", () => {
// is filed under the response that came after it.
expect(
reading.value.operations.filter(
(one) => one.response === "response:3" && one.component !== "REPL.Environment",
(one) => one.response === following && one.component !== "REPL.Environment",
),
).toEqual([]);

Expand Down Expand Up @@ -2958,15 +2970,18 @@ describe("Tier R5 — two live owners, and the controls each conversation captur
// conversation nowhere at all.
const oneRecords = yield* records(one.execution);
const otherRecords = yield* records(other.execution);
const oneReads = responseFor(oneRecords, 2);
const otherReads = responseFor(otherRecords, 2);
const otherAgain = responseFor(otherRecords, 4);
expect(controlsIn(oneRecords)).toEqual([
ownControl(one.owner, "response:2", 0, "REPL.Entry.Output"),
ownControl(one.owner, "response:2", 1, "REPL.EntryInput"),
ownControl(one.owner, oneReads, 0, "REPL.Entry.Output"),
ownControl(one.owner, oneReads, 1, "REPL.EntryInput"),
]);
expect(controlsIn(otherRecords)).toEqual([
ownControl(other.owner, "response:2", 0, "REPL.Entry.Output"),
ownControl(other.owner, "response:2", 1, "REPL.EntryInput"),
ownControl(other.owner, "response:4", 0, "REPL.Entry.Output"),
ownControl(other.owner, "response:4", 1, "REPL.EntryInput"),
ownControl(other.owner, otherReads, 0, "REPL.Entry.Output"),
ownControl(other.owner, otherReads, 1, "REPL.EntryInput"),
ownControl(other.owner, otherAgain, 0, "REPL.Entry.Output"),
ownControl(other.owner, otherAgain, 1, "REPL.EntryInput"),
]);
expect(JSON.stringify(oneRecords)).not.toContain(active(other.owner));
expect(JSON.stringify(otherRecords)).not.toContain(active(one.owner));
Expand Down Expand Up @@ -4622,9 +4637,9 @@ describe("993 mounted nested Output journeys", () => {
);
yield* page.until("the settled Sidekick", sidekickIdle);
const saved = yield* settledReading(repl);
expect(
saved.chat.filter((record) => record.kind === "response" && record.response === "response:1"),
).toEqual([expect.objectContaining({ output: "Review complete.", hasOutput: true })]);
expect(saved.chat.filter((record) => record.kind === "response" && record.turn === 1)).toEqual([
expect.objectContaining({ output: "Review complete.", hasOutput: true }),
]);
const known = yield* projected(saved.chat);
const rows = sidekickChatRows(known, undefined).filter(
(row) => row.kind === "reply" && row.turn === 1,
Expand Down Expand Up @@ -4680,9 +4695,7 @@ describe("993 mounted nested Output journeys", () => {
yield* savedReplies(page.repl, 1);
yield* page.until("the failed selected response", (reading) => reading.shows("Before."));
const saved = yield* settledReading(page.repl);
expect(
saved.chat.filter((record) => record.kind === "response" && record.response === "response:1"),
).toEqual([
expect(saved.chat.filter((record) => record.kind === "response" && record.turn === 1)).toEqual([
expect.objectContaining({
hasOutput: true,
output: "Before.",
Expand Down
Loading
Loading