mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-10 02:46:21 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20aff6d9f6 | ||
|
|
bdb66747e7 | ||
|
|
f91c6d8b25 | ||
|
|
30f8b2f4b6 |
@@ -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
|
||||
output: event.response?.output?.length
|
||||
? event.response.output.map((item) =>
|
||||
item.type === "reasoning" && item.id !== undefined
|
||||
? (output.find((done) => done.type === item.type && done.id === item.id) ?? item)
|
||||
|
||||
@@ -581,52 +581,54 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
it.effect("continues a streamed tool call with only the new tool 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)
|
||||
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(
|
||||
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)
|
||||
yield* first.observe(
|
||||
firstCreate,
|
||||
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}' },
|
||||
],
|
||||
})
|
||||
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}' },
|
||||
],
|
||||
})
|
||||
|
||||
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", () =>
|
||||
@@ -680,45 +682,47 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
it.effect("continues a promoted steer after assistant output with response-only text metadata", () =>
|
||||
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(
|
||||
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)
|
||||
yield* first.observe(
|
||||
create,
|
||||
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],
|
||||
})
|
||||
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],
|
||||
})
|
||||
|
||||
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", () =>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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") })
|
||||
})
|
||||
@@ -18,6 +18,7 @@ export function createTimelineCache(
|
||||
visible: Accessor<boolean>,
|
||||
) {
|
||||
const owner = getOwner()
|
||||
let workspace = untrack(session.identity.workspaceKey)
|
||||
const cache = createScopedCache(
|
||||
(key) =>
|
||||
createRoot((dispose) => {
|
||||
@@ -51,8 +52,18 @@ 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, cache.clear, { defer: true }))
|
||||
return () => cache.get(session.identity.sessionKey()).value
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { batch, 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,6 +144,35 @@ 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 {
|
||||
|
||||
@@ -1166,6 +1166,18 @@ 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"] },
|
||||
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"], type: input["type"] },
|
||||
}).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"] },
|
||||
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"], type: input["type"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404, 500],
|
||||
empty: false,
|
||||
|
||||
@@ -4348,17 +4348,70 @@ 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
|
||||
|
||||
@@ -213,9 +213,10 @@ 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. 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] `__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] 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
|
||||
|
||||
@@ -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 } from "../stdlib/value.js"
|
||||
import { coerceToNumber, coerceToString, compoundOperators, errorBrandName } from "../stdlib/value.js"
|
||||
import { Values } from "../values.js"
|
||||
|
||||
const MAX_ARRAY_LENGTH = 4_294_967_295
|
||||
@@ -113,6 +113,25 @@ 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(
|
||||
@@ -206,6 +225,8 @@ 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(
|
||||
@@ -224,7 +245,8 @@ export class Runtime<R> {
|
||||
settlePromise: (promise) => this.root.settlePromise(promise),
|
||||
syncIterator: (value, node) => this.root.syncIterator(value, node),
|
||||
}
|
||||
for (const [name, value] of globals(this)) globalScope.set(name, { mutable: false, value })
|
||||
this.builtins = new Map(globals(this))
|
||||
for (const [name, value] of this.builtins) globalScope.set(name, { mutable: false, value })
|
||||
}
|
||||
|
||||
run(program: Program): Effect.Effect<unknown, unknown, R> {
|
||||
@@ -2002,7 +2024,7 @@ class Frame<R> {
|
||||
|
||||
private getMemberReference(
|
||||
node: MemberExpression,
|
||||
operation: "read" | "delete" = "read",
|
||||
operation: "read" | "write" | "delete" = "read",
|
||||
): Effect.Effect<
|
||||
| MemberReference
|
||||
| ToolReference
|
||||
@@ -2043,6 +2065,12 @@ 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)
|
||||
@@ -2198,7 +2226,7 @@ class Frame<R> {
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const reference = yield* self.getMemberReference(node)
|
||||
const reference = yield* self.getMemberReference(node, "write")
|
||||
if (
|
||||
reference === OptionalShortCircuit ||
|
||||
reference instanceof ComputedValue ||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 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))
|
||||
})
|
||||
})
|
||||
@@ -722,6 +722,17 @@ 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)
|
||||
|
||||
@@ -185,17 +185,17 @@ describe("blocked member names on tool paths", () => {
|
||||
const array = []
|
||||
object.__proto__ = { polluted: true }
|
||||
return [
|
||||
object.constructor, array.constructor, "".constructor, Math.constructor,
|
||||
object.__proto__.polluted, ({}).polluted, array.__proto__, Object().__proto__, new Object().constructor,
|
||||
typeof ({}).constructor, typeof [].__proto__,
|
||||
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__,
|
||||
]
|
||||
`,
|
||||
),
|
||||
).toEqual([null, null, null, null, true, null, null, null, null, "undefined", "undefined"])
|
||||
).toEqual([true, true, true, null, true, null, null, null, true, null, null, "undefined"])
|
||||
expect((await failure(runtime, `return (() => 1).constructor`)).message).toContain(
|
||||
"Cannot read properties of a function",
|
||||
)
|
||||
const escape = await failure(runtime, `return ({}).constructor.constructor("return 1")()`)
|
||||
const escape = await failure(runtime, `return ({}).constructor.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")
|
||||
|
||||
@@ -43,6 +43,7 @@ export type MessagesInput = {
|
||||
sessionID: Session.ID
|
||||
limit?: number
|
||||
order?: "asc" | "desc"
|
||||
type?: SessionMessage.Type
|
||||
cursor?: {
|
||||
id: SessionMessage.ID
|
||||
direction: "previous" | "next"
|
||||
@@ -156,13 +157,16 @@ 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(where)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, input.sessionID),
|
||||
boundary,
|
||||
input.type === undefined ? undefined : eq(SessionMessageTable.type, input.type),
|
||||
),
|
||||
)
|
||||
.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,
|
||||
|
||||
@@ -4490,6 +4490,34 @@
|
||||
]
|
||||
},
|
||||
"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": [],
|
||||
@@ -4552,7 +4580,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"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.",
|
||||
"summary": "Get session messages"
|
||||
}
|
||||
},
|
||||
@@ -14243,6 +14271,9 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -14351,6 +14382,9 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16297,6 +16331,9 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -17285,6 +17322,9 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -18256,6 +18296,12 @@
|
||||
},
|
||||
"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"],
|
||||
@@ -18295,6 +18341,12 @@
|
||||
},
|
||||
"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"],
|
||||
|
||||
@@ -19,6 +19,23 @@ 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")
|
||||
@@ -39,7 +56,7 @@ export const MessageGroup = HttpApiGroup.make("server.message")
|
||||
identifier: "v2.message.list",
|
||||
summary: "Get session messages",
|
||||
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.",
|
||||
"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.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -44,6 +44,7 @@ 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(
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
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,14 +43,13 @@ 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("2")
|
||||
await expect(usage.locator('[data-slot="context-tool-group-count"]')).toHaveText("3")
|
||||
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("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(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(first).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(patches.locator('[data-component="file"]')).toBeVisible()
|
||||
await group.screenshot({ path: info.outputPath("merged.png") })
|
||||
@@ -62,13 +61,7 @@ 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",
|
||||
"d.ts",
|
||||
])
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
|
||||
if (separator === "error") await expect(group.locator('[data-kind="tool-error-card"]')).toBeVisible()
|
||||
})
|
||||
}
|
||||
@@ -79,14 +72,8 @@ 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", "d.ts"])
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.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",
|
||||
"d.ts",
|
||||
])
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.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 call of [1, 2]) {
|
||||
for (const count of [3, 4]) {
|
||||
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 2 Shell, Patch")
|
||||
await expect(trigger).toHaveAccessibleName(`Used ${count} Shell, Patch`)
|
||||
await expect(diff).toBeVisible()
|
||||
await root
|
||||
.locator('[data-component="session-timeline"]')
|
||||
.screenshot({ path: info.outputPath(`append-${call}.png`) })
|
||||
.screenshot({ path: info.outputPath(`append-${count}.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,10 +132,7 @@ 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), file("src/d.ts", 0, 1)] },
|
||||
metadata: state.phase === "running" ? {} : { files: [file("src/a.ts", 1, 2), file("src/c.ts", 0, 1)] },
|
||||
},
|
||||
),
|
||||
]),
|
||||
|
||||
@@ -533,15 +533,6 @@ 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) {
|
||||
@@ -549,8 +540,7 @@ export function CurrentContextToolGroup(props: {
|
||||
return { text: title, title, before: "", count: "", between: "", after: "" }
|
||||
}
|
||||
const title = names() || i18n.plural("ui.messagePart.context.thought", thoughts)
|
||||
const count =
|
||||
patchedFiles() || props.parts.filter((part) => part.type === "tool" || part.type === "shell").length || thoughts
|
||||
const count = 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()
|
||||
|
||||
@@ -4490,6 +4490,34 @@
|
||||
]
|
||||
},
|
||||
"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": [],
|
||||
@@ -4552,7 +4580,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"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.",
|
||||
"summary": "Get session messages"
|
||||
}
|
||||
},
|
||||
@@ -14243,6 +14271,9 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -14351,6 +14382,9 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16297,6 +16331,9 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -17285,6 +17322,9 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -18256,6 +18296,12 @@
|
||||
},
|
||||
"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"],
|
||||
@@ -18295,6 +18341,12 @@
|
||||
},
|
||||
"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"],
|
||||
|
||||
@@ -4490,6 +4490,34 @@
|
||||
]
|
||||
},
|
||||
"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": [],
|
||||
@@ -4552,7 +4580,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"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.",
|
||||
"summary": "Get session messages"
|
||||
}
|
||||
},
|
||||
@@ -14243,6 +14271,9 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -14351,6 +14382,9 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16297,6 +16331,9 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -17285,6 +17322,9 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -18256,6 +18296,12 @@
|
||||
},
|
||||
"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"],
|
||||
@@ -18295,6 +18341,12 @@
|
||||
},
|
||||
"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"],
|
||||
|
||||
Reference in New Issue
Block a user