Skip to content
Closed
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
174 changes: 174 additions & 0 deletions packages/opencode/test/session/observation-mask.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>
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
Comment on lines +58 to +61
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify schema requirements for ToolStatePending and compare with test fixture shape.
rg -n -C3 'export const ToolStatePending|status:\s*z\.literal\("pending"\)|input:\s*z\.record|raw:\s*z\.string' --type=ts
rg -n -C3 'function makeToolPart|status:\s*"pending"|state:\s*\{\s*status:\s*"pending"' packages/opencode/test/session/observation-mask.test.ts

Repository: AltimateAI/altimate-code

Length of output: 3210


Pending-state fixture in makeToolPart does not match ToolStatePending schema contract.

The fixture at line 60 only sets status: "pending" but omits the required input and raw fields. The force-cast with as MessageV2.ToolPart bypasses type validation and can mask schema regressions.

Proposed fix
   return {
     ...base,
-    state: { status: "pending" },
+    state: {
+      status: "pending",
+      input: overrides.input ?? {},
+      raw: "",
+    },
   } as MessageV2.ToolPart
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return {
...base,
state: { status: "pending" },
} as MessageV2.ToolPart
return {
...base,
state: {
status: "pending",
input: overrides.input ?? {},
raw: "",
},
} as MessageV2.ToolPart
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/opencode/test/session/observation-mask.test.ts` around lines 58 -
61, The pending-state fixture returned by makeToolPart currently sets state: {
status: "pending" } which violates the ToolStatePending contract (missing
required input and raw), and it hides the issue with a force-cast to
MessageV2.ToolPart; update makeToolPart so that when creating a pending state
(ToolStatePending) it includes the required fields (e.g., input and raw with
appropriate test values or nullability matching the schema) and remove or
replace the unsafe "as MessageV2.ToolPart" cast with a properly typed return or
a validated factory variant so the fixture matches the ToolStatePending shape
used by 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)
})
Comment on lines +120 to +139
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Surrogate-pair test does not prove truncation path is executed.

This test currently validates UTF-8 round-trip safety, but it can still pass when no truncation occurs. Add an assertion that truncation happened (e.g., ellipsis present / suffix removed).

Proposed strengthening
   test("truncates long args without breaking surrogate pairs", () => {
@@
-    const padding = "a".repeat(70)
+    const padding = "a".repeat(120)
@@
     const mask = SessionCompaction.createObservationMask(part)
@@
+    expect(mask).toContain("…")
+    expect(mask).not.toContain("after")
     // The mask should not contain a lone high surrogate
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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("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(120)
const part = makeToolPart({
tool: "write",
input: { content: padding + emoji + "after" },
})
const mask = SessionCompaction.createObservationMask(part)
expect(mask).toContain("…")
expect(mask).not.toContain("after")
// 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)
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/opencode/test/session/observation-mask.test.ts` around lines 120 -
139, The surrogate-pair test currently doesn't assert that truncation actually
ran; update the test that calls SessionCompaction.createObservationMask (the
test using makeToolPart and variable mask) to assert the truncation happened by
checking for an ellipsis or that the "after" suffix is removed (e.g.,
expect(mask).toContain("…") or expect(mask).not.toContain("after")); keep the
existing UTF-8 round-trip checks but add this additional assertion so the
truncation path is exercised and verified.


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")
})
})
144 changes: 144 additions & 0 deletions packages/opencode/test/session/session-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading