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
42 changes: 41 additions & 1 deletion apps/server/src/t3x/autoResume/Reactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ const readModel = (o: {
messages?: Array<{ id: string; role: string }>;
status?: string;
latestTurnId?: string;
/** Explicit latestTurn override; pass null to model an idle thread whose
* projection row has no latest_turn_id (see radroid/t3code#6). */
latestTurn?: { turnId: string; state: string } | null;
}): OrchestrationReadModel =>
({
snapshotSequence: 1,
Expand All @@ -51,7 +54,10 @@ const readModel = (o: {
settledOverride: null,
messages: o.messages ?? [{ id: "u1", role: "user" }],
activities: [],
latestTurn: { turnId: o.latestTurnId ?? "turn-1", state: "completed" },
latestTurn:
o.latestTurn !== undefined
? o.latestTurn
: { turnId: o.latestTurnId ?? "turn-1", state: "completed" },
session: { status: o.status ?? "ready", providerName: "claudeAgent" },
},
],
Expand Down Expand Up @@ -201,6 +207,40 @@ describe("AutoResumeReactor (integration)", () => {
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

// Regression for radroid/t3code#6 — the incident shape observed in production:
// the limit lands while the turn is RUNNING (baseline captures its id); by wake
// time the turn has settled and the projection row has no latest_turn_id, so the
// snapshot reports latestTurn: null. That must NOT read as "thread-advanced".
it.effect("resumes when the limited turn has settled away by wake time (latestTurn null)", () =>
Effect.gen(function* () {
const { dispatched, modelRef, deps } = yield* harness(
readModel({ status: "running", latestTurn: { turnId: "turn-1", state: "running" } }),
[rejectedEvent(100)],
);

yield* Effect.gen(function* () {
yield* settle; // detection schedules; baseline.latestTurnId === "turn-1"

// The limited turn settles and the session stops during the wait — the
// projection's latest_turn_id empties out, so the snapshot's latestTurn is null.
yield* Ref.set(modelRef, readModel({ status: "stopped", latestTurn: null }));

yield* advancePastResume;

const commands = yield* Ref.get(dispatched);
const turnStarts = commands.filter((c) => c.type === "thread.turn.start");
assert.strictEqual(turnStarts.length, 1, "the settled thread must resume, not cancel");
const summaries = commands
.filter((c) => c.type === "thread.activity.append")
.map((c) => (c as unknown as { activity: { summary: string } }).activity.summary);
assert.isFalse(
summaries.some((s) => s.includes("thread-advanced")),
"no thread-advanced cancellation may be posted",
);
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

it.effect("does NOT resume when the user takes over before the window reopens", () =>
Effect.gen(function* () {
const { dispatched, modelRef, deps } = yield* harness(readModel({}), [rejectedEvent(100)]);
Expand Down
19 changes: 19 additions & 0 deletions apps/server/src/t3x/autoResume/guards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,25 @@ describe("cancelReason", () => {
expect(cancelReason(thread, baseline())).toBe("thread-advanced");
});

// Regression for radroid/t3code#6: the projection populates latest_turn_id only while
// a turn is active, so a limit captured mid-turn (baseline has the running turn's id)
// always sees latestTurn: null once that turn settles. Null is "no active turn", not
// advancement — cancelling here killed every real-world resume.
it("does NOT treat a settled-away turn (latestTurn null at fire) as advancement", () => {
const thread = makeThread({
messages: [{ id: "u1", role: "user" }],
status: "stopped",
});
expect(thread.latestTurn).toBeNull();
expect(cancelReason(thread, baseline())).toBeNull();
});

it("still detects advancement when a turn exists but the baseline had none", () => {
const noTurnBaseline = captureBaseline(makeThread({ messages: [{ id: "u1", role: "user" }] }));
const thread = makeThread({ messages: [{ id: "u1", role: "user" }], latestTurnId: "turn-9" });
expect(cancelReason(thread, noTurnBaseline)).toBe("thread-advanced");
});

it("blocks when awaiting input", () => {
const thread = makeThread({
messages: [{ id: "u1", role: "user" }],
Expand Down
13 changes: 12 additions & 1 deletion apps/server/src/t3x/autoResume/guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,17 @@ export function cancelReason(
if (threadIsProgressing(thread)) return "progressing";
if (hasOpenBlockingRequest(thread.activities)) return "awaiting-input";
if (newestUserMessageId(thread) !== baseline.newestUserMessageId) return "user-took-over";
if ((thread.latestTurn?.turnId ?? null) !== baseline.latestTurnId) return "thread-advanced";
// Advancement needs POSITIVE evidence: a different, non-null turn id. The snapshot's
// `latestTurn` is joined on `projection_threads.latest_turn_id`, which is populated
// only while a turn is active — so a usage limit that lands mid-turn captures the
// running turn's id in the baseline, and by fire time (turn settled, session idle)
// the snapshot reports `latestTurn: null`. That null means "no active turn", not
// "the thread moved on"; treating it as advancement cancelled every real resume
// (radroid/t3code#6). A genuine user takeover is caught above via the newest user
// message; active work is caught by `progressing`.
const currentTurnId = thread.latestTurn?.turnId ?? null;
if (currentTurnId !== null && currentTurnId !== baseline.latestTurnId) {
return "thread-advanced";
}
return null;
}