Skip to content
Merged
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
42 changes: 17 additions & 25 deletions apps/web/src/domains/chat/inspector/compaction-trail-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
* React Query hook for the Compaction tab.
*
* **Call-scoped.** The hook fetches the set of compaction events that
* led up to a specific LLM call — not the entire conversation. Picking
* a different call in the rail produces a different trail (cache key
* varies on `callId`), so the question "what happened to the context
* before this call ran?" gets a focused answer.
* ran in the open window between the previous non-`compactionAgent`
* LLM call and the selected call — not the entire conversation.
* Picking a different call in the rail produces a different trail
* (cache key varies on `callId`), so the question "what did the
* compactor do to my context before *this specific* call ran?" gets a
* focused answer.
*
* Lazy-load contract: the underlying `queryFn` only fires when the
* tab is mounted (i.e. selected). Callers should not invoke this from
Expand All @@ -14,26 +16,17 @@
* never triggers the fetch. `staleTime` matches the rest of the
* inspector hooks (30s) so re-selecting the tab inside that window
* serves from cache without a re-fetch.
*
* Today the `queryFn` resolves to `fetchCompactionTrailMock`. When
* the daemon ships a real route, swap the import — the response shape
* is pinned by `CompactionTrailResponse` in `compaction-trail-types.ts`.
*/

import { queryOptions, useQuery } from "@tanstack/react-query";

import { fetchCompactionTrailMock } from "./compaction-trail-mock";
import type { CompactionTrailResponse } from "./compaction-trail-types";
import {
CompactionTrailRequestError,
fetchCompactionTrail,
} from "./compaction-trail-fetch";

export class CompactionTrailRequestError extends Error {
status: number;

constructor(status: number, message: string) {
super(message);
this.name = "CompactionTrailRequestError";
this.status = status;
}
}
export { CompactionTrailRequestError };

export function compactionTrailQueryOptions(
assistantId: string | undefined,
Expand All @@ -60,13 +53,12 @@ export function compactionTrailQueryOptions(
if (!callId) {
throw new CompactionTrailRequestError(0, "Missing callId");
}
// TODO: replace with real daemon fetch once the route exists:
// GET /v1/assistants/{assistantId}/conversations/{conversationId}/compaction
// ?callId={callId}
// Returns the same `CompactionTrailResponse` shape this mock does
// — see `compaction-trail-types.ts`. The daemon scopes the result
// server-side to compactions that happened before the call ran.
return await fetchCompactionTrailMock(conversationId, callId, signal);
return await fetchCompactionTrail(
assistantId,
conversationId,
callId,
signal,
);
},
enabled,
staleTime: 30_000,
Expand Down
168 changes: 168 additions & 0 deletions apps/web/src/domains/chat/inspector/compaction-trail-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* Tests for the Compaction Trail real fetcher.
*
* Spies on `client.get` rather than `mock.module`-ing the whole SDK,
* matching the pattern in `apps/web/src/domains/chat/api/messages.test.ts`
* — keeps the module registry clean for sibling test files.
*
* What's pinned:
* - URL pattern + path params + `callId` query reach the SDK
* exactly (the daemon route is hand-rolled, not generated, so
* drift here would silently 404).
* - The abort signal is forwarded so React Query can cancel
* in-flight requests when the tab unmounts.
* - HTTP failures raise `CompactionTrailRequestError` with the
* status code — the Compaction tab branches on `error.status`.
* - Malformed payloads raise the same error type with status `0`
* rather than silently returning an `events: []` trail.
*/

import {
afterEach,
beforeEach,
describe,
expect,
mock,
test,
} from "bun:test";

import { client } from "@/domains/chat/api/client";

import {
CompactionTrailRequestError,
fetchCompactionTrail,
} from "./compaction-trail-fetch";
import type { CompactionTrailResponse } from "./compaction-trail-types";

type CapturedGetOptions = {
url: string;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
signal?: AbortSignal;
};

let captured: CapturedGetOptions | null = null;
let nextGetResult: { data: unknown; error: unknown; response: Response };
const originalGet = client.get;

const SAMPLE_RESPONSE: CompactionTrailResponse = {
conversationId: "conv-abc",
events: [
{
id: "compaction-1",
createdAt: Date.parse("2026-05-26T22:19:11Z"),
model: "claude-sonnet-4-5",
provider: "anthropic",
inputTokens: 184_000,
outputTokens: 4_800,
durationMs: null,
responsePreview: "Picked up the New Conversation 404 Bug thread.",
requestMessageCount: 130,
stopReason: "end_turn",
estimatedCostUsd: 0.62,
},
],
};

beforeEach(() => {
captured = null;
nextGetResult = {
data: SAMPLE_RESPONSE,
error: null,
response: new Response(null, { status: 200 }),
};
client.get = mock(async (options: CapturedGetOptions) => {
captured = options;
return nextGetResult;
}) as typeof client.get;
});

afterEach(() => {
client.get = originalGet;
});

describe("fetchCompactionTrail", () => {
test("calls the assistant route with the platform path + query params", async () => {
await fetchCompactionTrail(
"assistant-1",
"conv-abc",
"call-32",
undefined,
);

expect(captured).not.toBeNull();
expect(captured!.url).toBe(
"/v1/assistants/{assistant_id}/conversations/{conversation_id}/compaction",
);
expect(captured!.path).toEqual({
assistant_id: "assistant-1",
conversation_id: "conv-abc",
});
expect(captured!.query).toEqual({ callId: "call-32" });
});

test("forwards the abort signal so React Query can cancel", async () => {
const controller = new AbortController();
await fetchCompactionTrail(
"assistant-1",
"conv-abc",
"call-32",
controller.signal,
);
expect(captured!.signal).toBe(controller.signal);
});

test("resolves with the response body on a 200", async () => {
const result = await fetchCompactionTrail(
"assistant-1",
"conv-abc",
"call-32",
undefined,
);
expect(result.conversationId).toBe("conv-abc");
expect(result.events).toHaveLength(1);
expect(result.events[0].id).toBe("compaction-1");
});

test("throws CompactionTrailRequestError with the HTTP status on non-OK", async () => {
nextGetResult = {
data: null,
error: { detail: "not found" },
response: new Response(null, { status: 404 }),
};

try {
await fetchCompactionTrail(
"assistant-1",
"conv-abc",
"call-32",
undefined,
);
throw new Error("expected fetchCompactionTrail to throw");
} catch (err) {
expect(err).toBeInstanceOf(CompactionTrailRequestError);
expect((err as CompactionTrailRequestError).status).toBe(404);
}
});

test("throws CompactionTrailRequestError(0) when the body is malformed", async () => {
nextGetResult = {
data: { conversationId: "conv-abc" }, // missing `events`
error: null,
response: new Response(null, { status: 200 }),
};

try {
await fetchCompactionTrail(
"assistant-1",
"conv-abc",
"call-32",
undefined,
);
throw new Error("expected fetchCompactionTrail to throw");
} catch (err) {
expect(err).toBeInstanceOf(CompactionTrailRequestError);
expect((err as CompactionTrailRequestError).status).toBe(0);
}
});
});
81 changes: 81 additions & 0 deletions apps/web/src/domains/chat/inspector/compaction-trail-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Real fetcher for the Compaction tab.
*
* Talks to the assistant's per-conversation route at
* `GET /v1/assistants/{assistantId}/conversations/{conversationId}/compaction?callId=…`,
* routed via the platform's `RuntimeProxyWildcardView`. Handler:
* `assistant/src/runtime/routes/conversation-compaction-routes.ts`.
*
* The assistant scopes the result **server-side** to the open window
* between the previous non-`compactionAgent` LLM call and the call
* identified by `callId` — picking a different call in the rail
* produces a different trail. See the route's doc comment for the
* floor/ceiling semantics.
*
* No generated SDK function exists for this route yet (the
* OpenAPI regen hasn't picked it up). We call `client.get` directly
* with the URL pattern + path/query params, matching sibling
* inspector hand-rolled fetchers (`fetchConversationMessages`,
* `archiveConversation`).
*/

import { client, SDK_BASE_OPTIONS } from "@/domains/chat/api/client";
import { assertHasResponse } from "@/lib/api-errors";

import type { CompactionTrailResponse } from "./compaction-trail-types";

export class CompactionTrailRequestError extends Error {
status: number;

constructor(status: number, message: string) {
super(message);
this.name = "CompactionTrailRequestError";
this.status = status;
}
}

/**
* Type guard for the wire shape returned by the assistant route. The
* `client.get` call is typed but `data` is still `unknown` on the wire
* — narrow defensively rather than trusting the generic.
*/
function isCompactionTrailResponse(
value: unknown,
): value is CompactionTrailResponse {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return typeof v.conversationId === "string" && Array.isArray(v.events);
}

export async function fetchCompactionTrail(
assistantId: string,
conversationId: string,
callId: string,
signal: AbortSignal | undefined,
): Promise<CompactionTrailResponse> {
const { data, error, response } = await client.get<
CompactionTrailResponse,
unknown
>({
...SDK_BASE_OPTIONS,
url: "/v1/assistants/{assistant_id}/conversations/{conversation_id}/compaction",
path: { assistant_id: assistantId, conversation_id: conversationId },
query: { callId },
signal,
throwOnError: false,
});
assertHasResponse(response, error, "Failed to fetch compaction trail");
if (!response.ok) {
throw new CompactionTrailRequestError(
response.status,
`Compaction trail request failed (HTTP ${response.status})`,
);
}
if (!isCompactionTrailResponse(data)) {
throw new CompactionTrailRequestError(
0,
"Compaction trail response was malformed",
);
}
return data;
}
Loading