Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
564 lines
16 KiB
TypeScript
564 lines
16 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
normalizeAgentActivitySession,
|
|
type AgentActivitySession,
|
|
type AgentActivityTurn
|
|
} from "@tutti-os/agent-activity-core";
|
|
import {
|
|
buildCanonicalWorkspaceAgentDetailView,
|
|
projectWorkspaceAgentTimelineToConversationVM
|
|
} from "./agentConversation/projection/workspaceAgentTimelineProjection";
|
|
import type { WorkspaceAgentActivityCard } from "./workspaceAgentActivityListViewModel";
|
|
import type { WorkspaceAgentSessionDetailTurn } from "./workspaceAgentSessionDetailViewModel";
|
|
import type { WorkspaceAgentActivityTimelineItem } from "./workspaceAgentTimelineTypes";
|
|
import { enrichProjectedTurnsWithCanonicalErrors } from "./workspaceAgentTurnErrorProjection";
|
|
|
|
describe("canonical Turn error projection", () => {
|
|
it("projects visible agent errors from assistant message payloads", () => {
|
|
const conversation = projectWorkspaceAgentTimelineToConversationVM({
|
|
activity: activity(),
|
|
session: session(),
|
|
workspaceRoot: "/workspace/demo",
|
|
timelineItems: [
|
|
userMessage("turn-1", 1, "Ship the patch"),
|
|
{
|
|
id: 99,
|
|
workspaceId: "room-1",
|
|
agentSessionId: "session-1",
|
|
turnId: "turn-1",
|
|
seq: 99,
|
|
eventId: "visible-error:event-99",
|
|
actorType: "agent",
|
|
actorId: "hermes",
|
|
itemType: "message.assistant",
|
|
role: "assistant",
|
|
status: "failed",
|
|
payload: {
|
|
kind: "agent_visible_error",
|
|
code: "process_exited",
|
|
phase: "start",
|
|
provider: "hermes",
|
|
detail: "Config invalid",
|
|
retryable: false,
|
|
content: "Hermes failed to start.",
|
|
text: "Hermes failed to start."
|
|
},
|
|
occurredAtUnixMs: 99,
|
|
createdAtUnixMs: 99
|
|
}
|
|
]
|
|
});
|
|
|
|
const row = conversation.rows.find(
|
|
(
|
|
candidate
|
|
): candidate is Extract<
|
|
(typeof conversation.rows)[number],
|
|
{ kind: "message" }
|
|
> => candidate.kind === "message" && candidate.speaker === "assistant"
|
|
);
|
|
expect(row?.messages[0]?.visibleError).toEqual({
|
|
code: "process_exited",
|
|
phase: "start",
|
|
provider: "hermes",
|
|
detail: "Config invalid",
|
|
retryable: false
|
|
});
|
|
});
|
|
|
|
it("adds a terminal canonical error to a hydrated Turn when the provider emitted no error message", () => {
|
|
const failed = failedTurn({
|
|
error: {
|
|
message: "Selected model is at capacity. Please try a different model."
|
|
}
|
|
});
|
|
const input = {
|
|
activity: activity(),
|
|
session: session({ latestTurn: failed }),
|
|
sessionTurns: [failed],
|
|
workspaceRoot: "/workspace/demo",
|
|
timelineItems: [userMessage("turn-1", 1, "Ship the patch")]
|
|
};
|
|
const detail = buildCanonicalWorkspaceAgentDetailView(input);
|
|
const conversation = projectWorkspaceAgentTimelineToConversationVM(input);
|
|
|
|
expect(detail.turns[0]?.agentMessages).toEqual([
|
|
expect.objectContaining({
|
|
id: "turn-error:session-1:turn-1",
|
|
body: "Selected model is at capacity. Please try a different model.",
|
|
statusKind: "failed",
|
|
visibleError: {
|
|
code: null,
|
|
phase: "turn",
|
|
provider: "codex",
|
|
detail:
|
|
"Selected model is at capacity. Please try a different model.",
|
|
retryable: null
|
|
}
|
|
})
|
|
]);
|
|
expect(
|
|
conversation.rows.flatMap((row) =>
|
|
row.kind === "message" && row.speaker === "assistant"
|
|
? row.messages
|
|
: []
|
|
)
|
|
).toEqual([
|
|
expect.objectContaining({
|
|
id: "turn-error:session-1:turn-1",
|
|
visibleError: expect.objectContaining({
|
|
detail: "Selected model is at capacity. Please try a different model."
|
|
})
|
|
})
|
|
]);
|
|
});
|
|
|
|
it("upgrades a matching assistant failure instead of duplicating the canonical error", () => {
|
|
const failed = failedTurn({
|
|
error: { code: "provider_error", message: "Provider unavailable" }
|
|
});
|
|
const detail = buildCanonicalWorkspaceAgentDetailView({
|
|
activity: activity(),
|
|
session: session({ latestTurn: failed }),
|
|
sessionTurns: [failed],
|
|
workspaceRoot: "/workspace/demo",
|
|
timelineItems: [
|
|
userMessage("turn-1", 1, "Ship the patch"),
|
|
assistantMessage("turn-1", 2, "Provider unavailable", "failed")
|
|
]
|
|
});
|
|
|
|
expect(detail.turns[0]?.agentMessages).toHaveLength(1);
|
|
expect(detail.turns[0]?.agentMessages[0]).toEqual(
|
|
expect.objectContaining({
|
|
id: "event-2",
|
|
visibleError: expect.objectContaining({
|
|
code: "provider_error",
|
|
detail: "Provider unavailable"
|
|
})
|
|
})
|
|
);
|
|
});
|
|
|
|
it("keeps an existing structured visible error as the single Turn failure row", () => {
|
|
const failed = failedTurn({
|
|
error: { code: "provider_error", message: "Canonical provider failure" }
|
|
});
|
|
const detail = buildCanonicalWorkspaceAgentDetailView({
|
|
activity: activity(),
|
|
session: session({ latestTurn: failed }),
|
|
sessionTurns: [failed],
|
|
workspaceRoot: "/workspace/demo",
|
|
timelineItems: [
|
|
userMessage("turn-1", 1, "Ship the patch"),
|
|
{
|
|
...assistantMessage("turn-1", 2, "Provider request failed", "failed"),
|
|
payload: {
|
|
kind: "agent_visible_error",
|
|
code: "provider_error",
|
|
phase: "run",
|
|
provider: "codex",
|
|
detail: "Provider request failed",
|
|
retryable: true
|
|
}
|
|
}
|
|
]
|
|
});
|
|
|
|
expect(detail.turns[0]?.agentMessages).toHaveLength(1);
|
|
expect(detail.turns[0]?.agentMessages[0]?.id).toBe("event-2");
|
|
expect(detail.turns[0]?.agentMessages[0]?.visibleError?.detail).toBe(
|
|
"Provider request failed"
|
|
);
|
|
});
|
|
|
|
it("adds canonical raw detail to an existing structured failure row", () => {
|
|
const failed = failedTurn({
|
|
error: {
|
|
code: "provider_error",
|
|
message: "Canonical provider failure",
|
|
detail: "provider response\nwith diagnostic context"
|
|
}
|
|
});
|
|
const detail = buildCanonicalWorkspaceAgentDetailView({
|
|
activity: activity(),
|
|
session: session({ latestTurn: failed }),
|
|
sessionTurns: [failed],
|
|
workspaceRoot: "/workspace/demo",
|
|
timelineItems: [
|
|
userMessage("turn-1", 1, "Ship the patch"),
|
|
{
|
|
...assistantMessage("turn-1", 2, "Provider request failed", "failed"),
|
|
payload: {
|
|
kind: "agent_visible_error",
|
|
code: "provider_error",
|
|
phase: "run",
|
|
provider: "codex",
|
|
detail: "Provider request failed",
|
|
retryable: true
|
|
}
|
|
}
|
|
]
|
|
});
|
|
|
|
expect(detail.turns[0]?.agentMessages).toHaveLength(1);
|
|
expect(detail.turns[0]?.agentMessages[0]?.visibleError).toEqual(
|
|
expect.objectContaining({
|
|
detail: "provider response\nwith diagnostic context",
|
|
detailAvailable: true
|
|
})
|
|
);
|
|
});
|
|
|
|
it("attaches a historical error to its owning Turn after that Turn is hydrated", () => {
|
|
const failed = failedTurn();
|
|
const completed = completedTurn({
|
|
turnId: "turn-2",
|
|
startedAtUnixMs: 20,
|
|
settledAtUnixMs: 30,
|
|
updatedAtUnixMs: 30
|
|
});
|
|
const detail = buildCanonicalWorkspaceAgentDetailView({
|
|
activity: activity(),
|
|
session: session({ latestTurn: completed }),
|
|
sessionTurns: [failed, completed],
|
|
workspaceRoot: "/workspace/demo",
|
|
timelineItems: [
|
|
userMessage("turn-1", 1, "Ship the patch"),
|
|
userMessage("turn-2", 20, "Try again")
|
|
]
|
|
});
|
|
|
|
expect(detail.turns.map((turn) => turn.id)).toEqual(["turn-1", "turn-2"]);
|
|
expect(detail.turns[0]?.agentMessages).toEqual([
|
|
expect.objectContaining({
|
|
id: "turn-error:session-1:turn-1",
|
|
visibleError: expect.objectContaining({ detail: "Turn failed" })
|
|
})
|
|
]);
|
|
expect(detail.turns[1]?.agentMessages).toHaveLength(0);
|
|
});
|
|
|
|
it("does not create an older failed Turn outside the hydrated transcript window", () => {
|
|
const failed = failedTurn();
|
|
const completed = completedTurn({
|
|
turnId: "turn-2",
|
|
startedAtUnixMs: 20,
|
|
settledAtUnixMs: 30,
|
|
updatedAtUnixMs: 30
|
|
});
|
|
const input = {
|
|
activity: activity(),
|
|
session: session({ latestTurn: completed }),
|
|
sessionTurns: [failed, completed],
|
|
workspaceRoot: "/workspace/demo",
|
|
timelineItems: [userMessage("turn-2", 20, "Try again")]
|
|
};
|
|
const detail = buildCanonicalWorkspaceAgentDetailView(input);
|
|
const conversation = projectWorkspaceAgentTimelineToConversationVM(input);
|
|
|
|
expect(detail.turns.map((turn) => turn.id)).toEqual(["turn-2"]);
|
|
expect(detail.turns[0]?.userMessage?.body).toBe("Try again");
|
|
expect(
|
|
conversation.rows.some(
|
|
(row) =>
|
|
row.kind === "message" &&
|
|
row.speaker === "assistant" &&
|
|
row.messages.some(
|
|
(message) => message.id === "turn-error:session-1:turn-1"
|
|
)
|
|
)
|
|
).toBe(false);
|
|
});
|
|
|
|
it("projects the older error exactly once when an earlier page hydrates its transcript anchor", () => {
|
|
const failed = failedTurn();
|
|
const completed = completedTurn({
|
|
turnId: "turn-2",
|
|
startedAtUnixMs: 20,
|
|
settledAtUnixMs: 30,
|
|
updatedAtUnixMs: 30
|
|
});
|
|
const baseInput = {
|
|
activity: activity(),
|
|
session: session({ latestTurn: completed }),
|
|
sessionTurns: [failed, completed],
|
|
workspaceRoot: "/workspace/demo"
|
|
};
|
|
|
|
const latestPage = buildCanonicalWorkspaceAgentDetailView({
|
|
...baseInput,
|
|
timelineItems: [userMessage("turn-2", 20, "Try again")]
|
|
});
|
|
const expandedWindow = buildCanonicalWorkspaceAgentDetailView({
|
|
...baseInput,
|
|
timelineItems: [
|
|
userMessage("turn-1", 1, "Ship the patch"),
|
|
userMessage("turn-2", 20, "Try again")
|
|
]
|
|
});
|
|
|
|
expect(latestPage.turns.map((turn) => turn.id)).toEqual(["turn-2"]);
|
|
expect(expandedWindow.turns.map((turn) => turn.id)).toEqual([
|
|
"turn-1",
|
|
"turn-2"
|
|
]);
|
|
expect(
|
|
expandedWindow.turns.flatMap((turn) =>
|
|
turn.agentMessages.filter(
|
|
(message) => message.id === "turn-error:session-1:turn-1"
|
|
)
|
|
)
|
|
).toHaveLength(1);
|
|
expect(
|
|
expandedWindow.turns[0]?.agentMessages[0]?.visibleError?.detail
|
|
).toBe("Turn failed");
|
|
});
|
|
|
|
it("projects the latest failed Turn when no transcript item has been hydrated", () => {
|
|
const failed = failedTurn();
|
|
const input = {
|
|
activity: activity(),
|
|
session: session({ latestTurn: failed }),
|
|
sessionTurns: [failed],
|
|
workspaceRoot: "/workspace/demo",
|
|
timelineItems: []
|
|
};
|
|
const detail = buildCanonicalWorkspaceAgentDetailView(input);
|
|
const conversation = projectWorkspaceAgentTimelineToConversationVM(input);
|
|
|
|
expect(detail.turns).toEqual([
|
|
expect.objectContaining({
|
|
id: "turn-1",
|
|
agentMessages: [
|
|
expect.objectContaining({
|
|
id: "turn-error:session-1:turn-1",
|
|
body: "Turn failed",
|
|
visibleError: expect.objectContaining({ detail: "Turn failed" })
|
|
})
|
|
]
|
|
})
|
|
]);
|
|
expect(
|
|
conversation.rows.some(
|
|
(row) =>
|
|
row.kind === "message" &&
|
|
row.speaker === "assistant" &&
|
|
row.messages.some(
|
|
(message) =>
|
|
message.id === "turn-error:session-1:turn-1" &&
|
|
message.visibleError?.detail === "Turn failed"
|
|
)
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
it("keeps the current running Turn last when an older failed Turn is outside the window", () => {
|
|
const failed = failedTurn();
|
|
const running = runningTurn({
|
|
turnId: "turn-2",
|
|
startedAtUnixMs: 20,
|
|
updatedAtUnixMs: 30
|
|
});
|
|
const runningSession = session({
|
|
activeTurnId: "turn-2",
|
|
activeTurn: running,
|
|
latestTurn: running
|
|
});
|
|
const input = {
|
|
activity: activity(),
|
|
session: runningSession,
|
|
sessionTurns: [failed, running],
|
|
workspaceRoot: "/workspace/demo",
|
|
timelineItems: [userMessage("turn-2", 20, "Continue working")]
|
|
};
|
|
const detail = buildCanonicalWorkspaceAgentDetailView(input);
|
|
const conversation = projectWorkspaceAgentTimelineToConversationVM(input);
|
|
|
|
expect(detail.turns.map((turn) => turn.id)).toEqual(["turn-2"]);
|
|
expect(detail.turns.at(-1)?.id).toBe("turn-2");
|
|
expect(detail.showProcessingIndicator).toBe(true);
|
|
expect(conversation.rows.at(-1)?.turnId).toBe("turn-2");
|
|
expect(
|
|
conversation.rows.some(
|
|
(row) =>
|
|
row.kind === "message" &&
|
|
row.speaker === "assistant" &&
|
|
row.messages.some((message) => message.turnId === "turn-1")
|
|
)
|
|
).toBe(false);
|
|
});
|
|
|
|
it.each(["failed", "interrupted"] as const)(
|
|
"does not change transcript membership for an unhydrated %s Turn",
|
|
(outcome) => {
|
|
const currentTurn = projectedTurn("turn-2");
|
|
const turns = new Map([[currentTurn.id, currentTurn]]);
|
|
|
|
enrichProjectedTurnsWithCanonicalErrors({
|
|
turns,
|
|
latestTurnId: "turn-2",
|
|
sessionTurns: [
|
|
failedTurn({
|
|
outcome,
|
|
error: { message: `${outcome} outside the window` }
|
|
})
|
|
],
|
|
provider: "codex",
|
|
agentSessionId: "session-1"
|
|
});
|
|
|
|
expect([...turns.keys()]).toEqual(["turn-2"]);
|
|
expect(turns.get("turn-2")).toBe(currentTurn);
|
|
expect(currentTurn.agentMessages).toEqual([]);
|
|
}
|
|
);
|
|
});
|
|
|
|
function activity(): WorkspaceAgentActivityCard {
|
|
return {
|
|
id: "activity-1",
|
|
sessionId: "session-1",
|
|
agentName: "Codex",
|
|
agentProvider: "codex",
|
|
status: "working",
|
|
title: "Codex",
|
|
latestActivitySummary: "Working",
|
|
sortTimeUnixMs: 30,
|
|
changedFiles: [],
|
|
userId: "user-1",
|
|
userName: "Taylor",
|
|
userAvatarUrl: ""
|
|
};
|
|
}
|
|
|
|
function session(
|
|
overrides: Partial<AgentActivitySession> = {}
|
|
): AgentActivitySession {
|
|
return normalizeAgentActivitySession({
|
|
activeTurnId: null,
|
|
latestTurnInteractions: [],
|
|
pendingInteractions: [],
|
|
workspaceId: "workspace-1",
|
|
agentSessionId: "session-1",
|
|
userId: "user-1",
|
|
provider: "codex",
|
|
providerSessionId: "provider-session-1",
|
|
cwd: "/workspace/demo",
|
|
title: "Codex",
|
|
createdAtUnixMs: 1,
|
|
updatedAtUnixMs: 30,
|
|
latestTurn: completedTurn(),
|
|
...overrides
|
|
});
|
|
}
|
|
|
|
function failedTurn(
|
|
overrides: Partial<AgentActivityTurn> = {}
|
|
): AgentActivityTurn {
|
|
return {
|
|
agentSessionId: "session-1",
|
|
error: { message: "Turn failed" },
|
|
origin: "user_prompt",
|
|
outcome: "failed",
|
|
phase: "settled",
|
|
settledAtUnixMs: 10,
|
|
startedAtUnixMs: 1,
|
|
turnId: "turn-1",
|
|
updatedAtUnixMs: 10,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function completedTurn(
|
|
overrides: Partial<AgentActivityTurn> = {}
|
|
): AgentActivityTurn {
|
|
return {
|
|
agentSessionId: "session-1",
|
|
error: null,
|
|
origin: "user_prompt",
|
|
outcome: "completed",
|
|
phase: "settled",
|
|
settledAtUnixMs: 10,
|
|
startedAtUnixMs: 1,
|
|
turnId: "turn-1",
|
|
updatedAtUnixMs: 10,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function runningTurn(
|
|
overrides: Partial<AgentActivityTurn> = {}
|
|
): AgentActivityTurn {
|
|
return {
|
|
agentSessionId: "session-1",
|
|
error: null,
|
|
origin: "user_prompt",
|
|
outcome: null,
|
|
phase: "running",
|
|
startedAtUnixMs: 1,
|
|
turnId: "turn-1",
|
|
updatedAtUnixMs: 10,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function projectedTurn(turnId: string): WorkspaceAgentSessionDetailTurn {
|
|
return {
|
|
id: turnId,
|
|
userMessage: null,
|
|
userMessages: [],
|
|
agentMessages: [],
|
|
toolCalls: [],
|
|
toolCallCount: 0,
|
|
hasFailedToolCall: false,
|
|
agentItems: []
|
|
};
|
|
}
|
|
|
|
function userMessage(
|
|
turnId: string,
|
|
seq: number,
|
|
content: string
|
|
): WorkspaceAgentActivityTimelineItem {
|
|
return {
|
|
id: seq,
|
|
workspaceId: "room-1",
|
|
agentSessionId: "session-1",
|
|
turnId,
|
|
seq,
|
|
eventId: `event-${seq}`,
|
|
actorType: "user",
|
|
actorId: "user-1",
|
|
itemType: "message.user",
|
|
role: "user",
|
|
content,
|
|
occurredAtUnixMs: seq,
|
|
createdAtUnixMs: seq
|
|
};
|
|
}
|
|
|
|
function assistantMessage(
|
|
turnId: string,
|
|
seq: number,
|
|
content: string,
|
|
status: string
|
|
): WorkspaceAgentActivityTimelineItem {
|
|
return {
|
|
id: seq,
|
|
workspaceId: "room-1",
|
|
agentSessionId: "session-1",
|
|
turnId,
|
|
seq,
|
|
eventId: `event-${seq}`,
|
|
actorType: "agent",
|
|
actorId: "codex",
|
|
itemType: "message.assistant",
|
|
role: "assistant",
|
|
status,
|
|
content,
|
|
occurredAtUnixMs: seq,
|
|
createdAtUnixMs: seq
|
|
};
|
|
}
|