diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 15:03:54 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 15:03:54 +0900 |
| commit | 9d6b7a97e8e96429815503718e1437fae41bf5d5 (patch) | |
| tree | ba3b3a95454a6d150e34b595d92d39acacb8ad6a /packages/core | |
| parent | ecb001ec7a2e573d8dedf5064e860e5a3e7788fd (diff) | |
| parent | 40b0b6a23a5cbd494f9956315c2e424d16edb282 (diff) | |
| download | dispatch-9d6b7a97e8e96429815503718e1437fae41bf5d5.tar.gz dispatch-9d6b7a97e8e96429815503718e1437fae41bf5d5.zip | |
Merge branch 'dev' into td/todo-fix
Diffstat (limited to 'packages/core')
| -rw-r--r-- | packages/core/src/credentials/claude.ts | 38 | ||||
| -rw-r--r-- | packages/core/src/credentials/index.ts | 1 | ||||
| -rw-r--r-- | packages/core/src/db/tabs.ts | 14 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 1 | ||||
| -rw-r--r-- | packages/core/tests/credentials/wake-probe.test.ts | 49 | ||||
| -rw-r--r-- | packages/core/tests/db/tabs.test.ts | 69 |
6 files changed, 170 insertions, 2 deletions
diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts index 168d544..432e403 100644 --- a/packages/core/src/credentials/claude.ts +++ b/packages/core/src/credentials/claude.ts @@ -373,6 +373,44 @@ export function buildBillingHeaderValue( export const SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."; +/** + * Build the request body for a Claude "wake" probe — a tiny, cheap message + * whose only purpose is to keep the subscription's rate-limit window warm. + * + * This MUST mirror the shape of a genuine Claude Code CLI request, because + * Anthropic validates the `system[]` array on OAuth (Pro/Max) -authenticated, + * Claude-Code-billed requests. A bare `{ model, messages }` body (no system + * identity) is rejected (401/403) — which is exactly how the old probe silently + * failed. The valid shape is: + * + * system: [ + * { type: "text", text: "x-anthropic-billing-header: ..." }, // billing, no cache_control + * { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }, + * ] + * messages: [ { role: "user", content: "hi" } ] + * + * Mirrors the runtime `transformClaudeOAuthBody` output for a single short user + * turn. Pure: deterministic given its inputs (the billing header samples only + * the user text), so it can be unit-tested without touching the network. + */ +export function buildWakeProbeBody(model: string): { + model: string; + max_tokens: number; + system: Array<{ type: "text"; text: string }>; + messages: Array<{ role: "user"; content: string }>; +} { + const messages = [{ role: "user" as const, content: "hi" }]; + return { + model, + max_tokens: 16, + system: [ + { type: "text", text: buildBillingHeaderValue(messages) }, + { type: "text", text: SYSTEM_IDENTITY }, + ], + messages, + }; +} + // ─── Anthropic Request Headers ──────────────────────────────── export function getAnthropicHeaders(accessToken: string): Record<string, string> { diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts index ff7392b..46fa5b6 100644 --- a/packages/core/src/credentials/index.ts +++ b/packages/core/src/credentials/index.ts @@ -9,6 +9,7 @@ export { export { ANTHROPIC_MODELS_FALLBACK, buildBillingHeaderValue, + buildWakeProbeBody, type ClaudeAccount, type ClaudeCredentials, type ClaudeProfile, diff --git a/packages/core/src/db/tabs.ts b/packages/core/src/db/tabs.ts index 8b290d2..f719a01 100644 --- a/packages/core/src/db/tabs.ts +++ b/packages/core/src/db/tabs.ts @@ -115,6 +115,20 @@ export function updateTabStatus(id: string, status: string): void { }); } +export function updateTabPositions(idsInOrder: string[]): void { + const db = getDatabase(); + const now = Date.now(); + const update = db.query("UPDATE tabs SET position = $position, updated_at = $now WHERE id = $id"); + // One transaction so a reorder is atomic: either every tab lands at its new + // slot or none does, never a half-applied ordering. + const applyAll = db.transaction(() => { + idsInOrder.forEach((id, index) => { + update.run({ $id: id, $position: index, $now: now }); + }); + }); + applyAll(); +} + export function archiveTab(id: string): void { const db = getDatabase(); db.query("UPDATE tabs SET is_open = 0, updated_at = $now WHERE id = $id").run({ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a7b1cad..8334102 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -56,6 +56,7 @@ export { shortestUniquePrefix, type TabRow, updateTabModel, + updateTabPositions, updateTabStatus, updateTabTitle, } from "./db/tabs.js"; diff --git a/packages/core/tests/credentials/wake-probe.test.ts b/packages/core/tests/credentials/wake-probe.test.ts new file mode 100644 index 0000000..253efec --- /dev/null +++ b/packages/core/tests/credentials/wake-probe.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; + +// `claude.ts` transitively imports `db/index.js`, whose top-level +// `import { Database } from "bun:sqlite"` can't resolve under vitest's Node +// runtime. Stub the db module — `buildWakeProbeBody` never touches it. +vi.mock("../../src/db/index.js", () => ({ + getDatabase: vi.fn(() => { + throw new Error("db not available in this test"); + }), +})); + +const { buildWakeProbeBody } = await import("../../src/credentials/claude.js"); + +const IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."; + +describe("buildWakeProbeBody", () => { + it("targets the requested model with a tiny token budget", () => { + const body = buildWakeProbeBody("claude-3-5-haiku-20241022"); + expect(body.model).toBe("claude-3-5-haiku-20241022"); + expect(body.max_tokens).toBe(16); + }); + + it("emits a Claude-Code-shaped system[]: billing first, identity second", () => { + const body = buildWakeProbeBody("claude-3-5-haiku-20241022"); + expect(body.system).toHaveLength(2); + + // system[0] is the billing header line (no cache_control on a probe). + expect(body.system[0]).toEqual({ + type: "text", + text: expect.stringMatching(/^x-anthropic-billing-header: /), + }); + expect(body.system[0]).not.toHaveProperty("cache_control"); + + // system[1] is the VERBATIM Claude Code identity string. Anthropic + // rejects OAuth (Pro/Max) requests whose system[] lacks this. + expect(body.system[1]).toEqual({ type: "text", text: IDENTITY }); + }); + + it("carries a single short user message", () => { + const body = buildWakeProbeBody("claude-3-5-haiku-20241022"); + expect(body.messages).toEqual([{ role: "user", content: "hi" }]); + }); + + it("is deterministic for a given model (pure)", () => { + const a = buildWakeProbeBody("claude-3-5-haiku-20241022"); + const b = buildWakeProbeBody("claude-3-5-haiku-20241022"); + expect(a).toEqual(b); + }); +}); diff --git a/packages/core/tests/db/tabs.test.ts b/packages/core/tests/db/tabs.test.ts index 67533dc..2cd226b 100644 --- a/packages/core/tests/db/tabs.test.ts +++ b/packages/core/tests/db/tabs.test.ts @@ -50,6 +50,15 @@ class FakeDatabase { }; } + /** + * Match Bun's `db.transaction(fn)` shape: returns a callable that runs + * `fn` synchronously. The fake is in-memory and single-threaded, so we + * don't emulate rollback — callers just need the wrapper to be invocable. + */ + transaction(fn: () => void): () => void { + return () => fn(); + } + private execSelect(sql: string, params?: Record<string, unknown>): unknown[] { const norm = sql.replace(/\s+/g, " ").trim(); @@ -89,6 +98,11 @@ class FakeDatabase { return this.rows.filter((r) => r.is_open === 1).map((r) => ({ id: r.id })); } + // listOpenTabs: every open tab ordered by position. + if (norm === "SELECT * FROM tabs WHERE is_open = 1 ORDER BY position ASC") { + return this.rows.filter((r) => r.is_open === 1).sort((a, b) => a.position - b.position); + } + throw new Error(`FakeDatabase: unsupported SELECT: ${norm}`); } @@ -129,6 +143,16 @@ class FakeDatabase { return; } + // updateTabPositions: rewrite a single tab's position (run per id inside a txn) + if (norm === "UPDATE tabs SET position = $position, updated_at = $now WHERE id = $id") { + const row = this.rows.find((r) => r.id === params?.$id); + if (row) { + row.position = (params?.$position as number) ?? row.position; + row.updated_at = (params?.$now as number) ?? Date.now(); + } + return; + } + throw new Error(`FakeDatabase: unsupported mutation: ${norm}`); } } @@ -150,8 +174,16 @@ vi.mock("../../src/db/index.js", () => ({ // Dynamic import AFTER `vi.mock` registers (vitest hoists `vi.mock` to // the very top of the file, so by the time this line runs the mock is // active for `./index.js` resolution inside `tabs.ts`). -const { archiveTab, createTab, getDescendantIds, getTab, resolveTabPrefix, shortestUniquePrefix } = - await import("../../src/db/tabs.js"); +const { + archiveTab, + createTab, + getDescendantIds, + getTab, + listOpenTabs, + resolveTabPrefix, + shortestUniquePrefix, + updateTabPositions, +} = await import("../../src/db/tabs.js"); beforeAll(() => { fakeDb = new FakeDatabase(); @@ -351,3 +383,36 @@ describe("shortestUniquePrefix", () => { expect(shortestUniquePrefix("abcd1111-0000-4000-8000-000000000000")).toBe("abcd"); }); }); + +// --------------------------------------------------------------------------- +// updateTabPositions — drag-and-drop reorder persistence +// --------------------------------------------------------------------------- +describe("updateTabPositions", () => { + it("rewrites each tab's position to its index in the given order", () => { + createTab("a", "A"); // position 0 + createTab("b", "B"); // position 1 + createTab("c", "C"); // position 2 + + updateTabPositions(["c", "a", "b"]); + + // listOpenTabs orders by position → reflects the new order. + expect(listOpenTabs().map((t) => t.id)).toEqual(["c", "a", "b"]); + expect(getTab("c")?.position).toBe(0); + expect(getTab("a")?.position).toBe(1); + expect(getTab("b")?.position).toBe(2); + }); + + it("is a no-op for an empty list", () => { + createTab("a", "A"); + createTab("b", "B"); + updateTabPositions([]); + expect(listOpenTabs().map((t) => t.id)).toEqual(["a", "b"]); + }); + + it("ignores ids that don't exist without throwing", () => { + createTab("a", "A"); + expect(() => updateTabPositions(["ghost", "a"])).not.toThrow(); + // "a" took index 1 in the requested order. + expect(getTab("a")?.position).toBe(1); + }); +}); |
