Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3529,7 +3529,7 @@ export default function ChatView(props: ChatViewProps) {
live: liveWorktreeSetup,
recorded: recordedWorktreeSetup,
turnStarted: activeThread?.latestTurn?.startedAt != null,
isWorking,
followUpSent: (serverMessages?.filter((message) => message.role === "user").length ?? 0) > 1,
Comment thread
juliusmarminge marked this conversation as resolved.
Resolved
});
// 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