Compare commits

..
Author SHA1 Message Date
Hona b2af928895 fix(session-ui): count patched files in tool summaries
Grouped tool summaries used the raw tool-call count, so repeated patch calls reported the number of invocations instead of the distinct files changed. Derive the count from successful patch metadata while keeping the existing fallback for other tools.
2026-09-09 23:39:58 +00:00
25 changed files with 140 additions and 677 deletions
@@ -181,7 +181,7 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
responseID,
request,
// Completion can re-encrypt reasoning. Callers replay the item already emitted by output_item.done.
output: event.response?.output?.length
output: event.response?.output
? event.response.output.map((item) =>
item.type === "reasoning" && item.id !== undefined
? (output.find((done) => done.type === item.type && done.id === item.id) ?? item)
@@ -581,54 +581,52 @@ describe("OpenAI Responses route", () => {
)
it.effect("continues a streamed tool call with only the new tool output", () =>
Effect.forEach([undefined, []], (output) =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "gpt-5.2",
store: false,
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
}
const first = continuationDriver(firstRequest)
const firstCreate = yield* first.create(undefined)
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "gpt-5.2",
store: false,
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
}
const first = continuationDriver(firstRequest)
const firstCreate = yield* first.create(undefined)
yield* first.observe(
firstCreate,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
arguments: '{ "city": "Paris" }',
},
}),
)
const saved = checkpoint(
yield* first.observe(
firstCreate,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
arguments: '{ "city": "Paris" }',
},
}),
)
const saved = checkpoint(
yield* first.observe(
firstCreate,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1", output } }),
),
)
const second = continuationDriver({
...firstRequest,
input: [
...firstRequest.input,
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
],
})
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const second = continuationDriver({
...firstRequest,
input: [
...firstRequest.input,
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
],
})
const create = yield* second.create(saved)
const create = yield* second.create(saved)
expect(create.mode).toBe("incremental")
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
previous_response_id: "resp_1",
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
})
}),
),
expect(create.mode).toBe("incremental")
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
previous_response_id: "resp_1",
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
})
}),
)
it.effect("continues a tool call from authoritative completed response output", () =>
@@ -682,47 +680,45 @@ describe("OpenAI Responses route", () => {
)
it.effect("continues a promoted steer after assistant output with response-only text metadata", () =>
Effect.forEach([undefined, []], (output) =>
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
const create = yield* first.create(undefined)
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
const create = yield* first.create(undefined)
yield* first.observe(
create,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
},
}),
)
const saved = checkpoint(
yield* first.observe(
create,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
},
}),
)
const saved = checkpoint(
yield* first.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1", output } }),
),
)
const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
const next = continuationDriver({
type: "response.create",
model: "gpt-5.2",
store: false,
input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
})
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
const next = continuationDriver({
type: "response.create",
model: "gpt-5.2",
store: false,
input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
})
const continued = yield* next.create(saved)
const continued = yield* next.create(saved)
expect(continued.mode).toBe("incremental")
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
previous_response_id: "resp_1",
input: [steer],
})
}),
),
expect(continued.mode).toBe("incremental")
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
previous_response_id: "resp_1",
input: [steer],
})
}),
)
it.effect("continues streamed reasoning when completion re-encrypts the same item", () =>
@@ -1,68 +0,0 @@
import { expect, test } from "@playwright/test"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
import { mockOpenCodeServer } from "../utils/mock-server"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
test("keeps five loaded workspace tabs visible and reactive through repeated switches", async ({ page }, info) => {
const sessions = Array.from({ length: 5 }, (_, index) => ({
...fixture.sessions[0]!,
id: `ses_workspace_cycle_${index}`,
directory: `${fixture.directory}/worktree-${index}`,
title: `Workspace session ${index}`,
}))
const events: OpenCodeEvent[] = []
await mockOpenCodeServer(page, {
...fixture,
sessions,
pageMessages: (id) => ({
items: [
{ id: `msg_user_${id}`, type: "user", text: `Prompt for ${id}`, time: { created: 1 } },
{
id: `msg_assistant_${id}`,
type: "assistant",
agent: "build",
model: { id: "claude-opus-4-6", providerID: "opencode" },
time: { created: 2, completed: 3 },
content: [{ type: "text", text: `Answer for ${id}` }],
},
] satisfies SessionMessageInfo[],
}),
events: () => events.splice(0),
})
await page.route("**/api/location?*", (route) =>
route.fulfill({
json: {
directory: new URL(route.request().url()).searchParams.get("location[directory]"),
project: { id: fixture.project.id, directory: fixture.directory, canonical: fixture.directory },
},
}),
)
await installStressSessionTabs(page, { sessionIDs: sessions.map((session) => session.id) })
await page.goto(stressSessionHref(sessions[0]!.id))
await expect(page.getByText(`Answer for ${sessions[0]!.id}`, { exact: true })).toBeVisible()
for (const session of [...sessions.slice(1), ...sessions, ...sessions.toReversed()]) {
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(session.id)}"]`).click()
await expect(page.locator(`[data-timeline-part-id="msg_assistant_${session.id}:text:0"]`)).toBeVisible()
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
}
const active = sessions[0]!
events.push({
id: "evt_workspace_cycle_update",
created: 4,
type: "session.text.ended",
location: { directory: active.directory },
durable: { aggregateID: active.id, seq: 0, version: 1 },
data: {
sessionID: active.id,
assistantMessageID: `msg_assistant_${active.id}`,
ordinal: 0,
text: "Still receiving updates",
},
})
await expect(page.getByText("Still receiving updates", { exact: true })).toBeVisible()
await page.screenshot({ path: info.outputPath("workspace-tabs.png") })
})
+2 -13
View File
@@ -18,7 +18,6 @@ export function createTimelineCache(
visible: Accessor<boolean>,
) {
const owner = getOwner()
let workspace = untrack(session.identity.workspaceKey)
const cache = createScopedCache(
(key) =>
createRoot((dispose) => {
@@ -52,18 +51,8 @@ export function createTimelineCache(
{ maxEntries: 16, dispose: (entry) => entry.dispose() },
)
onCleanup(cache.clear)
const syncWorkspace = (key: string) => {
if (workspace === key) return
workspace = key
cache.clear()
}
// Providers follow the selected Location even while its history is loading.
// Dispose detached views before their effects can read the new Location.
createComputed(on(session.identity.workspaceKey, syncWorkspace, { defer: true }))
return () => {
// A tab's render can run before the workspace watcher in the same batch.
// Clear the old workspace here, and let that later watcher keep this view.
syncWorkspace(session.identity.workspaceKey())
return cache.get(session.identity.sessionKey()).value
}
createComputed(on(session.identity.workspaceKey, cache.clear, { defer: true }))
return () => cache.get(session.identity.sessionKey()).value
}
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode/client/promise"
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { createMemo, createRoot, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { ServerScope, SessionRouteKey, SessionStateKey } from "../src/runtime/server/scope"
import { createTimelineCache } from "../src/session/timeline/cache"
@@ -144,35 +144,6 @@ test("disposes views on workspace changes while the destination is not rendered"
expect(input.disposed).toEqual(["ses_a", "ses_a"])
})
for (const order of ["session-first", "workspace-first"] as const) {
test(`keeps views live across five workspaces when updates are ${order}`, () => {
const input = setup()
const render = createRoot((dispose) => ({ selected: createMemo(input.cache), dispose }))
const visited = ["ses_a"]
try {
;["ses_b", "ses_c", "ses_d", "ses_e", "ses_a", "ses_c", "ses_b", "ses_e", "ses_d", "ses_a"].forEach(
(id, index) => {
batch(() => {
if (order === "workspace-first") input.setState("directory", `/repo/${id}`)
input.setState("id", id)
if (order === "session-first") input.setState("directory", `/repo/${id}`)
})
expect(input.disposed).toEqual(visited)
input.setState("messages", id, [
{ id: `msg_live_${index}`, type: "user", text: "Live update", time: { created: index + 3 } },
])
expect((render.selected() as HTMLDivElement).dataset.messages).toBe(`msg_live_${index}`)
expect(input.views.get(id)!.active()).toBe(true)
visited.push(id)
},
)
} finally {
render.dispose()
input.dispose()
}
})
}
test("evicts the least recently selected view and disposes all retained owners", () => {
const input = setup()
try {
-12
View File
@@ -1166,18 +1166,6 @@ export type MessageListInput = {
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly cursor?: string | undefined
readonly type?:
| "agent-switched"
| "model-switched"
| "location-switched"
| "user"
| "synthetic"
| "system"
| "skill"
| "shell"
| "assistant"
| "compaction"
| undefined
}
export type MessageListOutput = {
readonly data: ReadonlyArray<SessionMessage.Info>
@@ -757,7 +757,7 @@ const EndpointMessageList = (raw: RawClient["server.message"]) => (input: Messag
preserveEffect<MessageListOutput>()(
raw["session.messages"]({
params: { sessionID: input["sessionID"] },
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"], type: input["type"] },
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] },
}).pipe(Effect.mapError(mapClientError)),
)
@@ -1015,7 +1015,7 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/message`,
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"], type: input["type"] },
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 500],
empty: false,
@@ -4348,70 +4348,17 @@ export type MessageListInput = {
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly cursor?: string | undefined
readonly type?:
| "agent-switched"
| "model-switched"
| "location-switched"
| "user"
| "synthetic"
| "system"
| "skill"
| "shell"
| "assistant"
| "compaction"
| undefined
}["limit"]
readonly order?: {
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly cursor?: string | undefined
readonly type?:
| "agent-switched"
| "model-switched"
| "location-switched"
| "user"
| "synthetic"
| "system"
| "skill"
| "shell"
| "assistant"
| "compaction"
| undefined
}["order"]
readonly cursor?: {
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly cursor?: string | undefined
readonly type?:
| "agent-switched"
| "model-switched"
| "location-switched"
| "user"
| "synthetic"
| "system"
| "skill"
| "shell"
| "assistant"
| "compaction"
| undefined
}["cursor"]
readonly type?: {
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly cursor?: string | undefined
readonly type?:
| "agent-switched"
| "model-switched"
| "location-switched"
| "user"
| "synthetic"
| "system"
| "skill"
| "shell"
| "assistant"
| "compaction"
| undefined
}["type"]
}
export type MessageListOutput = SessionMessagesResponse
+3 -4
View File
@@ -213,10 +213,9 @@ ultimate source of truth.
synchronous iterator support for `fromEntries`.
- [x] `Object.keys` over arrays and tool references.
- [x] Object identity is preserved by in-CodeMode Object helpers.
- [x] `__proto__`, `constructor`, and `prototype` are ordinary own data keys. `x.constructor` without an own key resolves
to the owning built-in (`[].constructor === Array`, `new TypeError().constructor === TypeError`); prototype objects
are not observable, so `[].__proto__` and `Object.prototype` read as `undefined` and `o.__proto__ = x` sets an own
field.
- [x] `__proto__`, `constructor`, and `prototype` are ordinary own data keys. Prototype machinery is not observable:
data objects have no prototype, so `({}).constructor` and `[].__proto__` read as `undefined` and `o.__proto__ = x`
sets an own field.
- [x] Circular references are rejected when created (`o.self = o`, `array.push(array)`), not at serialization as in JS.
- [x] `Object.is` for supported data values.
- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion
+4 -32
View File
@@ -85,7 +85,7 @@ import { numberMethods } from "../stdlib/number.js"
import { constructRegExp, regexpMethods, regexpProperties } from "../stdlib/regexp.js"
import { stringMethods } from "../stdlib/string.js"
import { uriArgument, urlMethods, urlProperties, urlSearchParamsMethods, urlWritableProperties } from "../stdlib/url.js"
import { coerceToNumber, coerceToString, compoundOperators, errorBrandName } from "../stdlib/value.js"
import { coerceToNumber, coerceToString, compoundOperators } from "../stdlib/value.js"
import { Values } from "../values.js"
const MAX_ARRAY_LENGTH = 4_294_967_295
@@ -113,25 +113,6 @@ const calleeDescription = (callee: Expression | Super | undefined): string => {
return "The called value"
}
const hasOwn = (value: unknown, key: PropertyKey): boolean =>
value !== null && typeof value === "object" && Object.hasOwn(value, key)
const constructorName = (value: unknown): string | undefined => {
if (typeof value === "string") return "String"
if (typeof value === "number") return "Number"
if (typeof value === "boolean") return "Boolean"
if (Array.isArray(value)) return "Array"
if (value instanceof Values.Date) return "Date"
if (value instanceof Values.RegExp) return "RegExp"
if (value instanceof Values.Map) return "Map"
if (value instanceof Values.Set) return "Set"
if (value instanceof Values.URL) return "URL"
if (value instanceof Values.URLSearchParams) return "URLSearchParams"
if (value instanceof Values.Promise) return "Promise"
if (value === null || typeof value !== "object" || isRuntimeReference(value)) return undefined
return errorBrandName(value) ?? "Object"
}
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
if (rhs instanceof HostFunction && rhs.instanceOf !== undefined) return rhs.instanceOf(lhs)
throw new InterpreterRuntimeError(
@@ -225,8 +206,6 @@ const promiseResolutionNode: AstNode = { type: "PromiseResolution", start: 0, en
/** One program execution: the tool bridge, promise scheduler, captured logs, and the global scope built once. */
export class Runtime<R> {
readonly runner: Runner<R>
/** Built-in globals by name, unaffected by program shadowing. */
readonly builtins: ReadonlyMap<string, unknown>
private readonly root: Frame<R>
constructor(
@@ -245,8 +224,7 @@ export class Runtime<R> {
settlePromise: (promise) => this.root.settlePromise(promise),
syncIterator: (value, node) => this.root.syncIterator(value, node),
}
this.builtins = new Map(globals(this))
for (const [name, value] of this.builtins) globalScope.set(name, { mutable: false, value })
for (const [name, value] of globals(this)) globalScope.set(name, { mutable: false, value })
}
run(program: Program): Effect.Effect<unknown, unknown, R> {
@@ -2024,7 +2002,7 @@ class Frame<R> {
private getMemberReference(
node: MemberExpression,
operation: "read" | "write" | "delete" = "read",
operation: "read" | "delete" = "read",
): Effect.Effect<
| MemberReference
| ToolReference
@@ -2065,12 +2043,6 @@ class Frame<R> {
return new ComputedValue(objectValue.member(key, propertyNode))
}
// Values have no prototype chain, so `.constructor` resolves to the owning built-in directly.
if (operation === "read" && key === "constructor" && !hasOwn(objectValue, key)) {
const name = constructorName(objectValue)
if (name !== undefined) return new ComputedValue(self.runtime.builtins.get(name))
}
if (typeof objectValue === "string") {
if (key === "length") return new ComputedValue(objectValue.length)
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
@@ -2226,7 +2198,7 @@ class Frame<R> {
): Effect.Effect<unknown, unknown, R> {
const self = this
return Effect.gen(function* () {
const reference = yield* self.getMemberReference(node, "write")
const reference = yield* self.getMemberReference(node)
if (
reference === OptionalShortCircuit ||
reference instanceof ComputedValue ||
@@ -1,66 +0,0 @@
/**
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
* - test/built-ins/RegExp/S15.10.7_A3_T1.js
* - test/built-ins/RegExp/S15.10.7_A3_T2.js
* - test/built-ins/Object/S15.2.2.1_A1_T1.js
*
* Copyright 2009 the Sputnik authors. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262.
*
* Only the instance-side assertions are ported. Test262 otherwise reaches `constructor` through
* `X.prototype.constructor`, boxed primitives (`new Object(1)`), `Function`, `isPrototypeOf`, or
* `.call`, none of which CodeMode exposes: values have no prototype chain, so `x.constructor`
* resolves directly to the owning built-in.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
describe("constructor Test262 parity", () => {
test("test/built-ins/RegExp/S15.10.7_A3_T1.js", async () => {
expect(
await value(`
const __re = /[^a]*/
return [typeof __re, __re.constructor === RegExp, __re instanceof RegExp]
`),
).toEqual(["object", true, true])
})
test("test/built-ins/RegExp/S15.10.7_A3_T2.js", async () => {
expect(
await value(`
const __re = new RegExp()
return [typeof __re, __re.constructor === RegExp, __re instanceof RegExp]
`),
).toEqual(["object", true, true])
})
test("test/built-ins/Object/S15.2.2.1_A1_T1.js", async () => {
expect(
await value(`
const obj = new Object()
return [obj !== undefined, obj.constructor === Object]
`),
).toEqual([true, true])
})
test("every built-in reports itself for its own values", async () => {
expect(
await value(`
return [
[].constructor === Array, "".constructor === String, (1).constructor === Number, true.constructor === Boolean,
new Date(0).constructor === Date, new Map().constructor === Map, new Set().constructor === Set,
new URL("https://a.b/").constructor === URL, new URLSearchParams("a=1").constructor === URLSearchParams,
Promise.resolve(1).constructor === Promise, new TypeError("x").constructor === TypeError,
new RangeError("x").constructor === RangeError, new AggregateError([]).constructor === AggregateError,
]
`),
).toEqual(Array(13).fill(true))
})
})
-11
View File
@@ -722,17 +722,6 @@ describe("Set", () => {
})
describe("stdlib integration", () => {
test("constructor follows own keys, shadowing, writes, and new", async () => {
expect(
await value(`return [JSON.parse('{"constructor":"Foo"}').constructor, ({ constructor: 1 }).constructor]`),
).toEqual(["Foo", 1])
expect(await value(`const Array = 5; return [].constructor.isArray([])`)).toBe(true)
expect(await value(`const o = {}; o.constructor = 7; return o.constructor`)).toBe(7)
expect(await value(`return new ([].constructor)(3).length`)).toBe(3)
expect(await value(`return typeof ({}).constructor`)).toBe("function")
expect(await value(`return ({}).constructor.constructor`)).toBeNull()
})
test("new dispatches on the constructor value, not its name", async () => {
expect(await value(`const D = Date; return new D(0) instanceof Date`)).toBe(true)
expect(await value(`const make = (C) => new C([["a", 1]]); return make(Map).get("a")`)).toBe(1)
+5 -5
View File
@@ -185,17 +185,17 @@ describe("blocked member names on tool paths", () => {
const array = []
object.__proto__ = { polluted: true }
return [
object.constructor === Object, array.constructor === Array, "".constructor === String, Math.constructor,
object.__proto__.polluted, ({}).polluted, array.__proto__, Object().__proto__, new Object().constructor === Object,
({}).constructor.constructor, [].constructor.__proto__, typeof [].__proto__,
object.constructor, array.constructor, "".constructor, Math.constructor,
object.__proto__.polluted, ({}).polluted, array.__proto__, Object().__proto__, new Object().constructor,
typeof ({}).constructor, typeof [].__proto__,
]
`,
),
).toEqual([true, true, true, null, true, null, null, null, true, null, null, "undefined"])
).toEqual([null, null, null, null, true, null, null, null, null, "undefined", "undefined"])
expect((await failure(runtime, `return (() => 1).constructor`)).message).toContain(
"Cannot read properties of a function",
)
const escape = await failure(runtime, `return ({}).constructor.constructor.constructor("return 1")()`)
const escape = await failure(runtime, `return ({}).constructor.constructor("return 1")()`)
expect(escape.message).toContain("Cannot access a property on a non-object value")
const poisoned = await failure(runtime, `const o = {}; o.__proto__.constructor("return 1")`)
expect(poisoned.message).toContain("Cannot access a property on a non-object value")
+4 -8
View File
@@ -43,7 +43,6 @@ export type MessagesInput = {
sessionID: Session.ID
limit?: number
order?: "asc" | "desc"
type?: SessionMessage.Type
cursor?: {
id: SessionMessage.ID
direction: "previous" | "next"
@@ -157,16 +156,13 @@ const layer = Layer.effect(
? gt(SessionMessageTable.seq, anchor.seq)
: lt(SessionMessageTable.seq, anchor.seq)
: undefined
const where = boundary
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
: eq(SessionMessageTable.session_id, input.sessionID)
const query = db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, input.sessionID),
boundary,
input.type === undefined ? undefined : eq(SessionMessageTable.type, input.type),
),
)
.where(where)
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
+1 -53
View File
@@ -4490,34 +4490,6 @@
]
},
"required": false
},
{
"name": "type",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"enum": [
"agent-switched",
"model-switched",
"location-switched",
"user",
"synthetic",
"system",
"skill",
"shell",
"assistant",
"compaction"
]
},
{
"type": "null"
}
],
"description": "Filter by message type before pagination. When omitted, all message types are returned. Pass the same type when following cursors."
},
"required": false
}
],
"security": [],
@@ -4580,7 +4552,7 @@
}
}
},
"description": "Retrieve projected messages for a session, optionally filtered by type. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline, passing the same type filter on each page.",
"description": "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
"summary": "Get session messages"
}
},
@@ -14271,9 +14243,6 @@
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"websocket": {
"type": "boolean"
},
"modelID": {
"type": "string"
},
@@ -14382,9 +14351,6 @@
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"websocket": {
"type": "boolean"
},
"canonical": {
"type": "string"
},
@@ -16331,9 +16297,6 @@
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"websocket": {
"type": "boolean"
},
"settings": {
"type": "object"
},
@@ -17322,9 +17285,6 @@
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"websocket": {
"type": "boolean"
},
"settings": {
"type": "object"
},
@@ -18296,12 +18256,6 @@
},
"providerContext": {
"$ref": "#/components/schemas/Session.ProviderContext"
},
"cost": {
"$ref": "#/components/schemas/Money.USD"
},
"tokens": {
"$ref": "#/components/schemas/TokenUsage.Info"
}
},
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
@@ -18341,12 +18295,6 @@
},
"error": {
"$ref": "#/components/schemas/Session.StructuredError"
},
"cost": {
"$ref": "#/components/schemas/Money.USD"
},
"tokens": {
"$ref": "#/components/schemas/TokenUsage.Info"
}
},
"required": ["type", "id", "time", "status", "reason", "error"],
+1 -18
View File
@@ -19,23 +19,6 @@ export const SessionMessagesQuery = Schema.Struct({
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
}),
),
type: Schema.optional(
Schema.Literals([
"agent-switched",
"model-switched",
"location-switched",
"user",
"synthetic",
"system",
"skill",
"shell",
"assistant",
"compaction",
] satisfies ReadonlyArray<SessionMessage.Type>),
).annotate({
description:
"Filter by message type before pagination. When omitted, all message types are returned. Pass the same type when following cursors.",
}),
}).annotate({ identifier: "SessionMessagesQuery" })
export const MessageGroup = HttpApiGroup.make("server.message")
@@ -56,7 +39,7 @@ export const MessageGroup = HttpApiGroup.make("server.message")
identifier: "v2.message.list",
summary: "Get session messages",
description:
"Retrieve projected messages for a session, optionally filtered by type. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline, passing the same type filter on each page.",
"Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
}),
),
)
-1
View File
@@ -44,7 +44,6 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl
sessionID: ctx.params.sessionID,
limit: ctx.query.limit ?? DefaultMessagesLimit,
order,
type: ctx.query.type,
cursor: decoded ? { id: decoded.id, direction: decoded.direction } : undefined,
})
.pipe(
-102
View File
@@ -1,102 +0,0 @@
import { expect } from "bun:test"
import { OpenCode, type SessionMessageInfo } from "@opencode/client"
import { Session } from "@opencode/schema/session"
import { Effect } from "effect"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
const messages: SessionMessageInfo[] = [
{ id: "msg_z", type: "user", text: "First request", time: { created: 300 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test" },
content: [{ type: "text", text: "First answer" }],
finish: "stop",
time: { created: 400, completed: 500 },
},
{ id: "msg_b", type: "user", text: "Second request", time: { created: 100 } },
{ id: "msg_synthetic", type: "synthetic", text: "Background completion", time: { created: 200 } },
{
id: "msg_compaction",
type: "compaction",
status: "completed",
reason: "manual",
summary: "Conversation summary",
recent: "",
time: { created: 700 },
},
{ id: "msg_x", type: "user", text: "Third request", time: { created: 600 } },
{ id: "msg_system", type: "system", text: "Updated instructions", time: { created: 800 } },
{ id: "msg_a", type: "user", text: "Fourth request", time: { created: 500 } },
]
const setup = Effect.gen(function* () {
const handler = yield* ServerFetch.make({
app: { version: "test" },
database: { path: ":memory:" },
config: { project: false },
models: { fetch: false },
fs: { filewatcher: false },
})
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: Object.assign((input: string | URL | Request, init?: RequestInit) => handler(new Request(input, init)), {
preconnect: fetch.preconnect,
}),
})
const session = yield* Effect.promise(async () => {
const template = await api.session.create({ title: "Message filtering" })
return api.session.import({ info: { ...template, id: Session.ID.create() }, messages })
})
return { api, handler, sessionID: session.id }
})
it.live("filters message types before paginating in either direction through the generated client", () =>
Effect.gen(function* () {
const fixture = yield* setup
yield* Effect.promise(async () => {
const input = { sessionID: fixture.sessionID, type: "user", limit: 2 } as const
// Omission retains the full transcript, in durable sequence rather than timestamp or ID order.
expect(
(await fixture.api.message.list({ sessionID: fixture.sessionID })).data.map((message) => message.id),
).toEqual(messages.toReversed().map((message) => message.id))
for (const order of ["asc", "desc"] as const) {
const ids = order === "asc" ? ["msg_z", "msg_b", "msg_x", "msg_a"] : ["msg_a", "msg_x", "msg_b", "msg_z"]
const first = await fixture.api.message.list({ ...input, order })
expect(first.data.map((message) => message.id)).toEqual(ids.slice(0, 2))
if (!first.cursor.next) throw new Error("Expected a next cursor")
const second = await fixture.api.message.list({ ...input, cursor: first.cursor.next })
expect(second.data.map((message) => message.id)).toEqual(ids.slice(2))
if (!second.cursor.previous || !second.cursor.next) throw new Error("Expected previous and next cursors")
const previous = await fixture.api.message.list({ ...input, cursor: second.cursor.previous })
expect(previous.data).toEqual(first.data)
const end = await fixture.api.message.list({ ...input, cursor: second.cursor.next })
expect(end).toEqual({ data: [], cursor: { previous: null, next: null } })
}
expect((await fixture.api.message.list({ sessionID: fixture.sessionID, type: "compaction" })).data).toEqual([
messages[4],
])
expect(
(await fixture.api.message.list({ sessionID: fixture.sessionID, type: "assistant", limit: 1 })).data,
).toEqual([messages[1]])
expect((await fixture.api.message.list({ sessionID: fixture.sessionID, type: "shell" })).data).toEqual([])
})
}),
)
it.live("rejects unknown message type filters at the HTTP boundary", () =>
Effect.gen(function* () {
const fixture = yield* setup
yield* Effect.promise(async () => {
for (const type of ["unknown", "tool", "User", ""]) {
const response = await fixture.handler(
new Request(`http://opencode.local/api/session/${fixture.sessionID}/message?type=${type}`),
)
expect(response.status).toBe(400)
expect(await response.json()).toMatchObject({ _tag: "InvalidRequestError" })
}
})
}),
)
@@ -43,13 +43,14 @@ story("merges follow-up patches into one stack with a distinct file count", asyn
await root.getByRole("button", { name: "Start follow-up patch" }).click()
const usage = group.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-usage"]')
await expect(usage.locator('[data-slot="context-tool-group-prefix"]')).toHaveText("Used")
await expect(usage.locator('[data-slot="context-tool-group-count"]')).toHaveText("3")
await expect(usage.locator('[data-slot="context-tool-group-count"]')).toHaveText("2")
await expect(patches).toHaveCount(1)
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
await expect(usage.locator('[data-slot="context-tool-group-count"]')).toHaveText("4")
await expect(patches).toHaveCount(1)
await expect(patches.getByText("3 files", { exact: true })).toBeVisible()
await expect(patches.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
await expect(patches.getByText("4 files", { exact: true })).toBeVisible()
await expect(patches.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts", "d.ts"])
await expect(first).toHaveAttribute("aria-expanded", "true")
await expect(patches.locator('[data-component="file"]')).toBeVisible()
await group.screenshot({ path: info.outputPath("merged.png") })
@@ -61,7 +62,13 @@ for (const separator of ["shell", "error", "reasoning"]) {
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
const group = root.locator('[data-component="collapsed-tool-group"]')
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(2)
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText([
"a.ts",
"b.ts",
"a.ts",
"c.ts",
"d.ts",
])
if (separator === "error") await expect(group.locator('[data-kind="tool-error-card"]')).toBeVisible()
})
}
@@ -72,8 +79,14 @@ story("does not retain patch files in the wrong batch when thoughts are shown",
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
const group = root.locator('[data-component="collapsed-tool-group"]')
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts", "d.ts"])
await root.getByRole("button", { name: "Show thoughts", exact: true }).click()
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(2)
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText([
"a.ts",
"b.ts",
"a.ts",
"c.ts",
"d.ts",
])
})
@@ -29,16 +29,16 @@ for (const open of [true, false]) {
await expect(second).toHaveAttribute("aria-expanded", "true")
await expect(diff).toBeVisible()
const original = await patch.elementHandle()
for (const count of [3, 4]) {
for (const call of [1, 2]) {
await root.getByRole("button", { name: "Append tool call", exact: true }).click()
await expect(
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
).toHaveText("Shell, Patch")
await expect(trigger).toHaveAccessibleName(`Used ${count} Shell, Patch`)
await expect(trigger).toHaveAccessibleName("Used 2 Shell, Patch")
await expect(diff).toBeVisible()
await root
.locator('[data-component="session-timeline"]')
.screenshot({ path: info.outputPath(`append-${count}.png`) })
.screenshot({ path: info.outputPath(`append-${call}.png`) })
await expect(shell).toHaveAttribute("aria-expanded", String(open))
await expect(first).toHaveAttribute("aria-expanded", String(open))
await expect(second).toHaveAttribute("aria-expanded", "true")
@@ -132,7 +132,10 @@ export const PatchFollowUps = {
state.phase === "running" ? "running" : "completed",
{},
{
metadata: state.phase === "running" ? {} : { files: [file("src/a.ts", 1, 2), file("src/c.ts", 0, 1)] },
metadata:
state.phase === "running"
? {}
: { files: [file("src/a.ts", 1, 2), file("src/c.ts", 0, 1), file("src/d.ts", 0, 1)] },
},
),
]),
@@ -533,6 +533,15 @@ export function CurrentContextToolGroup(props: {
),
].join(", "),
)
const patchedFiles = createMemo(() =>
patchFileGroups(
tools().flatMap((tool) => {
if (tool.name !== "patch" || tool.state.status === "error") return []
const files = currentToolMetadata(tool).files
return Array.isArray(files) ? files : []
}),
).length,
)
const label = createMemo(() => {
const thoughts = props.parts.filter((part) => part.type === "reasoning").length
if (!names() && !thoughts) {
@@ -540,7 +549,8 @@ export function CurrentContextToolGroup(props: {
return { text: title, title, before: "", count: "", between: "", after: "" }
}
const title = names() || i18n.plural("ui.messagePart.context.thought", thoughts)
const count = props.parts.filter((part) => part.type === "tool" || part.type === "shell").length || thoughts
const count =
patchedFiles() || props.parts.filter((part) => part.type === "tool" || part.type === "shell").length || thoughts
const text = i18n.plural("ui.messagePart.tools.used", count, { tools: title })
const index = text.indexOf(title)
const before = text.slice(0, index).trim()
+1 -53
View File
@@ -4490,34 +4490,6 @@
]
},
"required": false
},
{
"name": "type",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"enum": [
"agent-switched",
"model-switched",
"location-switched",
"user",
"synthetic",
"system",
"skill",
"shell",
"assistant",
"compaction"
]
},
{
"type": "null"
}
],
"description": "Filter by message type before pagination. When omitted, all message types are returned. Pass the same type when following cursors."
},
"required": false
}
],
"security": [],
@@ -4580,7 +4552,7 @@
}
}
},
"description": "Retrieve projected messages for a session, optionally filtered by type. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline, passing the same type filter on each page.",
"description": "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
"summary": "Get session messages"
}
},
@@ -14271,9 +14243,6 @@
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"websocket": {
"type": "boolean"
},
"modelID": {
"type": "string"
},
@@ -14382,9 +14351,6 @@
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"websocket": {
"type": "boolean"
},
"canonical": {
"type": "string"
},
@@ -16331,9 +16297,6 @@
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"websocket": {
"type": "boolean"
},
"settings": {
"type": "object"
},
@@ -17322,9 +17285,6 @@
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"websocket": {
"type": "boolean"
},
"settings": {
"type": "object"
},
@@ -18296,12 +18256,6 @@
},
"providerContext": {
"$ref": "#/components/schemas/Session.ProviderContext"
},
"cost": {
"$ref": "#/components/schemas/Money.USD"
},
"tokens": {
"$ref": "#/components/schemas/TokenUsage.Info"
}
},
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
@@ -18341,12 +18295,6 @@
},
"error": {
"$ref": "#/components/schemas/Session.StructuredError"
},
"cost": {
"$ref": "#/components/schemas/Money.USD"
},
"tokens": {
"$ref": "#/components/schemas/TokenUsage.Info"
}
},
"required": ["type", "id", "time", "status", "reason", "error"],
+1 -53
View File
@@ -4490,34 +4490,6 @@
]
},
"required": false
},
{
"name": "type",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"enum": [
"agent-switched",
"model-switched",
"location-switched",
"user",
"synthetic",
"system",
"skill",
"shell",
"assistant",
"compaction"
]
},
{
"type": "null"
}
],
"description": "Filter by message type before pagination. When omitted, all message types are returned. Pass the same type when following cursors."
},
"required": false
}
],
"security": [],
@@ -4580,7 +4552,7 @@
}
}
},
"description": "Retrieve projected messages for a session, optionally filtered by type. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline, passing the same type filter on each page.",
"description": "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
"summary": "Get session messages"
}
},
@@ -14271,9 +14243,6 @@
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"websocket": {
"type": "boolean"
},
"modelID": {
"type": "string"
},
@@ -14382,9 +14351,6 @@
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"websocket": {
"type": "boolean"
},
"canonical": {
"type": "string"
},
@@ -16331,9 +16297,6 @@
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"websocket": {
"type": "boolean"
},
"settings": {
"type": "object"
},
@@ -17322,9 +17285,6 @@
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"websocket": {
"type": "boolean"
},
"settings": {
"type": "object"
},
@@ -18296,12 +18256,6 @@
},
"providerContext": {
"$ref": "#/components/schemas/Session.ProviderContext"
},
"cost": {
"$ref": "#/components/schemas/Money.USD"
},
"tokens": {
"$ref": "#/components/schemas/TokenUsage.Info"
}
},
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
@@ -18341,12 +18295,6 @@
},
"error": {
"$ref": "#/components/schemas/Session.StructuredError"
},
"cost": {
"$ref": "#/components/schemas/Money.USD"
},
"tokens": {
"$ref": "#/components/schemas/TokenUsage.Info"
}
},
"required": ["type", "id", "time", "status", "reason", "error"],