summaryrefslogtreecommitdiffhomepage
path: root/packages/core
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 15:03:54 +0900
committerAdam Malczewski <[email protected]>2026-06-02 15:03:54 +0900
commit9d6b7a97e8e96429815503718e1437fae41bf5d5 (patch)
treeba3b3a95454a6d150e34b595d92d39acacb8ad6a /packages/core
parentecb001ec7a2e573d8dedf5064e860e5a3e7788fd (diff)
parent40b0b6a23a5cbd494f9956315c2e424d16edb282 (diff)
downloaddispatch-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.ts38
-rw-r--r--packages/core/src/credentials/index.ts1
-rw-r--r--packages/core/src/db/tabs.ts14
-rw-r--r--packages/core/src/index.ts1
-rw-r--r--packages/core/tests/credentials/wake-probe.test.ts49
-rw-r--r--packages/core/tests/db/tabs.test.ts69
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);
+ });
+});