mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 20:01:27 +00:00
Rename Foundry handoffs to tasks (#239)
* Restore foundry onboarding stack * Consolidate foundry rename * Create foundry tasks without prompts * Rename Foundry handoffs to tasks
This commit is contained in:
parent
d30cc0bcc8
commit
d75e8c31d1
281 changed files with 9242 additions and 4356 deletions
142
foundry/packages/frontend/src/features/sessions/model.test.ts
Normal file
142
foundry/packages/frontend/src/features/sessions/model.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { SandboxSessionRecord } from "@sandbox-agent/foundry-client";
|
||||
import { buildTranscript, extractEventText, resolveSessionSelection } from "./model";
|
||||
|
||||
describe("extractEventText", () => {
|
||||
it("extracts prompt text arrays", () => {
|
||||
expect(extractEventText({ params: { prompt: [{ type: "text", text: "hello" }] } })).toBe("hello");
|
||||
});
|
||||
|
||||
it("falls back to method name", () => {
|
||||
expect(extractEventText({ method: "session/started" })).toBe("session/started");
|
||||
});
|
||||
|
||||
it("extracts agent result text when present", () => {
|
||||
expect(
|
||||
extractEventText({
|
||||
result: {
|
||||
text: "agent output",
|
||||
},
|
||||
}),
|
||||
).toBe("agent output");
|
||||
});
|
||||
|
||||
it("extracts text from chunked session updates", () => {
|
||||
expect(
|
||||
extractEventText({
|
||||
params: {
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "chunk",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("chunk");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTranscript", () => {
|
||||
it("maps sender/text/timestamp for UI transcript rendering", () => {
|
||||
const rows = buildTranscript([
|
||||
{
|
||||
id: "evt-1",
|
||||
eventIndex: 1,
|
||||
sessionId: "sess-1",
|
||||
createdAt: 1000,
|
||||
connectionId: "conn-1",
|
||||
sender: "client",
|
||||
payload: { params: { prompt: [{ type: "text", text: "hello" }] } },
|
||||
},
|
||||
{
|
||||
id: "evt-2",
|
||||
eventIndex: 2,
|
||||
sessionId: "sess-1",
|
||||
createdAt: 2000,
|
||||
connectionId: "conn-1",
|
||||
sender: "agent",
|
||||
payload: { params: { text: "world" } },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
id: "evt-1",
|
||||
sender: "client",
|
||||
text: "hello",
|
||||
createdAt: 1000,
|
||||
},
|
||||
{
|
||||
id: "evt-2",
|
||||
sender: "agent",
|
||||
text: "world",
|
||||
createdAt: 2000,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSessionSelection", () => {
|
||||
const session = (id: string, status: "running" | "idle" | "error" = "running"): SandboxSessionRecord =>
|
||||
({
|
||||
id,
|
||||
agentSessionId: `agent-${id}`,
|
||||
lastConnectionId: `conn-${id}`,
|
||||
createdAt: 1,
|
||||
status,
|
||||
}) as SandboxSessionRecord;
|
||||
|
||||
it("prefers explicit selection when present in session list", () => {
|
||||
const resolved = resolveSessionSelection({
|
||||
explicitSessionId: "session-2",
|
||||
taskSessionId: "session-1",
|
||||
sessions: [session("session-1"), session("session-2")],
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({
|
||||
sessionId: "session-2",
|
||||
staleSessionId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to task session when explicit selection is missing", () => {
|
||||
const resolved = resolveSessionSelection({
|
||||
explicitSessionId: null,
|
||||
taskSessionId: "session-1",
|
||||
sessions: [session("session-1")],
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({
|
||||
sessionId: "session-1",
|
||||
staleSessionId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the newest available session when configured session IDs are stale", () => {
|
||||
const resolved = resolveSessionSelection({
|
||||
explicitSessionId: null,
|
||||
taskSessionId: "session-stale",
|
||||
sessions: [session("session-fresh")],
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({
|
||||
sessionId: "session-fresh",
|
||||
staleSessionId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks stale session when no sessions are available", () => {
|
||||
const resolved = resolveSessionSelection({
|
||||
explicitSessionId: null,
|
||||
taskSessionId: "session-stale",
|
||||
sessions: [],
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({
|
||||
sessionId: null,
|
||||
staleSessionId: "session-stale",
|
||||
});
|
||||
});
|
||||
});
|
||||
137
foundry/packages/frontend/src/features/sessions/model.ts
Normal file
137
foundry/packages/frontend/src/features/sessions/model.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import type { SandboxSessionEventRecord } from "@sandbox-agent/foundry-client";
|
||||
import type { SandboxSessionRecord } from "@sandbox-agent/foundry-client";
|
||||
|
||||
function fromPromptArray(value: unknown): string | null {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== "object") {
|
||||
continue;
|
||||
}
|
||||
const text = (item as { text?: unknown }).text;
|
||||
if (typeof text === "string" && text.trim().length > 0) {
|
||||
parts.push(text.trim());
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join("\n") : null;
|
||||
}
|
||||
|
||||
function fromSessionUpdate(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const update = value as {
|
||||
content?: unknown;
|
||||
sessionUpdate?: unknown;
|
||||
};
|
||||
if (update.sessionUpdate !== "agent_message_chunk") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = update.content;
|
||||
if (!content || typeof content !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = (content as { text?: unknown }).text;
|
||||
return typeof text === "string" ? text : null;
|
||||
}
|
||||
|
||||
export function extractEventText(payload: unknown): string {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return String(payload ?? "");
|
||||
}
|
||||
|
||||
const envelope = payload as {
|
||||
method?: unknown;
|
||||
params?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
const params = envelope.params;
|
||||
if (params && typeof params === "object") {
|
||||
const updateText = fromSessionUpdate((params as { update?: unknown }).update);
|
||||
if (typeof updateText === "string") {
|
||||
return updateText;
|
||||
}
|
||||
|
||||
const text = (params as { text?: unknown }).text;
|
||||
if (typeof text === "string" && text.trim().length > 0) {
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
const prompt = fromPromptArray((params as { prompt?: unknown }).prompt);
|
||||
if (prompt) {
|
||||
return prompt;
|
||||
}
|
||||
}
|
||||
|
||||
const result = envelope.result;
|
||||
if (result && typeof result === "object") {
|
||||
const text = (result as { text?: unknown }).text;
|
||||
if (typeof text === "string" && text.trim().length > 0) {
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (envelope.error) {
|
||||
return JSON.stringify(envelope.error, null, 2);
|
||||
}
|
||||
|
||||
if (typeof envelope.method === "string") {
|
||||
return envelope.method;
|
||||
}
|
||||
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
export function buildTranscript(events: SandboxSessionEventRecord[]): Array<{
|
||||
id: string;
|
||||
sender: "client" | "agent";
|
||||
text: string;
|
||||
createdAt: number;
|
||||
}> {
|
||||
return events.map((event) => ({
|
||||
id: event.id,
|
||||
sender: event.sender,
|
||||
text: extractEventText(event.payload),
|
||||
createdAt: event.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolveSessionSelection(input: { explicitSessionId: string | null; taskSessionId: string | null; sessions: SandboxSessionRecord[] }): {
|
||||
sessionId: string | null;
|
||||
staleSessionId: string | null;
|
||||
} {
|
||||
const sessionIds = new Set(input.sessions.map((session) => session.id));
|
||||
const hasSession = (id: string | null): id is string => Boolean(id && sessionIds.has(id));
|
||||
|
||||
if (hasSession(input.explicitSessionId)) {
|
||||
return { sessionId: input.explicitSessionId, staleSessionId: null };
|
||||
}
|
||||
|
||||
if (hasSession(input.taskSessionId)) {
|
||||
return { sessionId: input.taskSessionId, staleSessionId: null };
|
||||
}
|
||||
|
||||
const fallbackSessionId = input.sessions[0]?.id ?? null;
|
||||
if (fallbackSessionId) {
|
||||
return { sessionId: fallbackSessionId, staleSessionId: null };
|
||||
}
|
||||
|
||||
if (input.explicitSessionId) {
|
||||
return { sessionId: null, staleSessionId: input.explicitSessionId };
|
||||
}
|
||||
|
||||
if (input.taskSessionId) {
|
||||
return { sessionId: null, staleSessionId: input.taskSessionId };
|
||||
}
|
||||
|
||||
return { sessionId: null, staleSessionId: null };
|
||||
}
|
||||
84
foundry/packages/frontend/src/features/tasks/model.test.ts
Normal file
84
foundry/packages/frontend/src/features/tasks/model.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { TaskRecord } from "@sandbox-agent/foundry-shared";
|
||||
import { formatDiffStat, groupTasksByRepo } from "./model";
|
||||
|
||||
const base: TaskRecord = {
|
||||
workspaceId: "default",
|
||||
repoId: "repo-a",
|
||||
repoRemote: "https://example.com/repo-a.git",
|
||||
taskId: "task-1",
|
||||
branchName: "feature/one",
|
||||
title: "Feature one",
|
||||
task: "Ship one",
|
||||
providerId: "daytona",
|
||||
status: "running",
|
||||
statusMessage: null,
|
||||
activeSandboxId: "sandbox-1",
|
||||
activeSessionId: "session-1",
|
||||
sandboxes: [
|
||||
{
|
||||
sandboxId: "sandbox-1",
|
||||
providerId: "daytona",
|
||||
sandboxActorId: null,
|
||||
switchTarget: "daytona://sandbox-1",
|
||||
cwd: null,
|
||||
createdAt: 10,
|
||||
updatedAt: 10,
|
||||
},
|
||||
],
|
||||
agentType: null,
|
||||
prSubmitted: false,
|
||||
diffStat: null,
|
||||
prUrl: null,
|
||||
prAuthor: null,
|
||||
ciStatus: null,
|
||||
reviewStatus: null,
|
||||
reviewer: null,
|
||||
conflictsWithMain: null,
|
||||
hasUnpushed: null,
|
||||
parentBranch: null,
|
||||
createdAt: 10,
|
||||
updatedAt: 10,
|
||||
};
|
||||
|
||||
describe("groupTasksByRepo", () => {
|
||||
it("groups by repo and sorts by recency", () => {
|
||||
const rows: TaskRecord[] = [
|
||||
{ ...base, taskId: "h1", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 10 },
|
||||
{ ...base, taskId: "h2", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 50 },
|
||||
{ ...base, taskId: "h3", repoId: "repo-b", repoRemote: "https://example.com/repo-b.git", updatedAt: 30 },
|
||||
];
|
||||
|
||||
const groups = groupTasksByRepo(rows);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0]?.repoId).toBe("repo-a");
|
||||
expect(groups[0]?.tasks[0]?.taskId).toBe("h2");
|
||||
});
|
||||
|
||||
it("sorts repo groups by latest task activity first", () => {
|
||||
const rows: TaskRecord[] = [
|
||||
{ ...base, taskId: "h1", repoId: "repo-z", repoRemote: "https://example.com/repo-z.git", updatedAt: 200 },
|
||||
{ ...base, taskId: "h2", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 100 },
|
||||
];
|
||||
|
||||
const groups = groupTasksByRepo(rows);
|
||||
expect(groups[0]?.repoId).toBe("repo-z");
|
||||
expect(groups[1]?.repoId).toBe("repo-a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDiffStat", () => {
|
||||
it("returns No changes for zero-diff values", () => {
|
||||
expect(formatDiffStat("+0/-0")).toBe("No changes");
|
||||
expect(formatDiffStat("+0 -0")).toBe("No changes");
|
||||
});
|
||||
|
||||
it("returns dash for empty values", () => {
|
||||
expect(formatDiffStat(null)).toBe("-");
|
||||
expect(formatDiffStat("")).toBe("-");
|
||||
});
|
||||
|
||||
it("keeps non-empty non-zero diff stats", () => {
|
||||
expect(formatDiffStat("+12/-4")).toBe("+12/-4");
|
||||
});
|
||||
});
|
||||
50
foundry/packages/frontend/src/features/tasks/model.ts
Normal file
50
foundry/packages/frontend/src/features/tasks/model.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type { TaskRecord } from "@sandbox-agent/foundry-shared";
|
||||
|
||||
export interface RepoGroup {
|
||||
repoId: string;
|
||||
repoRemote: string;
|
||||
tasks: TaskRecord[];
|
||||
}
|
||||
|
||||
export function groupTasksByRepo(tasks: TaskRecord[]): RepoGroup[] {
|
||||
const groups = new Map<string, RepoGroup>();
|
||||
|
||||
for (const task of tasks) {
|
||||
const group = groups.get(task.repoId);
|
||||
if (group) {
|
||||
group.tasks.push(task);
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.set(task.repoId, {
|
||||
repoId: task.repoId,
|
||||
repoRemote: task.repoRemote,
|
||||
tasks: [task],
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(groups.values())
|
||||
.map((group) => ({
|
||||
...group,
|
||||
tasks: [...group.tasks].sort((a, b) => b.updatedAt - a.updatedAt),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const aLatest = a.tasks[0]?.updatedAt ?? 0;
|
||||
const bLatest = b.tasks[0]?.updatedAt ?? 0;
|
||||
if (aLatest !== bLatest) {
|
||||
return bLatest - aLatest;
|
||||
}
|
||||
return a.repoRemote.localeCompare(b.repoRemote);
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDiffStat(diffStat: string | null | undefined): string {
|
||||
const normalized = diffStat?.trim();
|
||||
if (!normalized) {
|
||||
return "-";
|
||||
}
|
||||
if (normalized === "+0/-0" || normalized === "+0 -0" || normalized === "0 files changed") {
|
||||
return "No changes";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue