mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 02:46:29 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95752e54e6 |
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
|
||||
import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
collectAssistantTurnContentForStreamRenderStrategy,
|
||||
orderHeadForStreamRenderStrategy,
|
||||
orderTailForStreamRenderStrategy,
|
||||
type StreamStrategy,
|
||||
@@ -311,34 +310,6 @@ describe("layoutStream", () => {
|
||||
expect(footerOwners(layout)).toEqual([assistant.id]);
|
||||
});
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"copies every assistant block when a completed turn spans history and live head on %s",
|
||||
(platform) => {
|
||||
const strategy = strategyFor(platform);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [
|
||||
userMessage("u1", 1),
|
||||
assistantMessage("first block", 2, { groupId: "turn-1", index: 0 }),
|
||||
assistantMessage("second block", 3, { groupId: "turn-1", index: 1 }),
|
||||
],
|
||||
head: [assistantMessage("final block", 4, { groupId: "turn-1", index: 2 })],
|
||||
});
|
||||
const footer = layout.auxiliaryTurnFooter;
|
||||
|
||||
expect(footer).not.toBeNull();
|
||||
expect(
|
||||
collectAssistantTurnContentForStreamRenderStrategy({
|
||||
strategy,
|
||||
items: footer!.items,
|
||||
startIndex: footer!.startIndex,
|
||||
precedingItems: footer!.precedingItems,
|
||||
precedingStartIndex: footer!.precedingStartIndex,
|
||||
}),
|
||||
).toBe("first block\n\nsecond block\n\nfinal block");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"places inline footer after trailing visible tool rows before the next user on %s",
|
||||
(platform) => {
|
||||
|
||||
@@ -10,8 +10,6 @@ export interface TurnFooterHost {
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
precedingItems: StreamItem[] | null;
|
||||
precedingStartIndex: number | null;
|
||||
}
|
||||
|
||||
export interface StreamLayoutItem {
|
||||
@@ -68,16 +66,12 @@ function createTurnFooterHost(input: {
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
precedingItems?: StreamItem[] | null;
|
||||
precedingStartIndex?: number | null;
|
||||
}): TurnFooterHost {
|
||||
return {
|
||||
itemId: input.item.id,
|
||||
items: input.items,
|
||||
timing: input.timingByAssistantId.get(input.item.id),
|
||||
startIndex: input.index,
|
||||
precedingItems: input.precedingItems ?? null,
|
||||
precedingStartIndex: input.precedingStartIndex ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,11 +141,6 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
|
||||
items: assistant.items,
|
||||
index: assistant.index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
precedingItems: footerItems === input.liveHead ? input.history : null,
|
||||
precedingStartIndex:
|
||||
footerItems === input.liveHead
|
||||
? input.strategy.getHistoryLiveBoundaryIndex(input.history)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -185,8 +174,6 @@ function resolveCompletedFooter(input: {
|
||||
items: assistant.items,
|
||||
index: assistant.index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
precedingItems: input.boundaryAboveItems,
|
||||
precedingStartIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -272,25 +272,8 @@ export function collectAssistantTurnContentForStreamRenderStrategy(params: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
startIndex: number;
|
||||
precedingItems?: StreamItem[] | null;
|
||||
precedingStartIndex?: number | null;
|
||||
}): string {
|
||||
const content = params.strategy.collectAssistantTurnContent(params.items, params.startIndex);
|
||||
if (!params.precedingItems || params.precedingStartIndex == null) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const precedingContent = params.strategy.collectAssistantTurnContent(
|
||||
params.precedingItems,
|
||||
params.precedingStartIndex,
|
||||
);
|
||||
if (!precedingContent) {
|
||||
return content;
|
||||
}
|
||||
if (!content) {
|
||||
return precedingContent;
|
||||
}
|
||||
return `${precedingContent}\n\n${content}`;
|
||||
return params.strategy.collectAssistantTurnContent(params.items, params.startIndex);
|
||||
}
|
||||
|
||||
export function isNearBottomForStreamRenderStrategy(
|
||||
|
||||
@@ -3,6 +3,8 @@ import { View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
collectAssistantTurnContentForStreamRenderStrategy,
|
||||
type StreamStrategy,
|
||||
@@ -60,7 +62,9 @@ export const TurnFooter = memo(function TurnFooter({
|
||||
return (
|
||||
<CompletedTurnFooterRow
|
||||
strategy={strategy}
|
||||
host={host}
|
||||
items={host.items}
|
||||
timing={host.timing}
|
||||
startIndex={host.startIndex}
|
||||
supportsTimelineCursor={supportsTimelineCursor}
|
||||
onForkAssistantTurn={onForkAssistantTurn}
|
||||
/>
|
||||
@@ -69,12 +73,16 @@ export const TurnFooter = memo(function TurnFooter({
|
||||
|
||||
export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
|
||||
strategy,
|
||||
host,
|
||||
items,
|
||||
timing,
|
||||
startIndex,
|
||||
supportsTimelineCursor,
|
||||
onForkAssistantTurn,
|
||||
}: {
|
||||
strategy: TurnContentStrategy;
|
||||
host: TurnFooterHost;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
supportsTimelineCursor: boolean;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}) {
|
||||
@@ -82,7 +90,9 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
|
||||
<TurnFooterRow>
|
||||
<CompletedTurnFooter
|
||||
strategy={strategy}
|
||||
host={host}
|
||||
items={items}
|
||||
timing={timing}
|
||||
startIndex={startIndex}
|
||||
supportsTimelineCursor={supportsTimelineCursor}
|
||||
onForkAssistantTurn={onForkAssistantTurn}
|
||||
/>
|
||||
@@ -123,12 +133,16 @@ function RunningTurnFooter({ inFlightTurnStartedAt }: { inFlightTurnStartedAt: D
|
||||
|
||||
function CompletedTurnFooter({
|
||||
strategy,
|
||||
host,
|
||||
items,
|
||||
timing,
|
||||
startIndex,
|
||||
supportsTimelineCursor,
|
||||
onForkAssistantTurn,
|
||||
}: {
|
||||
strategy: TurnContentStrategy;
|
||||
host: TurnFooterHost;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
supportsTimelineCursor: boolean;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}) {
|
||||
@@ -136,16 +150,14 @@ function CompletedTurnFooter({
|
||||
() =>
|
||||
collectAssistantTurnContentForStreamRenderStrategy({
|
||||
strategy,
|
||||
items: host.items,
|
||||
startIndex: host.startIndex,
|
||||
precedingItems: host.precedingItems,
|
||||
precedingStartIndex: host.precedingStartIndex,
|
||||
items,
|
||||
startIndex,
|
||||
}),
|
||||
[strategy, host],
|
||||
[strategy, items, startIndex],
|
||||
);
|
||||
const boundary = resolveAssistantTurnForkBoundary({
|
||||
items: host.items,
|
||||
startIndex: host.startIndex,
|
||||
items,
|
||||
startIndex,
|
||||
supportsTimelineCursor,
|
||||
});
|
||||
const handleFork = useCallback(
|
||||
@@ -161,8 +173,8 @@ function CompletedTurnFooter({
|
||||
<View style={stylesheet.turnFooterSlot}>
|
||||
<AssistantTurnFooter
|
||||
getContent={getContent}
|
||||
completedAt={host.timing?.completedAt}
|
||||
durationMs={host.timing?.durationMs}
|
||||
completedAt={timing?.completedAt}
|
||||
durationMs={timing?.durationMs}
|
||||
onFork={boundary && onForkAssistantTurn ? handleFork : undefined}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -153,7 +153,9 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
const footer = footerHost ? (
|
||||
<CompletedTurnFooterRow
|
||||
strategy={input.strategy}
|
||||
host={footerHost}
|
||||
items={footerHost.items}
|
||||
timing={footerHost.timing}
|
||||
startIndex={footerHost.startIndex}
|
||||
supportsTimelineCursor={input.supportsTimelineCursor}
|
||||
onForkAssistantTurn={input.onForkAssistantTurn}
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,7 @@ describe("existing run workspace resolution", () => {
|
||||
await expect(resolveExistingRunWorkspace({ fetchWorkspaces }, "workspace-2")).resolves.toEqual({
|
||||
id: "workspace-2",
|
||||
cwd: "/workspace/two",
|
||||
createdForRun: false,
|
||||
});
|
||||
expect(fetchWorkspaces).toHaveBeenCalledWith({
|
||||
filter: { query: "workspace-2" },
|
||||
|
||||
@@ -504,6 +504,32 @@ async function connectToDaemonOrThrow(
|
||||
interface RunWorkspace {
|
||||
id?: string;
|
||||
cwd: string;
|
||||
createdForRun: boolean;
|
||||
}
|
||||
|
||||
type CreateRunAgentInput = Parameters<ConnectedDaemonClient["createAgent"]>[0];
|
||||
|
||||
async function createAgentInRunWorkspace(
|
||||
client: ConnectedDaemonClient,
|
||||
workspace: RunWorkspace,
|
||||
input: CreateRunAgentInput,
|
||||
): Promise<AgentSnapshotPayload> {
|
||||
try {
|
||||
return await client.createAgent(input);
|
||||
} catch (error) {
|
||||
if (workspace.createdForRun && workspace.id) {
|
||||
try {
|
||||
const archived = await client.archiveWorkspace(workspace.id);
|
||||
if (archived.error) {
|
||||
console.error(`Warning: failed to clean up workspace ${workspace.id}: ${archived.error}`);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
||||
console.error(`Warning: failed to clean up workspace ${workspace.id}: ${message}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunWorkspaceLookupClient {
|
||||
@@ -523,7 +549,7 @@ export async function resolveExistingRunWorkspace(
|
||||
});
|
||||
const workspace = result.entries.find((entry) => entry.id === workspaceId);
|
||||
if (workspace) {
|
||||
return { id: workspace.id, cwd: workspace.workspaceDirectory };
|
||||
return { id: workspace.id, cwd: workspace.workspaceDirectory, createdForRun: false };
|
||||
}
|
||||
|
||||
throw {
|
||||
@@ -551,7 +577,7 @@ async function resolveRunWorkspace(
|
||||
}
|
||||
|
||||
if (!newWorkspace && resolveRunCallerAgentId()) {
|
||||
return { cwd };
|
||||
return { cwd, createdForRun: false };
|
||||
}
|
||||
|
||||
const ambientWorkspaceId = newWorkspace ? undefined : process.env.PASEO_WORKSPACE_ID?.trim();
|
||||
@@ -578,7 +604,11 @@ async function resolveRunWorkspace(
|
||||
console.error(
|
||||
"Tip: pass --workspace <id> (or set PASEO_WORKSPACE_ID) to run in an existing workspace.",
|
||||
);
|
||||
return { id: result.workspace.id, cwd: result.workspace.workspaceDirectory ?? cwd };
|
||||
return {
|
||||
id: result.workspace.id,
|
||||
cwd: result.workspace.workspaceDirectory ?? cwd,
|
||||
createdForRun: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runRunCommand(
|
||||
@@ -627,7 +657,7 @@ export async function runRunCommand(
|
||||
|
||||
const callStructuredTurn = async (structuredPrompt: string): Promise<string> => {
|
||||
if (!structuredAgent) {
|
||||
structuredAgent = await client.createAgent({
|
||||
structuredAgent = await createAgentInRunWorkspace(client, workspace, {
|
||||
provider: resolvedProviderModel.provider,
|
||||
cwd: runCwd,
|
||||
workspaceId,
|
||||
@@ -698,7 +728,7 @@ export async function runRunCommand(
|
||||
}
|
||||
|
||||
// Create the agent
|
||||
const agent = await client.createAgent({
|
||||
const agent = await createAgentInRunWorkspace(client, workspace, {
|
||||
provider: resolvedProviderModel.provider,
|
||||
cwd: runCwd,
|
||||
workspaceId,
|
||||
|
||||
@@ -25,6 +25,7 @@ import { $ } from "zx";
|
||||
import { mkdtemp, rm, writeFile } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { createE2ETestContext } from "./helpers/test-daemon.ts";
|
||||
|
||||
$.verbose = false;
|
||||
|
||||
@@ -268,6 +269,56 @@ try {
|
||||
assert(output.includes("unknown option"), "should report unknown option for --isolation");
|
||||
console.log("✓ run --isolation is rejected\n");
|
||||
}
|
||||
|
||||
// Test 17: failed agent creation does not leave the run's workspace behind
|
||||
{
|
||||
console.log("Test 17: failed agent creation cleans up its workspace");
|
||||
const ctx = await createE2ETestContext({ timeout: 45000 });
|
||||
try {
|
||||
await $({ cwd: ctx.workDir })`git init -b main`;
|
||||
await $({ cwd: ctx.workDir })`git config user.email test@getpaseo.local`;
|
||||
await $({ cwd: ctx.workDir })`git config user.name "Paseo Test"`;
|
||||
await $({ cwd: ctx.workDir })`git commit --allow-empty -m initial`;
|
||||
|
||||
const before = await ctx.paseo(["workspace", "ls", "--json"]);
|
||||
assert.strictEqual(before.exitCode, 0, before.stderr);
|
||||
|
||||
const result = await ctx.paseo(
|
||||
[
|
||||
"run",
|
||||
"--provider",
|
||||
"claude",
|
||||
"--mode",
|
||||
"bypass",
|
||||
"--new-workspace",
|
||||
"worktree",
|
||||
"--worktree-mode",
|
||||
"branch-off",
|
||||
"--new-branch",
|
||||
"failed-run-workspace",
|
||||
"--base",
|
||||
"main",
|
||||
"--background",
|
||||
"do something",
|
||||
],
|
||||
{
|
||||
env: { PASEO_AGENT_ID: "", PASEO_WORKSPACE_ID: "" },
|
||||
},
|
||||
);
|
||||
assert.notStrictEqual(result.exitCode, 0, "invalid mode should reject agent creation");
|
||||
assert(
|
||||
`${result.stdout}\n${result.stderr}`.includes("Invalid mode 'bypass'"),
|
||||
`failure should reach provider mode validation\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
||||
);
|
||||
|
||||
const after = await ctx.paseo(["workspace", "ls", "--json"]);
|
||||
assert.strictEqual(after.exitCode, 0, after.stderr);
|
||||
assert.deepStrictEqual(JSON.parse(after.stdout), JSON.parse(before.stdout));
|
||||
} finally {
|
||||
await ctx.stop();
|
||||
}
|
||||
console.log("✓ failed agent creation cleans up its workspace\n");
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
|
||||
Reference in New Issue
Block a user