feat(frontend): viewer humain fil-par-paire en lecture seule (LS7)
Frontend pur, lecture seule, consomme read_conversation_page (LS6) ; aucun changement backend. - adapters : conversation.ts (gateway) + conversationNormalization.ts. - features/conversations : useConversationThread, ConversationViewer, index. - domain (types LS7), ports (port + Gateways.conversation), adapters/index, mock (+ clé inventaire). - features/workstate : ProjectWorkStatePanel prop onOpenConversation. - features/projects : ProjectsView swap viewer. - tests (QA, verts) : conversationNormalization, mock/conversationGateway, useConversationThread, ConversationViewer, ProjectsView.ls7. tsc 0, vitest 443/443 (+36). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
42
frontend/src/adapters/conversation.ts
Normal file
42
frontend/src/adapters/conversation.ts
Normal file
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Tauri adapter for {@link ConversationGateway} (LS7 human transcript viewer).
|
||||
*
|
||||
* This is the only frontend place that knows the `read_conversation_page`
|
||||
* command name and its argument shape; features consume the gateway port
|
||||
* through DI.
|
||||
*
|
||||
* **Wire contract (verified against LS6 — `crates/app-tauri/src/commands.rs`
|
||||
* + `dto.rs`):** the command takes a *single* `request` argument deserialized
|
||||
* into `ReadConversationPageRequestDto` (`#[serde(rename_all = "camelCase")]`)
|
||||
* with flat fields `projectId`, `conversationId`, `anchor` (optional turn id),
|
||||
* `direction` (`"forward"`/`"backward"`, default backward) and `limit`
|
||||
* (optional, `0`/omit ⇒ default 50, clamped to `[1, 200]`). It returns a
|
||||
* `TurnPageDto` (`turns` / `hasMore` / `nextAnchor`). Note: there is **no**
|
||||
* nested `cursor` object on the wire — the backend builds the domain
|
||||
* `PageCursor` itself from the flat `anchor`/`direction` fields.
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { TurnPage } from "@/domain";
|
||||
import type { ConversationGateway, ConversationPageRequest } from "@/ports";
|
||||
import { normalizeTurnPage } from "./conversationNormalization";
|
||||
|
||||
export class TauriConversationGateway implements ConversationGateway {
|
||||
async readPage(
|
||||
projectId: string,
|
||||
conversationId: string,
|
||||
request?: ConversationPageRequest,
|
||||
): Promise<TurnPage> {
|
||||
const page = await invoke<unknown>("read_conversation_page", {
|
||||
request: {
|
||||
projectId,
|
||||
conversationId,
|
||||
anchor: request?.anchor,
|
||||
direction: request?.direction ?? "backward",
|
||||
limit: request?.limit,
|
||||
},
|
||||
});
|
||||
return normalizeTurnPage(page);
|
||||
}
|
||||
}
|
||||
164
frontend/src/adapters/conversationNormalization.test.ts
Normal file
164
frontend/src/adapters/conversationNormalization.test.ts
Normal file
@ -0,0 +1,164 @@
|
||||
/**
|
||||
* LS7 — wire → domain normalization for the conversation transcript read, and the
|
||||
* `TauriConversationGateway.readPage` argument shape (single flat `request`).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { normalizeTurnPage } from "./conversationNormalization";
|
||||
|
||||
const invokeMock = vi.fn();
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (...args: unknown[]) => invokeMock(...args),
|
||||
}));
|
||||
|
||||
describe("normalizeTurnPage", () => {
|
||||
it("maps a full camelCase TurnPageDto to the domain TurnPage", () => {
|
||||
const page = normalizeTurnPage({
|
||||
turns: [
|
||||
{
|
||||
id: "t1",
|
||||
atMs: 1_700_000_000_000,
|
||||
role: "prompt",
|
||||
source: { kind: "human" },
|
||||
text: "bonjour",
|
||||
textLen: 7,
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
atMs: 1_700_000_000_500,
|
||||
role: "response",
|
||||
source: { kind: "agent", agentId: "agent-7" },
|
||||
text: "réponse complète",
|
||||
textLen: 16,
|
||||
},
|
||||
{
|
||||
id: "t3",
|
||||
atMs: 1_700_000_001_000,
|
||||
role: "toolActivity",
|
||||
source: { kind: "agent", agentId: "agent-7" },
|
||||
text: "tool run",
|
||||
textLen: 8,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
nextAnchor: "t3",
|
||||
});
|
||||
|
||||
expect(page.hasMore).toBe(true);
|
||||
expect(page.nextAnchor).toBe("t3");
|
||||
expect(page.turns).toHaveLength(3);
|
||||
expect(page.turns[0]).toEqual({
|
||||
id: "t1",
|
||||
atMs: 1_700_000_000_000,
|
||||
role: "prompt",
|
||||
source: { kind: "human" },
|
||||
text: "bonjour",
|
||||
textLen: 7,
|
||||
});
|
||||
// source.kind mapping: agent carries the agentId.
|
||||
expect(page.turns[1].source).toEqual({ kind: "agent", agentId: "agent-7" });
|
||||
// The three roles round-trip verbatim.
|
||||
expect(page.turns.map((t) => t.role)).toEqual([
|
||||
"prompt",
|
||||
"response",
|
||||
"toolActivity",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves the full (unbounded) turn text", () => {
|
||||
const big = "Z".repeat(40_000);
|
||||
const page = normalizeTurnPage({
|
||||
turns: [{ id: "t1", atMs: 1, role: "response", source: { kind: "human" }, text: big, textLen: 40_000 }],
|
||||
hasMore: false,
|
||||
});
|
||||
expect(page.turns[0].text).toHaveLength(40_000);
|
||||
expect(page.turns[0].textLen).toBe(40_000);
|
||||
});
|
||||
|
||||
it("omits nextAnchor when absent and defaults hasMore to false", () => {
|
||||
const page = normalizeTurnPage({ turns: [] });
|
||||
expect(page.turns).toEqual([]);
|
||||
expect(page.hasMore).toBe(false);
|
||||
expect("nextAnchor" in page).toBe(false);
|
||||
});
|
||||
|
||||
it("degrades defensively on a malformed payload (never throws)", () => {
|
||||
const page = normalizeTurnPage({
|
||||
turns: [{ id: 42, role: "weird", source: { kind: "ghost" } }, null, "nope"],
|
||||
hasMore: "yes",
|
||||
nextAnchor: 99,
|
||||
});
|
||||
// hasMore only true on a strict boolean; non-string nextAnchor dropped.
|
||||
expect(page.hasMore).toBe(false);
|
||||
expect("nextAnchor" in page).toBe(false);
|
||||
// Unknown role ⇒ "prompt"; non-record source ⇒ human; missing text ⇒ "".
|
||||
expect(page.turns[0].role).toBe("prompt");
|
||||
expect(page.turns[1].source).toEqual({ kind: "human" });
|
||||
expect(page.turns[0].text).toBe("");
|
||||
// An unknown source.kind degrades to human.
|
||||
expect(page.turns[0].source).toEqual({ kind: "human" });
|
||||
});
|
||||
|
||||
it("returns an empty page for a non-object payload", () => {
|
||||
expect(normalizeTurnPage(null)).toEqual({ turns: [], hasMore: false });
|
||||
expect(normalizeTurnPage(undefined)).toEqual({ turns: [], hasMore: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("TauriConversationGateway.readPage", () => {
|
||||
beforeEach(() => {
|
||||
invokeMock.mockReset();
|
||||
});
|
||||
|
||||
it("invokes read_conversation_page with a single flat request, default direction backward", async () => {
|
||||
invokeMock.mockResolvedValue({ turns: [], hasMore: false });
|
||||
const { TauriConversationGateway } = await import("./conversation");
|
||||
const gw = new TauriConversationGateway();
|
||||
|
||||
await gw.readPage("proj-1", "conv-1");
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||
const [command, args] = invokeMock.mock.calls[0];
|
||||
expect(command).toBe("read_conversation_page");
|
||||
// Single `request` argument with FLAT fields (no nested `cursor`).
|
||||
expect(args).toEqual({
|
||||
request: {
|
||||
projectId: "proj-1",
|
||||
conversationId: "conv-1",
|
||||
anchor: undefined,
|
||||
direction: "backward",
|
||||
limit: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards anchor/direction/limit and normalizes the result", async () => {
|
||||
invokeMock.mockResolvedValue({
|
||||
turns: [{ id: "t9", atMs: 5, role: "response", source: { kind: "human" }, text: "ok", textLen: 2 }],
|
||||
hasMore: true,
|
||||
nextAnchor: "t9",
|
||||
});
|
||||
const { TauriConversationGateway } = await import("./conversation");
|
||||
const gw = new TauriConversationGateway();
|
||||
|
||||
const page = await gw.readPage("p", "c", {
|
||||
anchor: "t5",
|
||||
direction: "forward",
|
||||
limit: 25,
|
||||
});
|
||||
|
||||
expect(invokeMock.mock.calls[0][1]).toEqual({
|
||||
request: {
|
||||
projectId: "p",
|
||||
conversationId: "c",
|
||||
anchor: "t5",
|
||||
direction: "forward",
|
||||
limit: 25,
|
||||
},
|
||||
});
|
||||
// Result is normalized to the domain TurnPage.
|
||||
expect(page.turns[0].id).toBe("t9");
|
||||
expect(page.nextAnchor).toBe("t9");
|
||||
expect(page.hasMore).toBe(true);
|
||||
});
|
||||
});
|
||||
67
frontend/src/adapters/conversationNormalization.ts
Normal file
67
frontend/src/adapters/conversationNormalization.ts
Normal file
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Wire → domain normalization for the conversation transcript read (LS7).
|
||||
*
|
||||
* Mirrors the LS6 backend DTO (`crates/app-tauri/src/dto.rs`): `TurnPageDto`
|
||||
* (camelCase `turns` / `hasMore` / `nextAnchor`), `TurnViewDto`
|
||||
* (`id` / `atMs` / `role` / `source` / `text` / `textLen`) and the serde-tagged
|
||||
* `TurnSourceDto` (`{ kind: "human" }` | `{ kind: "agent", agentId }`). The
|
||||
* `source` mapping is calqued on `workStateNormalization.ts`.
|
||||
*/
|
||||
|
||||
import type { TurnPage, TurnRole, TurnSource, TurnView } from "@/domain";
|
||||
|
||||
type RecordLike = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is RecordLike {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function arrayValue(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
/** Mirror of LS6 `TurnSourceDto` (serde tag `kind`, camelCase `agentId`). */
|
||||
function normalizeSource(value: unknown): TurnSource {
|
||||
if (!isRecord(value)) return { kind: "human" };
|
||||
if (value.kind === "agent") {
|
||||
return { kind: "agent", agentId: stringValue(value.agentId, "unknown") };
|
||||
}
|
||||
return { kind: "human" };
|
||||
}
|
||||
|
||||
/** Mirror of the camelCase-serialized `TurnRole` (`prompt`/`response`/`toolActivity`). */
|
||||
function normalizeRole(value: unknown): TurnRole {
|
||||
return value === "response" || value === "toolActivity" ? value : "prompt";
|
||||
}
|
||||
|
||||
function normalizeTurn(value: unknown, index: number): TurnView {
|
||||
const turn = isRecord(value) ? value : {};
|
||||
const text = stringValue(turn.text);
|
||||
return {
|
||||
id: stringValue(turn.id, `turn-${index}`),
|
||||
atMs: numberValue(turn.atMs, index),
|
||||
role: normalizeRole(turn.role),
|
||||
source: normalizeSource(turn.source),
|
||||
text,
|
||||
textLen: numberValue(turn.textLen, text.length),
|
||||
};
|
||||
}
|
||||
|
||||
/** Maps a raw `TurnPageDto` payload to the domain {@link TurnPage}. */
|
||||
export function normalizeTurnPage(value: unknown): TurnPage {
|
||||
const page = isRecord(value) ? value : {};
|
||||
const nextAnchor = page.nextAnchor;
|
||||
return {
|
||||
turns: arrayValue(page.turns).map(normalizeTurn),
|
||||
hasMore: page.hasMore === true,
|
||||
...(typeof nextAnchor === "string" ? { nextAnchor } : {}),
|
||||
};
|
||||
}
|
||||
@ -26,6 +26,7 @@ import { TauriEmbedderGateway } from "./embedder";
|
||||
import { TauriGitGateway } from "./git";
|
||||
import { TauriPermissionGateway } from "./permission";
|
||||
import { TauriWorkStateGateway } from "./workState";
|
||||
import { TauriConversationGateway } from "./conversation";
|
||||
|
||||
function notImplemented(what: string): never {
|
||||
const err: GatewayError = {
|
||||
@ -59,6 +60,7 @@ export function createTauriGateways(): Gateways {
|
||||
embedder: new TauriEmbedderGateway(),
|
||||
permission: new TauriPermissionGateway(),
|
||||
workState: new TauriWorkStateGateway(),
|
||||
conversation: new TauriConversationGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -77,4 +79,5 @@ export {
|
||||
TauriGitGateway,
|
||||
TauriPermissionGateway,
|
||||
TauriWorkStateGateway,
|
||||
TauriConversationGateway,
|
||||
};
|
||||
|
||||
144
frontend/src/adapters/mock/conversationGateway.test.ts
Normal file
144
frontend/src/adapters/mock/conversationGateway.test.ts
Normal file
@ -0,0 +1,144 @@
|
||||
/**
|
||||
* LS7 — `MockConversationGateway` real pagination, mirroring the LS6 backend
|
||||
* slicing (strict anchor, clamp `[1,200]`/default 50, `hasMore`, `nextAnchor` =
|
||||
* last/most-recent turn of the page, full text preserved).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
import type { TurnView } from "@/domain";
|
||||
import { MockConversationGateway } from "./index";
|
||||
|
||||
const P = "proj";
|
||||
const C = "conv";
|
||||
|
||||
function turn(n: number, text = `texte ${n}`): TurnView {
|
||||
return {
|
||||
id: `t${n}`,
|
||||
atMs: 1_700_000_000_000 + n,
|
||||
role: n % 2 === 0 ? "response" : "prompt",
|
||||
source: { kind: "human" },
|
||||
text,
|
||||
textLen: text.length,
|
||||
};
|
||||
}
|
||||
|
||||
function seed(gw: MockConversationGateway, count: number): void {
|
||||
gw._setTurns(
|
||||
P,
|
||||
C,
|
||||
Array.from({ length: count }, (_, i) => turn(i + 1)),
|
||||
);
|
||||
}
|
||||
|
||||
let gw: MockConversationGateway;
|
||||
beforeEach(() => {
|
||||
gw = new MockConversationGateway();
|
||||
});
|
||||
|
||||
describe("MockConversationGateway.readPage", () => {
|
||||
it("backward with no anchor returns the LAST page (most recent), oldest→newest", async () => {
|
||||
seed(gw, 10);
|
||||
const page = await gw.readPage(P, C, { direction: "backward", limit: 4 });
|
||||
expect(page.turns.map((t) => t.id)).toEqual(["t7", "t8", "t9", "t10"]);
|
||||
expect(page.hasMore).toBe(true); // older turns exist
|
||||
expect(page.nextAnchor).toBe("t10"); // last/most-recent of the page
|
||||
});
|
||||
|
||||
it("backward anchored returns the strictly-older turns, ascending", async () => {
|
||||
seed(gw, 10);
|
||||
const page = await gw.readPage(P, C, {
|
||||
direction: "backward",
|
||||
anchor: "t7",
|
||||
limit: 3,
|
||||
});
|
||||
// Strictly before t7, closest three: t4,t5,t6 — ascending.
|
||||
expect(page.turns.map((t) => t.id)).toEqual(["t4", "t5", "t6"]);
|
||||
expect(page.hasMore).toBe(true); // t1..t3 remain
|
||||
expect(page.nextAnchor).toBe("t6");
|
||||
});
|
||||
|
||||
it("forward anchored returns the strictly-newer turns, ascending", async () => {
|
||||
seed(gw, 10);
|
||||
const page = await gw.readPage(P, C, {
|
||||
direction: "forward",
|
||||
anchor: "t4",
|
||||
limit: 3,
|
||||
});
|
||||
expect(page.turns.map((t) => t.id)).toEqual(["t5", "t6", "t7"]);
|
||||
expect(page.hasMore).toBe(true);
|
||||
expect(page.nextAnchor).toBe("t7");
|
||||
});
|
||||
|
||||
it("forward with no anchor returns the FIRST page (oldest)", async () => {
|
||||
seed(gw, 10);
|
||||
const page = await gw.readPage(P, C, { direction: "forward", limit: 4 });
|
||||
expect(page.turns.map((t) => t.id)).toEqual(["t1", "t2", "t3", "t4"]);
|
||||
expect(page.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it("clamps the limit to [1, 200] and defaults to 50", async () => {
|
||||
seed(gw, 60);
|
||||
// Default (no limit) ⇒ 50.
|
||||
const def = await gw.readPage(P, C, { direction: "backward" });
|
||||
expect(def.turns).toHaveLength(50);
|
||||
// 0 ⇒ default 50.
|
||||
const zero = await gw.readPage(P, C, { direction: "backward", limit: 0 });
|
||||
expect(zero.turns).toHaveLength(50);
|
||||
// Over 200 ⇒ clamped (only 60 exist, so all 60 here, proving > limit never throws).
|
||||
const big = await gw.readPage(P, C, { direction: "backward", limit: 999 });
|
||||
expect(big.turns).toHaveLength(60);
|
||||
// A non-positive (negative) limit is treated as "unset" ⇒ default 50.
|
||||
const neg = await gw.readPage(P, C, { direction: "backward", limit: -5 });
|
||||
expect(neg.turns).toHaveLength(50);
|
||||
// An explicit positive 1 is honoured (lower bound of the clamp).
|
||||
const one = await gw.readPage(P, C, { direction: "forward", limit: 1 });
|
||||
expect(one.turns).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("clamps a limit above 200 down to 200", async () => {
|
||||
seed(gw, 250);
|
||||
const page = await gw.readPage(P, C, { direction: "forward", limit: 500 });
|
||||
expect(page.turns).toHaveLength(200);
|
||||
expect(page.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it("returns an empty page for an empty conversation", async () => {
|
||||
const page = await gw.readPage(P, C, { direction: "backward" });
|
||||
expect(page.turns).toEqual([]);
|
||||
expect(page.hasMore).toBe(false);
|
||||
expect(page.nextAnchor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns an empty page for an unknown anchor", async () => {
|
||||
seed(gw, 5);
|
||||
const page = await gw.readPage(P, C, {
|
||||
direction: "forward",
|
||||
anchor: "does-not-exist",
|
||||
});
|
||||
expect(page.turns).toEqual([]);
|
||||
expect(page.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it("hasMore is false when the page reaches the thread end", async () => {
|
||||
seed(gw, 3);
|
||||
const page = await gw.readPage(P, C, { direction: "backward", limit: 50 });
|
||||
expect(page.turns.map((t) => t.id)).toEqual(["t1", "t2", "t3"]);
|
||||
expect(page.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the full (unbounded) turn text", async () => {
|
||||
const big = "Z".repeat(30_000);
|
||||
gw._setTurns(P, C, [turn(1, big)]);
|
||||
const page = await gw.readPage(P, C, { direction: "backward" });
|
||||
expect(page.turns[0].text).toHaveLength(30_000);
|
||||
});
|
||||
|
||||
it("isolates threads per (project, conversation)", async () => {
|
||||
gw._setTurns(P, C, [turn(1)]);
|
||||
gw._setTurns(P, "other", [turn(2), turn(3)]);
|
||||
const a = await gw.readPage(P, C, { direction: "backward" });
|
||||
const b = await gw.readPage(P, "other", { direction: "backward" });
|
||||
expect(a.turns.map((t) => t.id)).toEqual(["t1"]);
|
||||
expect(b.turns.map((t) => t.id)).toEqual(["t2", "t3"]);
|
||||
});
|
||||
});
|
||||
@ -38,10 +38,14 @@ import type {
|
||||
SkillScope,
|
||||
Template,
|
||||
TerminalSession,
|
||||
TurnPage,
|
||||
TurnView,
|
||||
Unsubscribe,
|
||||
} from "@/domain";
|
||||
import type {
|
||||
AgentGateway,
|
||||
ConversationGateway,
|
||||
ConversationPageRequest,
|
||||
ConversationDetails,
|
||||
CreateAgentInput,
|
||||
CreateMemoryInput,
|
||||
@ -1693,6 +1697,86 @@ export class MockWorkStateGateway implements WorkStateGateway {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory {@link ConversationGateway} with **real** pagination, mirroring the
|
||||
* LS6 backend slicing (`crates/infrastructure/.../conversation_log/mod.rs::page`
|
||||
* + `domain::clamp_page_limit`): turns stored oldest→newest; anchor pagination
|
||||
* is **strict** (turns strictly before/after the anchor); `limit` defaults to 50
|
||||
* and is clamped to `[1, 200]`; `hasMore` is true when more turns exist in the
|
||||
* travel direction; `nextAnchor` is the **last** (most recent) turn of the page.
|
||||
* Full turn text is preserved (never truncated).
|
||||
*/
|
||||
export class MockConversationGateway implements ConversationGateway {
|
||||
private threads = new Map<string, TurnView[]>();
|
||||
|
||||
private key(projectId: string, conversationId: string): string {
|
||||
return `${projectId}::${conversationId}`;
|
||||
}
|
||||
|
||||
/** Seeds the turns of a conversation (oldest→newest) for deterministic tests. */
|
||||
_setTurns(
|
||||
projectId: string,
|
||||
conversationId: string,
|
||||
turns: TurnView[],
|
||||
): void {
|
||||
this.threads.set(
|
||||
this.key(projectId, conversationId),
|
||||
turns.map((t) => structuredClone(t)),
|
||||
);
|
||||
}
|
||||
|
||||
async readPage(
|
||||
projectId: string,
|
||||
conversationId: string,
|
||||
request?: ConversationPageRequest,
|
||||
): Promise<TurnPage> {
|
||||
const all = this.threads.get(this.key(projectId, conversationId)) ?? [];
|
||||
const len = all.length;
|
||||
const raw = request?.limit;
|
||||
const limit = Math.min(Math.max(raw && raw > 0 ? raw : 50, 1), 200);
|
||||
const direction = request?.direction ?? "backward";
|
||||
const anchor = request?.anchor;
|
||||
|
||||
let start: number;
|
||||
let end: number;
|
||||
let hasMore: boolean;
|
||||
if (anchor === undefined) {
|
||||
if (direction === "forward") {
|
||||
start = 0;
|
||||
end = Math.min(limit, len);
|
||||
hasMore = end < len;
|
||||
} else {
|
||||
start = Math.max(len - limit, 0);
|
||||
end = len;
|
||||
hasMore = start > 0;
|
||||
}
|
||||
} else {
|
||||
const pos = all.findIndex((t) => t.id === anchor);
|
||||
if (pos === -1) {
|
||||
start = 0;
|
||||
end = 0;
|
||||
hasMore = false;
|
||||
} else if (direction === "forward") {
|
||||
start = pos + 1;
|
||||
end = Math.min(start + limit, len);
|
||||
hasMore = end < len;
|
||||
} else {
|
||||
end = pos;
|
||||
start = Math.max(end - limit, 0);
|
||||
hasMore = start > 0;
|
||||
}
|
||||
}
|
||||
|
||||
const turns = all.slice(start, end).map((t) => structuredClone(t));
|
||||
const last = turns.at(-1);
|
||||
return {
|
||||
turns,
|
||||
hasMore,
|
||||
...(last ? { nextAnchor: last.id } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function mostRestrictive(
|
||||
project?: PermissionSet["fallback"],
|
||||
agent?: PermissionSet["fallback"],
|
||||
@ -1722,6 +1806,7 @@ export function createMockGateways(): Gateways {
|
||||
embedder: new MockEmbedderGateway(),
|
||||
permission: new MockPermissionGateway(),
|
||||
workState: new MockWorkStateGateway(),
|
||||
conversation: new MockConversationGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ describe("createMockGateways", () => {
|
||||
it("exposes all gateways", () => {
|
||||
expect(Object.keys(gateways).sort()).toEqual([
|
||||
"agent",
|
||||
"conversation",
|
||||
"embedder",
|
||||
"git",
|
||||
"input",
|
||||
|
||||
Reference in New Issue
Block a user