open-design/apps/daemon/tests/handoff-design.test.ts
Bryan c530d163f8
feat(web): "Resume conversation in new chat" UI — #462 Commit B (companion to #1718) (#2264)
* feat(contracts): add handoff request/response DTOs

Adds HandoffRequest, HandoffResponse, and HANDOFF_SCHEMA_VERSION for
the upcoming POST /api/projects/:id/handoff synthesis endpoint. Mirrors
the finalize.ts subpath pattern (package.json#exports + esbuild entry +
index re-export) so daemon and web can import
@open-design/contracts/api/handoff.

Refs nexu-io/open-design#462.

* feat(daemon): add handoff synthesis pipeline (buildHandoffPrompt + synthesizeHandoffPrompt)

Adds `apps/daemon/src/handoff-design.ts` exposing the resume-conversation
synthesis primitives the upcoming `POST /api/projects/:id/handoff` route will
call into.

- `buildHandoffPrompt({ projectId, transcriptJsonl, transcriptMessageCount,
  now })` returns the system + user prompts. System prompt asks Claude to
  emit a structured Markdown body with Context / Decisions made / Open
  questions / Current focus / Provenance, with Provenance bullets explicitly
  flat (no Markdown emphasis on labels) to preempt the PR #1584 round-2
  parser bug.
- `synthesizeHandoffPrompt(db, projectsRoot, projectId, options)` reuses the
  existing finalize-design pipeline pieces: `exportProjectTranscript` →
  `truncateTranscriptForPrompt` → `buildHandoffPrompt` →
  `callAnthropicWithRetry` → `extractDesignMd`, but without the lockfile,
  disk write, design-system, or artifact-resolution paths.
- Promotes `DEFAULT_TIMEOUT_MS` in finalize-design.ts to `export const` so
  handoff shares the same 120s upstream-call bound.

Refs nexu-io/open-design#462.

* feat(daemon): wire POST /api/projects/:id/handoff route

Adds the handoff HTTP route and registers it in server.ts. Validation
block + error-mapping shape mirror registerFinalizeRoutes (BYOK payload,
upstream-error → ApiErrorCode mapping, redactSecrets on the raw upstream
body). Handoff has no lockfile, so the CONFLICT branch is omitted.

`res.on('close')` is wired to flip an AbortController whose signal is
threaded into synthesizeHandoffPrompt, so a UI-side cancel actually
aborts the daemon-side Anthropic call rather than letting it keep
running after the client walks away (mirrors the PR #974 fix for
finalize).

- `apps/daemon/src/handoff-routes.ts` — new, exports registerHandoffRoutes
  + RegisterHandoffRoutesDeps.
- `apps/daemon/src/server-context.ts` — adds handoff slot to ServerContext.
- `apps/daemon/src/route-context-contract.ts` — adds RegisterHandoffRoutesDeps
  to the compile-time coverage assertion.
- `apps/daemon/src/server.ts` — imports synthesizeHandoffPrompt +
  registerHandoffRoutes, builds handoffDeps, registers the route next
  to finalize.
- `apps/daemon/tests/handoff-route.test.ts` — 12 HTTP-layer tests:
  validation (400/403/404), happy path, upstream error mapping
  (401/429/502/502 non-JSON), api-key redaction.
- `apps/daemon/tests/handoff-route-abort.test.ts` — client-disconnect
  aborts the daemon-side controller.

Refs nexu-io/open-design#462.

* fix(daemon): map TranscriptExportLockedError to 409 CONFLICT on handoff route

`exportProjectTranscript` acquires a per-project `.transcript.lock`
internally (apps/daemon/src/transcript-export.ts:131-163) and throws
`TranscriptExportLockedError` on EEXIST. Concurrent handoff requests —
or a handoff that races `/api/projects/:id/finalize/anthropic` — lost
that lock and surfaced as 500 INTERNAL_ERROR through the route's
generic catch.

- `apps/daemon/src/handoff-routes.ts` — catch `TranscriptExportLockedError`
  and return `409 CONFLICT` ahead of the generic 500 branch, mirroring
  the existing `FinalizePackageLockedError → 409 CONFLICT` mapping at
  `apps/daemon/src/import-export-routes.ts:603-605`.
- `apps/daemon/src/server.ts` — thread `TranscriptExportLockedError`
  through `handoffDeps` so the route can match without a direct import.
- `apps/daemon/src/handoff-design.ts` — correct the module header
  comment that incorrectly claimed "no lockfile (concurrent handoff
  calls are safe)" — handoff does not add its own lock, but it does
  transitively acquire `.transcript.lock` via the transcript-export
  call.
- `apps/daemon/tests/handoff-route.test.ts` — regression test that
  pre-acquires `.transcript.lock` on disk via `fs.openSync(lockPath, 'wx')`
  before firing a handoff request, asserts 409 CONFLICT.

Refs nexu-io/open-design#462 — addresses @nettee's blocking review on
PR #1718 (comment 3242251338).

* fix(daemon): keep handoff request timeout armed through the response body read

`synthesizeHandoffPrompt` cleared the upstream-call timeout in a `finally`
that ran as soon as `callAnthropicWithRetry` returned. But `fetch()`
resolves once the upstream sends *headers* — so the subsequent
`await response.json()` body read ran with no timeout. A response that
sends headers and then stalls its body could hang `/api/projects/:id/handoff`
indefinitely instead of failing.

- `apps/daemon/src/handoff-design.ts` — move `clearTimeout(timeoutId)` into a
  single outer `finally` spanning both the call and the `response.json()`
  body parse, so the timeout stays armed until the body is fully consumed.
- `apps/daemon/src/handoff-design.ts` — the body-parse catch now re-throws
  `AbortError` as-is, mirroring the call-phase catch. Without this a
  body-phase timeout would surface as `502` "non-JSON body"; re-throwing
  lets the route map it to the intended `503` "handoff timed out"
  (`handoff-routes.ts:122-124`).
- `apps/daemon/tests/handoff-design.test.ts` — regression test: a `fetchImpl`
  returning a `Response` whose body never closes after headers, raced
  against a 500ms deadline, asserts the call aborts (not hangs) and rejects
  with `AbortError`.

Refs nexu-io/open-design#462 — addresses @nettee's round-2 blocking review
on PR #1718 (`handoff-design.ts:196`).

* fix(daemon): map upstream 400 to 400 BAD_REQUEST on handoff route

`callAnthropicWithRetry` preserves a non-retryable upstream status, so an
Anthropic HTTP 400 (`invalid_request_error` — unknown model, invalid
maxTokens, malformed body) reached the route's `FinalizeUpstreamError`
branch and fell through to `502 UPSTREAM_UNAVAILABLE`. That reported
deterministic caller input as a transient server outage, inviting
pointless retries and hiding which field was wrong.

- `apps/daemon/src/handoff-routes.ts` — special-case `err.status === 400`
  to `400 BAD_REQUEST` with the redacted upstream detail, ahead of the
  generic 502. Also refresh the route docblock: it claimed the 409 branch
  was omitted (stale since the R1 TranscriptExportLockedError fix) and
  that error mapping fully mirrors finalize (now diverges on 400).
- `apps/daemon/tests/handoff-route.test.ts` — route test driving an
  Anthropic `400 invalid_request_error`: asserts 400 BAD_REQUEST, the
  upstream detail is surfaced, and an echoed key is redacted.
- `packages/contracts/tests/package-runtime.test.ts` — import
  `@open-design/contracts/api/handoff` through the package `exports` map
  and assert `HANDOFF_SCHEMA_VERSION`, covering the built publish surface
  (esbuild entry + exports map + root re-export) that the source-only
  `handoff-contract.test.ts` does not exercise.

Refs nexu-io/open-design#462 — addresses @nettee's round-3 blocking
review on PR #1718.

* fix(daemon): await the now-async external base-URL validator on handoff route

Main's #1176 (`9a64fccd`) made `validateExternalApiBaseUrl` DNS-aware and
asynchronous (`validateBaseUrlResolved`) and updated the proxy and finalize
callers to `await` it. The handoff route — added on this branch in parallel,
against the old synchronous validator — still called it without `await`, so
`validated` was a Promise: `validated.error` / `validated.forbidden` were
`undefined`, the SSRF / malformed-URL guard silently no-opped, and a bad
`baseUrl` fell through to the upstream call and surfaced as 502.

A semantic merge break — no textual conflict, green on the branch in
isolation, red once CI re-merged latest main.

- `apps/daemon/src/handoff-routes.ts` — `await validateExternalApiBaseUrl(...)`,
  mirroring the finalize route (`import-export-routes.ts:561`). The handler
  is already `async`.

The existing `handoff-route.test.ts` cases "400 BAD_REQUEST when baseUrl is
not a valid URL" and "403 FORBIDDEN when baseUrl points at a private internal
IP" already encode this — red against branch + latest main, green now.

Refs nexu-io/open-design#462 — PR #1718 CI fix.

* chore(daemon): list handoff in the assertServerContextSatisfiesRoutes literal

The `assertServerContextSatisfiesRoutes({...})` call in `server.ts` enumerates
every route registrar's deps but omitted `handoff`. Adding `handoff: handoffDeps`
makes the literal complete and consistent with the other route deps.

This was not a typecheck break: route-dep coverage is guaranteed by the
`Assert<ServerContext extends AllRegisteredRouteDeps>` type in
`route-context-contract.ts` — and `AllRegisteredRouteDeps` already includes
`RegisterHandoffRoutesDeps` — not by this assertion-call literal. The literal
has omitted `handoff` since this branch's first push (`806db576`) through green
CI throughout; `tsc -p tsconfig.json --noEmit` is clean before and after.

Refs nexu-io/open-design#462 — addresses @nettee's round-4 review note on PR #1718.

* feat(web): add "Resume conversation in new chat" action (#462)

Adds a Resume control to the chat header, next to "New conversation".
Clicking it synthesizes a handoff prompt from the current transcript
via POST /api/projects/:id/handoff, opens a fresh conversation, and
auto-sends the synthesized prompt as its first user message — so a
drifted session resumes without the user replaying context by hand.
The old conversation is preserved.

- synthesizeHandoff() web-state wrapper in apps/web/src/state/projects.ts
- resume-conversation icon button in ChatPane (onResumeConversation /
  resumeConversationDisabled props)
- handleResumeConversation + pendingResumeRef + auto-send effect in
  ProjectView; effect gates on messagesConversationId so the prompt
  cannot fire before the new conversation's message read settles
- chat.resumeConversation i18n key across all 19 locales

Commit B of #462; Commit A is the daemon endpoint (PR #1718). This
branch is stacked on feat/handoff-endpoint so the web code resolves
@open-design/contracts/api/handoff.

* fix(daemon): scope handoff to one conversation + reject empty transcripts (#462)

Addresses the review on #1718 and #2264:

- mrcfps (#2264): the handoff endpoint exported the whole project's
  transcript, so a multi-conversation project blended unrelated chats
  into the synthesized prompt. HandoffRequest now carries a required
  conversationId; the route validates it belongs to the project
  (404 CONVERSATION_NOT_FOUND), and exportProjectTranscript takes an
  optional conversationId filter so only that conversation is exported.
- nettee (#1718): a zero-message conversation still called Anthropic and
  fabricated a handoff. synthesizeHandoffPrompt now throws
  EmptyTranscriptError on messageCount === 0; the route maps it to
  400 EMPTY_TRANSCRIPT before any BYOK tokens are spent.

HANDOFF_SCHEMA_VERSION bumped to 2 (conversationId is a new required
request field). Regression tests: a two-conversation scoping test, an
empty-conversation route + pipeline test, and a transcript-export
conversationId-filter unit test.

* feat(web): send conversationId with the resume handoff request (#462)

Follows the handoff endpoint becoming conversation-scoped. The resume
flow now passes the active conversationId to POST /handoff so the
synthesized prompt summarizes only the conversation being resumed.
handleResumeConversation bails when there is no active conversation;
synthesizeHandoff and the resume tests carry the new field.

* feat(daemon): add `od project handoff` CLI + register handoff error codes (#462)

Addresses the second-round review on #1718 and #2264:

- mrcfps (#2264): per AGENTS.md "Capability exposure (UI/CLI dual-track)",
  a user-facing capability must be reachable through the `od` CLI, not
  only the web UI. Adds `od project handoff <id> --conversation <id>
  --api-key <key> --model <model> [--base-url] [--max-tokens] [--json]`,
  driving the same POST /api/projects/:id/handoff endpoint. The logic
  lives in a testable handoff-cli.ts sibling module (mirrors
  artifacts-cli.ts) so cli.ts's import-time dispatch stays out of tests.
- nettee (#1718): the route emitted CONVERSATION_NOT_FOUND and
  EMPTY_TRANSCRIPT, which were absent from the shared API_ERROR_CODES
  union. Both are now registered in packages/contracts/src/errors.ts,
  with a contract test pinning them so the route and contract cannot
  drift again.

A CLI contract test covers the conversation-scoped request shape,
--json output, flag validation, and daemon-error surfacing.

* fix(daemon): fail `od project handoff` on a malformed 2xx response (#462)

Addresses nettee's review on #1718: runProjectHandoff treated any 2xx
response as success, so a broken daemon/proxy 200 with malformed or
shape-invalid JSON would print `undefined` (or `{}` under --json) and
still exit 0 — breaking the fail-fast contract scripts rely on. It now
validates the body is a well-formed HandoffResponse via an
isHandoffResponse type guard and fails fast otherwise. Regression tests
cover a shape-invalid and an unparseable 200 body.

* feat(web): surface the daemon's classified handoff error in the resume toast (#462)

Addresses mrcfps's non-blocking note on #2264: synthesizeHandoff returned
null for every non-2xx response, so RATE_LIMITED, EMPTY_TRANSCRIPT, and an
upstream 400 with provider detail all collapsed into one generic "check
your API key" toast — even though handoff-routes.ts had already classified
and sanitized them.

synthesizeHandoff now returns the daemon's structured `{ error }` on a
classified failure; `null` stays reserved for a transport failure or an
unparseable body. handleResumeConversation surfaces error.message plus
redacted details for the `{ error }` case, and a distinct
daemon-unreachable message for null.

* fix(web): omit empty baseUrl from the resume handoff request (#462)

Addresses mrcfps's review on #2264: the default Anthropic config
normalizes baseUrl to '' (config.ts), and the handoff route 400s an
explicit empty baseUrl — so the Resume action failed before synthesis
for every user who never set a custom base URL.

handleResumeConversation now forwards baseUrl only when config.baseUrl
is a non-empty string, matching the contract's optional-field semantics.
Tests: the default-config path asserts baseUrl is absent from the
request, and a new case covers a custom baseUrl being forwarded.

* refactor(daemon): dispatch `od project handoff` before the generic project parser (#462)

Addresses nettee's non-blocking note on #1718: runProject ran the shared
parseFlags(PROJECT_*) before reaching the handoff switch case, so a
malformed `od project handoff` invocation (`--unknown`, `--max-tokens`
with no value) threw out of the generic parser instead of hitting
handoff-cli's structured fail() — the entrypoint behaved differently
from the unit-tested runProjectHandoff helper.

The handoff sub now short-circuits before parseFlags / projectDaemonUrl,
so `od project handoff` runs exactly runProjectHandoff with no
intervening parsing. handoff-cli.test.ts gains unknown-flag and
missing-value cases covering the structured fail path.

---------

Co-authored-by: DevForgeAI CI/CD Engineer <devforge-ai@development.ai>
2026-05-20 13:28:27 +08:00

395 lines
13 KiB
TypeScript

// @ts-nocheck
// Tests for `apps/daemon/src/handoff-design.ts` — the synthesis pipeline
// behind `POST /api/projects/:id/handoff`. Mirrors the finalize-design
// test layout: a prompt-builder block, then a full-pipeline block with
// mocked Anthropic upstream.
import { afterEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
closeDatabase,
insertConversation,
insertProject,
openDatabase,
upsertMessage,
} from '../src/db.js';
import {
buildHandoffPrompt,
EmptyTranscriptError,
HANDOFF_SYSTEM_PROMPT,
synthesizeHandoffPrompt,
} from '../src/handoff-design.js';
import { FinalizeUpstreamError } from '../src/finalize-design.js';
const PROJECT_ID = 'project-handoff-1';
let tempDir: string | null = null;
let projectsRoot: string | null = null;
afterEach(() => {
closeDatabase();
vi.restoreAllMocks();
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
tempDir = null;
projectsRoot = null;
});
function setupProjectFixture(): { db: any; projectsRoot: string } {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-handoff-'));
const db = openDatabase(tempDir);
insertProject(db, {
id: PROJECT_ID,
name: 'Project',
createdAt: 1,
updatedAt: 1,
});
projectsRoot = path.join(tempDir, 'projects');
fs.mkdirSync(path.join(projectsRoot, PROJECT_ID), { recursive: true });
return { db, projectsRoot };
}
function seedConversation(
db: any,
options?: { messageCount?: number; conversationId?: string; text?: string },
) {
const count = options?.messageCount ?? 2;
const conversationId = options?.conversationId ?? 'conv-1';
const text = options?.text ?? 'message';
insertConversation(db, {
id: conversationId,
projectId: PROJECT_ID,
title: 'Resume me',
createdAt: 100,
updatedAt: 100,
});
for (let i = 0; i < count; i += 1) {
upsertMessage(db, conversationId, {
id: `${conversationId}-msg-${i}`,
role: i % 2 === 0 ? 'user' : 'assistant',
content: '',
events: [{ kind: 'text', text: `${text} ${i}` }],
});
}
}
describe('buildHandoffPrompt', () => {
const FIXED_NOW = new Date('2026-05-14T13:41:05.000Z');
const TRANSCRIPT_FIXTURE =
JSON.stringify({ kind: 'header', schemaVersion: 2, projectId: PROJECT_ID, messageCount: 2 }) +
'\n' +
JSON.stringify({ kind: 'message', id: 'm1', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }) +
'\n' +
JSON.stringify({ kind: 'message', id: 'm2', role: 'assistant', blocks: [{ type: 'text', text: 'hello' }] }) +
'\n';
it('uses HANDOFF_SYSTEM_PROMPT as the exported system prompt constant', () => {
const out = buildHandoffPrompt({
projectId: PROJECT_ID,
transcriptJsonl: TRANSCRIPT_FIXTURE,
transcriptMessageCount: 2,
now: FIXED_NOW,
});
expect(out.systemPrompt).toBe(HANDOFF_SYSTEM_PROMPT);
expect(HANDOFF_SYSTEM_PROMPT).toContain('## Context');
expect(HANDOFF_SYSTEM_PROMPT).toContain('## Decisions made');
expect(HANDOFF_SYSTEM_PROMPT).toContain('## Open questions');
expect(HANDOFF_SYSTEM_PROMPT).toContain('## Current focus');
expect(HANDOFF_SYSTEM_PROMPT).toContain('## Provenance');
});
it('includes the transcript verbatim and the generation context fields in the user prompt', () => {
const out = buildHandoffPrompt({
projectId: PROJECT_ID,
transcriptJsonl: TRANSCRIPT_FIXTURE,
transcriptMessageCount: 2,
now: FIXED_NOW,
});
expect(out.userPrompt).toContain(TRANSCRIPT_FIXTURE);
expect(out.userPrompt).toContain(`Project ID: ${PROJECT_ID}`);
expect(out.userPrompt).toContain('Transcript message count: 2');
expect(out.userPrompt).toContain('Generated at: 2026-05-14T13:41:05.000Z');
});
it('emits an ISO 8601 UTC timestamp (suffix Z, millisecond precision)', () => {
const out = buildHandoffPrompt({
projectId: PROJECT_ID,
transcriptJsonl: TRANSCRIPT_FIXTURE,
transcriptMessageCount: 0,
now: FIXED_NOW,
});
expect(out.userPrompt).toMatch(/Generated at: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/);
});
});
describe('synthesizeHandoffPrompt (pipeline)', () => {
function jsonResponse(status: number, body: any): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
function textResponse(status: number, body: string): Response {
return new Response(body, { status });
}
function fakeAnthropicSuccess(promptBody: string, inputTokens = 1234, outputTokens = 567): Response {
return jsonResponse(200, {
content: [{ type: 'text', text: promptBody }],
usage: { input_tokens: inputTokens, output_tokens: outputTokens },
});
}
const baseOptions = {
conversationId: 'conv-1',
apiKey: 'sk-test',
model: 'claude-opus-4-7',
maxTokens: 4096,
};
it('returns { prompt, model, inputTokens, outputTokens, transcriptMessageCount } on happy path', async () => {
const { db, projectsRoot } = setupProjectFixture();
seedConversation(db, { messageCount: 4 });
const synthesized = '## Context\nresume this work\n\n## Decisions made\n- (none)\n';
const fetchImpl = vi.fn(async () => fakeAnthropicSuccess(synthesized, 100, 50));
const result = await synthesizeHandoffPrompt(db, projectsRoot, PROJECT_ID, {
...baseOptions,
fetchImpl,
});
expect(result.prompt).toBe(synthesized);
expect(result.model).toBe('claude-opus-4-7');
expect(result.inputTokens).toBe(100);
expect(result.outputTokens).toBe(50);
expect(result.transcriptMessageCount).toBe(4);
expect(fetchImpl).toHaveBeenCalledOnce();
});
it('defaults baseUrl to https://api.anthropic.com when caller omits it', async () => {
const { db, projectsRoot } = setupProjectFixture();
seedConversation(db);
let observedUrl = '';
const fetchImpl = vi.fn(async (url: string) => {
observedUrl = url;
return fakeAnthropicSuccess('p');
});
await synthesizeHandoffPrompt(db, projectsRoot, PROJECT_ID, {
...baseOptions,
fetchImpl,
});
expect(observedUrl.startsWith('https://api.anthropic.com/v1/messages')).toBe(true);
});
it('rewraps fetch network errors as FinalizeUpstreamError(502)', async () => {
const { db, projectsRoot } = setupProjectFixture();
seedConversation(db);
const fetchImpl = vi.fn(async () => {
throw new TypeError('fetch failed');
});
await expect(
synthesizeHandoffPrompt(db, projectsRoot, PROJECT_ID, {
...baseOptions,
fetchImpl,
}),
).rejects.toThrow(FinalizeUpstreamError);
try {
await synthesizeHandoffPrompt(db, projectsRoot, PROJECT_ID, {
...baseOptions,
fetchImpl,
});
} catch (err: any) {
expect(err).toBeInstanceOf(FinalizeUpstreamError);
expect(err.status).toBe(502);
}
});
it('rewraps non-JSON 200 body as FinalizeUpstreamError(502)', async () => {
const { db, projectsRoot } = setupProjectFixture();
seedConversation(db);
const fetchImpl = vi.fn(async () => textResponse(200, '<html>not json</html>'));
await expect(
synthesizeHandoffPrompt(db, projectsRoot, PROJECT_ID, {
...baseOptions,
fetchImpl,
}),
).rejects.toMatchObject({ name: 'FinalizeUpstreamError', status: 502 });
});
it('honors a caller-supplied AbortSignal — aborting the call surfaces an AbortError', async () => {
const { db, projectsRoot } = setupProjectFixture();
seedConversation(db);
const controller = new AbortController();
const fetchImpl = vi.fn((url: string, init: RequestInit) => {
return new Promise((_resolve, reject) => {
init.signal?.addEventListener('abort', () => {
const err = new Error('aborted');
err.name = 'AbortError';
reject(err);
});
});
});
const pending = synthesizeHandoffPrompt(db, projectsRoot, PROJECT_ID, {
...baseOptions,
fetchImpl,
signal: controller.signal,
});
controller.abort();
await expect(pending).rejects.toMatchObject({ name: 'AbortError' });
});
it('bounds upstream calls via the exported DEFAULT_TIMEOUT_MS (test-only timeoutMs override)', async () => {
const { db, projectsRoot } = setupProjectFixture();
seedConversation(db);
const fetchImpl = vi.fn((_url: string, init: RequestInit) => {
return new Promise((_resolve, reject) => {
init.signal?.addEventListener('abort', () => {
const err = new Error('timed out');
err.name = 'AbortError';
reject(err);
});
});
});
const pending = synthesizeHandoffPrompt(db, projectsRoot, PROJECT_ID, {
...baseOptions,
fetchImpl,
timeoutMs: 5,
});
await expect(pending).rejects.toMatchObject({ name: 'AbortError' });
});
it('keeps the timeout armed through the body read — a stalled response body aborts instead of hanging', async () => {
const { db, projectsRoot } = setupProjectFixture();
seedConversation(db);
// fetch() resolves as soon as the upstream sends headers; this body
// never enqueues and never closes. The stream honors init.signal the
// way a real fetch body does, so a mid-read abort errors the stream.
const fetchImpl = vi.fn((_url: string, init: RequestInit) => {
const body = new ReadableStream({
start(controller) {
init.signal?.addEventListener('abort', () => {
const err = new Error('aborted');
err.name = 'AbortError';
controller.error(err);
});
},
});
return Promise.resolve(
new Response(body, {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
});
const pending = synthesizeHandoffPrompt(db, projectsRoot, PROJECT_ID, {
...baseOptions,
fetchImpl,
timeoutMs: 5,
});
// If clearTimeout runs before response.json() (the regressed bug), the
// timeout is disarmed and the stalled body read hangs forever — 'hung'
// would win this race. The timeout must stay armed and abort the read.
let hangTimer: ReturnType<typeof setTimeout>;
const outcome = await Promise.race([
pending.then(
() => 'resolved',
(err) => err,
),
new Promise((resolve) => {
hangTimer = setTimeout(() => resolve('hung'), 500);
}),
]);
clearTimeout(hangTimer!);
expect(outcome).not.toBe('hung');
expect(outcome).not.toBe('resolved');
expect(outcome).toMatchObject({ name: 'AbortError' });
});
it('truncates transcripts over 384 KiB to fit the prompt budget', async () => {
const { db, projectsRoot } = setupProjectFixture();
seedConversation(db, { messageCount: 5_000 });
let capturedBody = '';
const fetchImpl = vi.fn(async (_url: string, init: RequestInit) => {
capturedBody = String(init.body ?? '');
return fakeAnthropicSuccess('p');
});
const result = await synthesizeHandoffPrompt(db, projectsRoot, PROJECT_ID, {
...baseOptions,
fetchImpl,
});
const parsed = JSON.parse(capturedBody);
const userMessage = parsed.messages[0].content as string;
expect(userMessage.length).toBeLessThan(400 * 1024);
expect(userMessage).toContain('"kind":"truncated"');
expect(result.transcriptMessageCount).toBe(5_000);
});
it('scopes the synthesized transcript to the requested conversation, not the whole project', async () => {
const { db, projectsRoot } = setupProjectFixture();
// Two unrelated conversations in the same project. A project-wide
// export would blend both histories; handoff must summarize only the
// conversation the user is resuming.
seedConversation(db, { conversationId: 'conv-alpha', messageCount: 2, text: 'ALPHA-TOPIC' });
seedConversation(db, { conversationId: 'conv-bravo', messageCount: 2, text: 'BRAVO-TOPIC' });
let capturedBody = '';
const fetchImpl = vi.fn(async (_url: string, init: RequestInit) => {
capturedBody = String(init.body ?? '');
return fakeAnthropicSuccess('p');
});
const result = await synthesizeHandoffPrompt(db, projectsRoot, PROJECT_ID, {
...baseOptions,
conversationId: 'conv-alpha',
fetchImpl,
});
const userMessage = JSON.parse(capturedBody).messages[0].content as string;
expect(userMessage).toContain('ALPHA-TOPIC');
expect(userMessage).not.toContain('BRAVO-TOPIC');
expect(result.transcriptMessageCount).toBe(2);
});
it('rejects an empty conversation with EmptyTranscriptError instead of spending BYOK tokens', async () => {
const { db, projectsRoot } = setupProjectFixture();
seedConversation(db, { conversationId: 'conv-empty', messageCount: 0 });
const fetchImpl = vi.fn(async () => fakeAnthropicSuccess('p'));
await expect(
synthesizeHandoffPrompt(db, projectsRoot, PROJECT_ID, {
...baseOptions,
conversationId: 'conv-empty',
fetchImpl,
}),
).rejects.toBeInstanceOf(EmptyTranscriptError);
// The guard must fire before the upstream call — no tokens spent.
expect(fetchImpl).not.toHaveBeenCalled();
});
});