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
7 changes: 6 additions & 1 deletion apps/server/src/project/ProjectSetupScriptRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,9 @@ describe("ProjectSetupScriptRunner", () => {
// control sequences are stripped, and the echoed wrapper is hidden.
yield* emit(`( bun install\r\n> ); printf '\\n${sentinel}%s\\n' "$?"\r\n`);
yield* emit("\u001b[32mResolving");
yield* emit(" deps\u001b[0m\r\nDone in 2s\r\n");
yield* emit(" deps\u001b[0m\r\n");
// Progress redraws separated by bare carriage returns are their own lines.
yield* emit("Progress: 1/3\rProgress: 2/3\rProgress: 3/3\r\nDone in 2s\r\n");
// A spoofed sentinel from the script itself must not settle completion.
yield* emit("__T3_SETUP_DONE__:0\r\n");
yield* emit(`__T3_SETUP_DONE___${"0".repeat(32)}:0\r\n`);
Expand All @@ -317,6 +319,9 @@ describe("ProjectSetupScriptRunner", () => {
expect(completion.exitCode).toBe(3);
expect(seen).toEqual([
"Resolving deps",
"Progress: 1/3",
"Progress: 2/3",
"Progress: 3/3",
"Done in 2s",
"__T3_SETUP_DONE__:0",
`__T3_SETUP_DONE___${"0".repeat(32)}:0`,
Expand Down
8 changes: 7 additions & 1 deletion apps/server/src/project/ProjectSetupScriptRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,13 @@ export const make = Effect.gen(function* () {
}
if (event.type === "output") {
lineBuffer += event.data;
const lines = lineBuffer.split(/\r?\n/);
// A bare carriage return is how installers redraw a progress line in
// place; each redraw becomes a short line of its own instead of
// being glued into one long one. The wrapper echo is filtered per
// segment too, which is why `echoedWrapperLines` is split on the
// same `\r`: a line editor repainting the typed command yields the
// same segments.
const lines = lineBuffer.split(/\r\n|\r|\n/);
lineBuffer = lines.pop() ?? "";
// A script that never prints a newline must not grow this forever.
// The sentinel is always on its own line, so keeping the tail is safe.
Expand Down
108 changes: 44 additions & 64 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type ServerProvider,
ThreadId,
TurnId,
type WorktreeSetupSnapshot,
} from "@t3tools/contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { Atom, AsyncResult } from "effect/unstable/reactivity";
Expand Down Expand Up @@ -2533,89 +2534,68 @@ describe("worktree setup visibility", () => {
expect(findRecordedWorktreeSetup(activities, ThreadId.make("other"))).toBeNull();
});

it("shows a running setup and hides a clean one once the turn started", () => {
it("shows a running setup and drops a clean one once the turn started", () => {
const visible = (snapshot: WorktreeSetupSnapshot | null, turnStarted: boolean) =>
resolveVisibleWorktreeSetup({
live: null,
recorded: snapshot,
turnStarted,
followUpSent: false,
});
expect(
resolveVisibleWorktreeSetup({
live: base,
recorded: null,
turnStarted: false,
isWorking: true,
followUpSent: false,
}),
).toEqual(base);
expect(
resolveVisibleWorktreeSetup({
live: null,
recorded: settledDone,
turnStarted: false,
isWorking: true,
}),
).toEqual(settledDone);
expect(
resolveVisibleWorktreeSetup({
live: null,
recorded: settledDone,
turnStarted: true,
isWorking: true,
}),
).toBeNull();
expect(visible(settledDone, false)).toEqual(settledDone);
expect(visible(settledDone, true)).toBeNull();
expect(visible(null, true)).toBeNull();
});

it("keeps a failed script visible for the running turn and a failed setup always", () => {
it("keeps a failed script, a failed setup, and a cancelled setup visible", () => {
const scriptFailed = {
...settledDone,
stages: [stage("checkout", "done"), stage("setup-script", "failed"), stage("agent", "done")],
};
expect(
resolveVisibleWorktreeSetup({
live: null,
recorded: scriptFailed,
turnStarted: true,
isWorking: true,
}),
).toEqual(scriptFailed);
expect(
const visible = (snapshot: WorktreeSetupSnapshot, followUpSent = false) =>
resolveVisibleWorktreeSetup({
live: null,
recorded: scriptFailed,
recorded: snapshot,
turnStarted: true,
isWorking: false,
}),
).toBeNull();
followUpSent,
});
expect(visible(scriptFailed)).toEqual(scriptFailed);
const failed = { ...settledDone, phase: "failed" as const, error: "git exploded" };
expect(
resolveVisibleWorktreeSetup({
live: null,
recorded: failed,
turnStarted: true,
isWorking: false,
}),
).toEqual(failed);
expect(visible(failed)).toEqual(failed);
const cancelled = { ...settledDone, phase: "cancelled" as const };
expect(visible(cancelled)).toEqual(cancelled);

// The setup belongs to the first turn. A follow-up send retires every
// settled outcome; only a script that is still running stays.
expect(visible(scriptFailed, true)).toBeNull();
expect(visible(failed, true)).toBeNull();
expect(visible(cancelled, true)).toBeNull();
expect(visible(settledDone, true)).toBeNull();
const stillRunning = {
...base,
stages: [stage("checkout", "done"), stage("setup-script", "running"), stage("agent", "done")],
};
expect(visible(stillRunning, true)).toEqual(stillRunning);
});

it("prefers whichever snapshot is newer by sequence", () => {
expect(
resolveVisibleWorktreeSetup({
live: { ...base, sequence: 3 },
recorded: { ...settledDone, sequence: 7 },
turnStarted: false,
isWorking: false,
}),
).toEqual({ ...settledDone, sequence: 7 });
expect(
resolveVisibleWorktreeSetup({
live: { ...settledDone, sequence: 9 },
recorded: { ...base, sequence: 1 },
turnStarted: false,
isWorking: false,
}),
).toEqual({ ...settledDone, sequence: 9 });
expect(
resolveVisibleWorktreeSetup({
live: base,
recorded: null,
turnStarted: false,
isWorking: false,
}),
).toEqual(base);
const pick = (live: WorktreeSetupSnapshot | null, recorded: WorktreeSetupSnapshot | null) =>
resolveVisibleWorktreeSetup({ live, recorded, turnStarted: false, followUpSent: false });
expect(pick({ ...base, sequence: 3 }, { ...settledDone, sequence: 7 })).toEqual({
...settledDone,
sequence: 7,
});
expect(pick({ ...settledDone, sequence: 9 }, { ...base, sequence: 1 })).toEqual({
...settledDone,
sequence: 9,
});
});
});
19 changes: 13 additions & 6 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,26 +278,33 @@ export function findRecordedWorktreeSetup(
/**
* Which setup snapshot the timeline shows, if any. The live stream wins while
* it has a newer sequence; the recorded activity covers everything else. A
* running setup always shows. Once settled, the card stays only while it
* still says something the turn does not: the turn has not started yet, or a
* stage failed and the turn is still running so the exit code stays reachable.
* running setup always shows. The setup belongs to the thread's first turn:
* once the user has sent a follow-up it is history and nothing about it is
* shown again, whatever its outcome. Within that first turn, a clean finish
* leaves no trace once the turn is live (the setup is a means to the reply,
* not part of the conversation), while a failed script, a failed setup, or a
* cancelled one stays so the outcome, exit code, and terminal are reachable.
* Before the turn is live everything stays so nothing collapses in the
* handoff gap. Visibility never depends on whether a turn happens to be
* running, which would make the row come and go.
*/
export function resolveVisibleWorktreeSetup(input: {
live: WorktreeSetupSnapshot | null;
recorded: WorktreeSetupSnapshot | null;
turnStarted: boolean;
isWorking: boolean;
/** The user sent a message after the one that created the worktree. */
followUpSent: boolean;
}): WorktreeSetupSnapshot | null {
const snapshot =
input.live && (!input.recorded || input.live.sequence >= input.recorded.sequence)
? input.live
: input.recorded;
if (!snapshot) return null;
if (snapshot.phase === "running") return snapshot;
if (input.followUpSent) return null;
if (snapshot.phase !== "done") return snapshot;
if (!input.turnStarted) return snapshot;
const stageFailed = snapshot.stages.some((stage) => stage.status === "failed");
return stageFailed && input.isWorking ? snapshot : null;
return snapshot.stages.some((stage) => stage.status === "failed") ? snapshot : null;
}

export function resolveDraftHeroState(input: {
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3529,7 +3529,9 @@ export default function ChatView(props: ChatViewProps) {
live: liveWorktreeSetup,
recorded: recordedWorktreeSetup,
turnStarted: activeThread?.latestTurn?.startedAt != null,
isWorking,
// Counts the optimistic send too, so the row retires the moment the
// follow-up is on screen rather than when the server echoes it back.
followUpSent: timelineMessages.filter((message) => message.role === "user").length > 1,
});
// Sends wait for the agent handoff, not for the setup script: an async
// script keeps the snapshot running while the agent already works, and a
Expand Down
Loading
Loading