Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
TurnGroupSection,
type TurnGroupSectionProps,
} from "../TurnGroupSection";
import { sameRowSnapshot } from "./utils/sameRowSnapshot";

type TranscriptRowItemProps = TurnGroupSectionProps & {
className: string;
Expand Down Expand Up @@ -34,7 +35,11 @@ function sameRow(
const keys = Object.keys(next) as (keyof TranscriptRowItemProps)[];
return (
keys.length === Object.keys(previous).length &&
keys.every((key) => Object.is(previous[key], next[key]))
keys.every((key) =>
key === "snapshot"
? sameRowSnapshot(next.row, previous.snapshot, next.snapshot)
: Object.is(previous[key], next[key]),
)
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { sameRowSnapshot } from "./sameRowSnapshot";
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, test } from "bun:test";
import { reduceMany, type SessionSnapshot } from "@superset/chat/core";
import type { Envelope, Item, ToolCall } from "@superset/chat/protocol";
import type { TranscriptRow } from "../../../../utils/transcriptRows";
import { sameRowSnapshot } from "./sameRowSnapshot";

const reply: Item = {
id: "a-1",
kind: "agent_message",
startedAtMs: 1,
completedAtMs: 2,
text: "Done.",
};
const streaming: Item = {
id: "a-2",
kind: "agent_message",
startedAtMs: 3,
text: "",
};
const command: ToolCall = {
id: "tool-1",
kind: "tool_call",
startedAtMs: 4,
title: "bun test",
toolKind: "execute",
toolName: "bash",
status: "running",
content: [],
};
const approval = {
id: "approval-1",
kind: "approval_request",
startedAtMs: 5,
title: "bun test",
targetItemId: command.id,
status: "pending",
options: [],
} as unknown as Item;
const thinking: Item = {
id: "r-1",
kind: "reasoning",
startedAtMs: 6,
text: "",
};

function snapshotOf(items: readonly Item[], harness = "acp"): SessionSnapshot {
return {
session: { status: "running", harness, backgroundTasks: [] },
turns: new Map(),
items: new Map(items.map((item) => [item.id, { item, turnId: "t1" }])),
liveStreams: new Map(),
cursor: { epoch: "e", seq: 1 },
pendingReset: null,
} as unknown as SessionSnapshot;
}

function streamInto(snapshot: SessionSnapshot, itemId: string, append: string) {
return reduceMany(snapshot, [
{
cursor: { epoch: "e", seq: 2 },
delta: { type: "text", itemId, append },
} as unknown as Envelope,
]);
}

function itemRow(item: Item): TranscriptRow {
return { kind: "item", key: item.id, groupStart: false, item };
}

const toolRun: TranscriptRow = {
kind: "tool_run",
key: "run-1",
groupStart: false,
items: [command],
steps: [command, thinking],
defaultCollapsed: false,
};

describe("sameRowSnapshot", () => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
const items = [reply, streaming, command, approval, thinking];
const base = snapshotOf(items);

test("another message streaming leaves a settled row alone", () => {
expect(
sameRowSnapshot(itemRow(reply), base, streamInto(base, "a-2", "Hi")),
).toBe(true);
});

test("the row's own text streaming changes it", () => {
expect(
sameRowSnapshot(itemRow(streaming), base, streamInto(base, "a-2", "Hi")),
).toBe(false);
});

test("a tool run changes when its reasoning step streams", () => {
const next = streamInto(base, thinking.id, "Checking");
expect(sameRowSnapshot(toolRun, base, next)).toBe(false);
expect(sameRowSnapshot(toolRun, base, streamInto(base, "a-2", "Hi"))).toBe(
true,
);
});

test("an approval row follows changes to its target", () => {
const finished: ToolCall = { ...command, status: "completed" };
const next = snapshotOf([reply, streaming, finished, approval, thinking]);
expect(sameRowSnapshot(itemRow(approval), base, next)).toBe(false);
expect(sameRowSnapshot(itemRow(reply), base, next)).toBe(true);
});

test("every row changes when the harness does", () => {
expect(
sameRowSnapshot(itemRow(reply), base, snapshotOf(items, "codex")),
).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { SessionSnapshot } from "@superset/chat/core";
import { rowSnapshotView } from "../../../../utils/rowSnapshotView";
import type { TranscriptRow } from "../../../../utils/transcriptRows";

export function sameRowSnapshot(
row: TranscriptRow,
previous: SessionSnapshot,
next: SessionSnapshot,
): boolean {
if (previous === next) return true;
const before = rowSnapshotView(row, previous);
const after = rowSnapshotView(row, next);
return (
before.harness === after.harness &&
before.asker === after.asker &&
before.approvalTarget === after.approvalTarget &&
before.texts.length === after.texts.length &&
before.texts.every((text, index) => text === after.texts[index])
);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { useLingui } from "@lingui/react/macro";
import type { OutboxEntry, SessionSnapshot } from "@superset/chat/core";
import { approvalAsker, displayText } from "@superset/chat/core";
import type {
ApprovalRequest,
AvailableCommand,
Decision,
Item,
Expand All @@ -14,6 +12,7 @@ import type { ReactNode } from "react";
import type { ChatForkTarget } from "../../../../types";
import type { PageLink } from "../../../../utils/pageLinks";
import { PageLinkCard } from "../../../PageLinkCard";
import { rowSnapshotView } from "../../utils/rowSnapshotView";
import type { TranscriptRow } from "../../utils/transcriptRows";
import { AttachmentRail, type RailImage } from "../AttachmentRail";
import { ReplyFooter } from "../ReplyFooter";
Expand Down Expand Up @@ -59,15 +58,6 @@ function imageRail(tools: readonly ToolCall[]): ReactNode {
) : null;
}

function approvalTarget(
snapshot: SessionSnapshot,
item: Item,
): Item | undefined {
if (item.kind !== "approval_request") return undefined;
const targetId = (item as ApprovalRequest).targetItemId;
return targetId ? snapshot.items.get(targetId)?.item : undefined;
}

export type TurnGroupSectionProps = {
row: TranscriptRow;
lastReply: boolean;
Expand Down Expand Up @@ -104,7 +94,10 @@ export function TurnGroupSection({
snapshot,
}: TurnGroupSectionProps) {
const { t } = useLingui();
const harness = snapshot.session?.harness;
const { harness, texts, asker, approvalTarget } = rowSnapshotView(
row,
snapshot,
);
switch (row.kind) {
case "working":
return (
Expand All @@ -115,12 +108,11 @@ export function TurnGroupSection({
/>
);
case "item": {
const asker = approvalAsker(snapshot.items, row.item);
return (
<>
<ItemRow
afterTarget={row.afterTarget}
approvalTarget={approvalTarget(snapshot, row.item)}
approvalTarget={approvalTarget}
asker={
asker ? asker.title || t({ message: "Subagent" }) : undefined
}
Expand All @@ -133,7 +125,7 @@ export function TurnGroupSection({
onFork={onFork}
onRespond={onRespond}
pagesShownEarlier={row.pagesShownEarlier}
text={displayText(snapshot, row.item.id)}
text={texts[0] ?? ""}
/>
{row.item.kind === "tool_call" && imageRail([row.item as ToolCall])}
{pageCards(row.pages)}
Expand Down Expand Up @@ -166,9 +158,7 @@ export function TurnGroupSection({
onToggle={onToggleEntry}
rowKey={row.key}
steps={row.steps}
stepTexts={row.steps.map((step) =>
step.kind === "reasoning" ? displayText(snapshot, step.id) : "",
)}
stepTexts={texts}
/>
{imageRail(row.items)}
{pageCards(row.pages)}
Expand Down Expand Up @@ -199,7 +189,7 @@ export function TurnGroupSection({
className="hover:opacity-100"
item={row.item}
onFork={onFork}
text={displayText(snapshot, row.item.id)}
text={texts[0] ?? ""}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { type RowSnapshotView, rowSnapshotView } from "./rowSnapshotView";
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {
approvalAsker,
displayText,
type SessionSnapshot,
} from "@superset/chat/core";
import type { ApprovalRequest, Item, ToolCall } from "@superset/chat/protocol";
import type { TranscriptRow } from "../transcriptRows";

export type RowSnapshotView = {
harness: string | undefined;
/** Per item for item and footer rows, per step for tool runs. */
texts: readonly string[];
asker: ToolCall | undefined;
approvalTarget: Item | undefined;
};

function approvalTarget(
snapshot: SessionSnapshot,
item: Item,
): Item | undefined {
if (item.kind !== "approval_request") return undefined;
const targetId = (item as ApprovalRequest).targetItemId;
return targetId ? snapshot.items.get(targetId)?.item : undefined;
}

export function rowSnapshotView(
row: TranscriptRow,
snapshot: SessionSnapshot,
): RowSnapshotView {
const harness = snapshot.session?.harness;
switch (row.kind) {
case "item":
return {
harness,
texts: [displayText(snapshot, row.item.id)],
asker: approvalAsker(snapshot.items, row.item),
approvalTarget: approvalTarget(snapshot, row.item),
};
case "reply_footer":
return {
harness,
texts: [displayText(snapshot, row.item.id)],
asker: undefined,
approvalTarget: undefined,
};
case "tool_run":
return {
harness,
texts: row.steps.map((step) =>
step.kind === "reasoning" ? displayText(snapshot, step.id) : "",
),
asker: undefined,
approvalTarget: undefined,
};
default:
return {
harness,
texts: [],
asker: undefined,
approvalTarget: undefined,
};
}
}
Loading