From 7e64c74ee43f0c3f1327f4fd89b9258236b73e68 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 10:20:44 +0000 Subject: [PATCH] =?UTF-8?q?test:=20session=20utilities=20=E2=80=94=20isDef?= =?UTF-8?q?aultTitle,=20fromRow/toRow,=20createObservationMask?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 17 tests covering two untested modules in the session subsystem: session identity helpers and compaction observation masks. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../test/session/observation-mask.test.ts | 174 ++++++++++++++++++ .../test/session/session-utils.test.ts | 144 +++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 packages/opencode/test/session/observation-mask.test.ts create mode 100644 packages/opencode/test/session/session-utils.test.ts diff --git a/packages/opencode/test/session/observation-mask.test.ts b/packages/opencode/test/session/observation-mask.test.ts new file mode 100644 index 0000000000..2c47a0fa9f --- /dev/null +++ b/packages/opencode/test/session/observation-mask.test.ts @@ -0,0 +1,174 @@ +import { describe, test, expect } from "bun:test" +import { SessionCompaction } from "../../src/session/compaction" +import type { MessageV2 } from "../../src/session/message-v2" + +function makeToolPart(overrides: { + tool?: string + input?: Record + output?: string + status?: "completed" | "running" | "error" | "pending" +}): MessageV2.ToolPart { + const status = overrides.status ?? "completed" + const base = { + id: "part_1" as any, + sessionID: "sess_1" as any, + messageID: "msg_1" as any, + type: "tool" as const, + callID: "call_1", + tool: overrides.tool ?? "bash", + } + + if (status === "completed") { + return { + ...base, + state: { + status: "completed", + input: overrides.input ?? {}, + output: overrides.output ?? "", + title: "test", + metadata: {}, + time: { start: 1, end: 2 }, + }, + } as MessageV2.ToolPart + } + + if (status === "running") { + return { + ...base, + state: { + status: "running", + input: overrides.input ?? {}, + time: { start: 1 }, + }, + } as MessageV2.ToolPart + } + + if (status === "error") { + return { + ...base, + state: { + status: "error", + input: overrides.input ?? {}, + error: "something failed", + time: { start: 1, end: 2 }, + }, + } as MessageV2.ToolPart + } + + return { + ...base, + state: { status: "pending" }, + } as MessageV2.ToolPart +} + +describe("SessionCompaction.createObservationMask", () => { + test("produces correct mask for a completed tool with normal output", () => { + const part = makeToolPart({ + tool: "read", + input: { file_path: "/src/index.ts" }, + output: "line1\nline2\nline3", + }) + const mask = SessionCompaction.createObservationMask(part) + + expect(mask).toContain("[Tool output cleared") + expect(mask).toContain("read(") + expect(mask).toContain("file_path:") + expect(mask).toContain("returned 3 lines") + // "line1\nline2\nline3" = 17 bytes ASCII + expect(mask).toContain("17 B") + // First line fingerprint + expect(mask).toContain('— "line1"') + }) + + test("handles empty output gracefully", () => { + const part = makeToolPart({ + tool: "bash", + input: { command: "echo hello" }, + output: "", + }) + const mask = SessionCompaction.createObservationMask(part) + + expect(mask).toContain("returned 1 lines") + expect(mask).toContain("0 B") + // No fingerprint for empty output + expect(mask).not.toContain('— "') + }) + + test("counts lines and bytes correctly for multi-line output", () => { + // Generate enough lines to exceed 1 KB + const lines = Array.from({ length: 200 }, (_, i) => `output line number ${i}`) + const output = lines.join("\n") + const part = makeToolPart({ tool: "grep", output }) + const mask = SessionCompaction.createObservationMask(part) + + expect(mask).toContain("returned 200 lines") + const expectedBytes = Buffer.byteLength(output, "utf8") + expect(expectedBytes).toBeGreaterThan(1024) + expect(mask).toMatch(/\d+\.\d+ KB/) + expect(mask).toContain('— "output line number 0"') + }) + + test("formats bytes as KB and MB for larger outputs", () => { + // ~1 MB output + const bigOutput = "x".repeat(1024 * 1024 + 512) + const part = makeToolPart({ tool: "cat", output: bigOutput }) + const mask = SessionCompaction.createObservationMask(part) + + expect(mask).toMatch(/1\.0 MB/) + }) + + test("truncates long args without breaking surrogate pairs", () => { + // Create input with a value containing emoji (surrogate pairs in JS) + // U+1F600 = 😀 is represented as \uD83D\uDE00 in UTF-16 + const emoji = "😀" + // Build a value string where the emoji sits right near the truncation boundary + const padding = "a".repeat(70) + const part = makeToolPart({ + tool: "write", + input: { content: padding + emoji + "after" }, + }) + const mask = SessionCompaction.createObservationMask(part) + + // The mask should not contain a lone high surrogate + // Verify the args portion is well-formed by checking the whole mask is valid + expect(mask).toBeDefined() + // Ensure no lone surrogates by round-tripping through TextEncoder + const encoded = new TextEncoder().encode(mask) + const decoded = new TextDecoder().decode(encoded) + expect(decoded).toBe(mask) + }) + + test("uses empty input for pending tool parts", () => { + const part = makeToolPart({ tool: "bash", status: "pending" }) + const mask = SessionCompaction.createObservationMask(part) + + expect(mask).toContain("bash()") + expect(mask).toContain("returned 1 lines") + expect(mask).toContain("0 B") + }) + + test("uses input from running tool parts", () => { + const part = makeToolPart({ + tool: "edit", + input: { file: "main.ts" }, + status: "running", + }) + const mask = SessionCompaction.createObservationMask(part) + + expect(mask).toContain("edit(file:") + // Running parts have no output + expect(mask).toContain("0 B") + }) + + test("uses input from error tool parts", () => { + const part = makeToolPart({ + tool: "bash", + input: { command: "rm -rf /" }, + status: "error", + }) + const mask = SessionCompaction.createObservationMask(part) + + expect(mask).toContain("bash(command:") + expect(mask).toContain("0 B") + }) +}) diff --git a/packages/opencode/test/session/session-utils.test.ts b/packages/opencode/test/session/session-utils.test.ts new file mode 100644 index 0000000000..1e765fee65 --- /dev/null +++ b/packages/opencode/test/session/session-utils.test.ts @@ -0,0 +1,144 @@ +import { describe, test, expect } from "bun:test" +import { Session } from "../../src/session" + +describe("Session.isDefaultTitle", () => { + test("recognises parent session titles", () => { + expect(Session.isDefaultTitle("New session - 2024-01-15T08:30:00.000Z")).toBe(true) + }) + + test("recognises child session titles", () => { + expect(Session.isDefaultTitle("Child session - 2024-06-01T23:59:59.999Z")).toBe(true) + }) + + test("rejects arbitrary strings", () => { + expect(Session.isDefaultTitle("My custom title")).toBe(false) + expect(Session.isDefaultTitle("")).toBe(false) + expect(Session.isDefaultTitle("hello world")).toBe(false) + }) + + test("rejects prefix without valid ISO timestamp", () => { + expect(Session.isDefaultTitle("New session - not-a-date")).toBe(false) + expect(Session.isDefaultTitle("New session - ")).toBe(false) + expect(Session.isDefaultTitle("New session - 2024-01-15")).toBe(false) + }) + + test("rejects partial prefix matches", () => { + expect(Session.isDefaultTitle("New session 2024-01-15T08:30:00.000Z")).toBe(false) + expect(Session.isDefaultTitle("New session -2024-01-15T08:30:00.000Z")).toBe(false) + }) + + test("rejects titles with extra content after timestamp", () => { + expect(Session.isDefaultTitle("New session - 2024-01-15T08:30:00.000Z extra")).toBe(false) + }) +}) + +describe("Session.fromRow / toRow", () => { + test("roundtrip preserves fields with full summary", () => { + const info: Session.Info = { + id: "sess_123" as any, + slug: "abc-def", + projectID: "proj_456" as any, + workspaceID: "ws_789" as any, + directory: "/home/user/project", + parentID: "sess_parent" as any, + title: "Test session", + version: "0.5.13", + summary: { + additions: 10, + deletions: 5, + files: 3, + diffs: [{ file: "src/index.ts", additions: 10, deletions: 5 }] as any, + }, + share: { url: "https://example.com/share/123" }, + revert: "snapshot_abc" as any, + permission: "plan" as any, + time: { + created: 1700000000000, + updated: 1700001000000, + compacting: 1700002000000, + archived: 1700003000000, + }, + } + + const row = Session.toRow(info) + const restored = Session.fromRow(row as any) + + expect(restored.id).toBe(info.id) + expect(restored.slug).toBe(info.slug) + expect(restored.projectID).toBe(info.projectID) + expect(restored.workspaceID).toBe(info.workspaceID) + expect(restored.directory).toBe(info.directory) + expect(restored.parentID).toBe(info.parentID) + expect(restored.title).toBe(info.title) + expect(restored.version).toBe(info.version) + expect(restored.summary).toEqual(info.summary) + expect(restored.share).toEqual(info.share) + expect(restored.revert).toBe(info.revert) + expect(restored.permission).toBe(info.permission) + expect(restored.time.created).toBe(info.time.created) + expect(restored.time.updated).toBe(info.time.updated) + expect(restored.time.compacting).toBe(info.time.compacting) + expect(restored.time.archived).toBe(info.time.archived) + }) + + test("fromRow produces undefined summary when all summary columns are null", () => { + const row = { + id: "sess_1", + slug: "x", + project_id: "p1", + workspace_id: null, + directory: "/tmp", + parent_id: null, + title: "t", + version: "1", + summary_additions: null, + summary_deletions: null, + summary_files: null, + summary_diffs: null, + share_url: null, + revert: null, + permission: null, + time_created: 1, + time_updated: 2, + time_compacting: null, + time_archived: null, + } + const info = Session.fromRow(row as any) + expect(info.summary).toBeUndefined() + expect(info.share).toBeUndefined() + expect(info.revert).toBeUndefined() + expect(info.parentID).toBeUndefined() + expect(info.workspaceID).toBeUndefined() + expect(info.time.compacting).toBeUndefined() + expect(info.time.archived).toBeUndefined() + }) + + test("fromRow constructs summary when at least one summary column is non-null", () => { + const row = { + id: "sess_2", + slug: "y", + project_id: "p2", + workspace_id: null, + directory: "/tmp", + parent_id: null, + title: "t", + version: "1", + summary_additions: 5, + summary_deletions: null, + summary_files: null, + summary_diffs: null, + share_url: null, + revert: null, + permission: null, + time_created: 1, + time_updated: 2, + time_compacting: null, + time_archived: null, + } + const info = Session.fromRow(row as any) + expect(info.summary).toBeDefined() + expect(info.summary!.additions).toBe(5) + expect(info.summary!.deletions).toBe(0) + expect(info.summary!.files).toBe(0) + }) +})