mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-21 05:06:37 +00:00
Merge branch 'dev' into feat/session-native-missions-v2
This commit is contained in:
commit
8d89f7f3a7
57 changed files with 4009 additions and 356 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import fs from "fs"
|
||||
import path from "path"
|
||||
import type { Logger } from "../logger"
|
||||
import { hashPassword, type PasswordHashRecord, verifyPassword } from "./password-hash"
|
||||
import { hashPassword, hashPasswordSync, type PasswordHashRecord, verifyPassword } from "./password-hash"
|
||||
|
||||
export interface AuthFile {
|
||||
version: 1
|
||||
|
|
@ -20,6 +20,7 @@ export class AuthStore {
|
|||
private cachedFile: AuthFile | null = null
|
||||
private overrideAuth: AuthFile | null = null
|
||||
private bootstrapUsername: string | null = null
|
||||
private passwordChanges: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(private readonly authFilePath: string, private readonly logger: Logger) {}
|
||||
|
||||
|
|
@ -65,7 +66,7 @@ export class AuthStore {
|
|||
const runtime: AuthFile = {
|
||||
version: 1,
|
||||
username: params.username,
|
||||
password: hashPassword(password),
|
||||
password: hashPasswordSync(password),
|
||||
userProvided: true,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
|
@ -97,7 +98,7 @@ export class AuthStore {
|
|||
)
|
||||
}
|
||||
|
||||
validateCredentials(username: string, password: string): boolean {
|
||||
async validateCredentials(username: string, password: string): Promise<boolean> {
|
||||
const auth = this.load()
|
||||
if (!auth) {
|
||||
return false
|
||||
|
|
@ -107,10 +108,21 @@ export class AuthStore {
|
|||
return false
|
||||
}
|
||||
|
||||
return verifyPassword(password, auth.password)
|
||||
const valid = await verifyPassword(password, auth.password)
|
||||
// A password change can commit while scrypt is off-thread. Do not grant a
|
||||
// session from superseded credentials or retry the login automatically.
|
||||
return valid && this.load() === auth
|
||||
}
|
||||
|
||||
setPassword(params: { password: string; markUserProvided: boolean }): AuthStatus {
|
||||
setPassword(params: { password: string; markUserProvided: boolean }): Promise<AuthStatus> {
|
||||
// Keep admission order and bootstrap state atomic across asynchronous hashing.
|
||||
// A failed change must not poison subsequent requests.
|
||||
const change = this.passwordChanges.then(() => this.changePassword(params))
|
||||
this.passwordChanges = change.then(() => {}, () => {})
|
||||
return change
|
||||
}
|
||||
|
||||
private async changePassword(params: { password: string; markUserProvided: boolean }): Promise<AuthStatus> {
|
||||
if (this.overrideAuth) {
|
||||
throw new Error(
|
||||
"Server password is provided via CLI/env and cannot be changed while running. Restart without --password / CODENOMAD_SERVER_PASSWORD to use auth.json.",
|
||||
|
|
@ -127,7 +139,7 @@ export class AuthStore {
|
|||
const created: AuthFile = {
|
||||
version: 1,
|
||||
username: this.bootstrapUsername,
|
||||
password: hashPassword(params.password),
|
||||
password: await hashPassword(params.password),
|
||||
userProvided: params.markUserProvided,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
|
@ -139,7 +151,7 @@ export class AuthStore {
|
|||
|
||||
const next: AuthFile = {
|
||||
...current,
|
||||
password: hashPassword(params.password),
|
||||
password: await hashPassword(params.password),
|
||||
userProvided: params.markUserProvided,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export class AuthManager {
|
|||
return this.tokenManager.consume(token)
|
||||
}
|
||||
|
||||
validateLogin(username: string, password: string): boolean {
|
||||
async validateLogin(username: string, password: string): Promise<boolean> {
|
||||
if (!this.authEnabled) {
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
32
packages/server/src/auth/password-hash.test.ts
Normal file
32
packages/server/src/auth/password-hash.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import assert from "node:assert/strict"
|
||||
import crypto from "node:crypto"
|
||||
import { it } from "node:test"
|
||||
import { hashPassword, verifyPassword, type PasswordHashRecord } from "./password-hash"
|
||||
|
||||
it("produces the existing v1 scrypt format and unchanged default parameters", async () => {
|
||||
const password = "test-password"
|
||||
const record = await hashPassword(password)
|
||||
assert.equal(record.algorithm, "scrypt")
|
||||
assert.equal(Buffer.from(record.saltBase64, "base64").length, 16)
|
||||
assert.equal(record.keyLength, 64)
|
||||
assert.deepEqual(record.params, { N: 16384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 })
|
||||
const expected = crypto.scryptSync(password, Buffer.from(record.saltBase64, "base64"), record.keyLength, record.params)
|
||||
assert.equal(record.hashBase64, expected.toString("base64"))
|
||||
assert.equal(await verifyPassword(password, record), true)
|
||||
assert.equal(await verifyPassword("wrong-password", record), false)
|
||||
})
|
||||
|
||||
it("verifies existing records using their saved salt, key length and custom scrypt parameters", async () => {
|
||||
const salt = Buffer.from("custom-salt")
|
||||
const params = { N: 1024, r: 8, p: 2, maxmem: 16 * 1024 * 1024 }
|
||||
const record: PasswordHashRecord = {
|
||||
algorithm: "scrypt", saltBase64: salt.toString("base64"),
|
||||
hashBase64: crypto.scryptSync("existing-password", salt, 32, params).toString("base64"),
|
||||
keyLength: 32, params,
|
||||
}
|
||||
assert.equal(await verifyPassword("existing-password", record), true)
|
||||
assert.equal(await verifyPassword("wrong-password", record), false)
|
||||
assert.equal(await verifyPassword("existing-password", { ...record, hashBase64: Buffer.from("short").toString("base64") }), false)
|
||||
assert.equal(await verifyPassword("existing-password", { ...record, algorithm: "unsupported" as "scrypt" }), false)
|
||||
await assert.rejects(verifyPassword("existing-password", { ...record, params: { ...params, N: 3 } }), /scrypt params/)
|
||||
})
|
||||
|
|
@ -20,11 +20,24 @@ const DEFAULT_SCRYPT_PARAMS = {
|
|||
maxmem: 32 * 1024 * 1024,
|
||||
}
|
||||
|
||||
export function hashPassword(password: string): PasswordHashRecord {
|
||||
// Startup-only: AuthManager initializes its CLI/env override before serving HTTP.
|
||||
export function hashPasswordSync(password: string): PasswordHashRecord {
|
||||
const salt = crypto.randomBytes(16)
|
||||
const params = DEFAULT_SCRYPT_PARAMS
|
||||
const keyLength = 64
|
||||
const derived = crypto.scryptSync(password, salt, keyLength, params)
|
||||
return buildRecord(salt, derived, keyLength, params)
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<PasswordHashRecord> {
|
||||
const salt = crypto.randomBytes(16)
|
||||
const params = DEFAULT_SCRYPT_PARAMS
|
||||
const keyLength = 64
|
||||
const derived = await derivePassword(password, salt, keyLength, params)
|
||||
return buildRecord(salt, derived, keyLength, params)
|
||||
}
|
||||
|
||||
function buildRecord(salt: Buffer, derived: Buffer, keyLength: number, params: PasswordHashRecord["params"]): PasswordHashRecord {
|
||||
return {
|
||||
algorithm: "scrypt",
|
||||
saltBase64: salt.toString("base64"),
|
||||
|
|
@ -34,16 +47,25 @@ export function hashPassword(password: string): PasswordHashRecord {
|
|||
}
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, record: PasswordHashRecord): boolean {
|
||||
export async function verifyPassword(password: string, record: PasswordHashRecord): Promise<boolean> {
|
||||
if (record.algorithm !== "scrypt") {
|
||||
return false
|
||||
}
|
||||
|
||||
const salt = Buffer.from(record.saltBase64, "base64")
|
||||
const expected = Buffer.from(record.hashBase64, "base64")
|
||||
const derived = crypto.scryptSync(password, salt, record.keyLength, record.params)
|
||||
const derived = await derivePassword(password, salt, record.keyLength, record.params)
|
||||
if (expected.length !== derived.length) {
|
||||
return false
|
||||
}
|
||||
return crypto.timingSafeEqual(expected, Buffer.from(derived))
|
||||
}
|
||||
|
||||
function derivePassword(password: string, salt: Buffer, keyLength: number, params: PasswordHashRecord["params"]): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.scrypt(password, salt, keyLength, params, (error, derived) => {
|
||||
if (error) reject(error)
|
||||
else resolve(derived)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export async function forwardRuntimeRequest(args: {
|
|||
args.reply.raw.once("close", disconnect)
|
||||
if (args.reply.raw.destroyed || args.request.raw.aborted) controller.abort()
|
||||
try {
|
||||
controller.signal.throwIfAborted()
|
||||
const headers = new Headers()
|
||||
for (const [key, value] of Object.entries(args.headers)) {
|
||||
if (typeof value === "string") headers.set(key, value)
|
||||
|
|
|
|||
|
|
@ -42,13 +42,25 @@ export async function readSessionOutline(db: DatabaseSync, scope: HistoryScope,
|
|||
FROM session_message WHERE ${where} AND seq>? AND seq<=? ORDER BY seq LIMIT 512`)
|
||||
const entries: OutlineEntry[] = [], checkpoints: Array<OutlineCheckpoint & { changed: boolean }> = []
|
||||
let examined = 0
|
||||
// Row counts do not bound time: one assistant can contain megabytes of tool
|
||||
// output. Cooperate during both checkpoint reads and JSON projection. A
|
||||
// single SQLite step is still synchronous, so this is not a hard deadline.
|
||||
let turnStarted = performance.now()
|
||||
do {
|
||||
signal.throwIfAborted()
|
||||
const previous = known.find(item => item.after === after)
|
||||
// Grow the last range to 512 rows before creating another checkpoint.
|
||||
// Otherwise one append per refresh eventually exhausts the manifest budget.
|
||||
const end = Math.min(previous && previous !== known.at(-1) ? previous.through : through, through)
|
||||
const rows = headers.all(...params, after, end)
|
||||
const rows = []
|
||||
for (const row of headers.iterate(...params, after, end)) {
|
||||
signal.throwIfAborted()
|
||||
rows.push(row)
|
||||
if (rows.length % 128 === 0 || performance.now() - turnStarted >= 8) {
|
||||
await yieldTurn(undefined, { signal })
|
||||
turnStarted = performance.now()
|
||||
}
|
||||
}
|
||||
// If a cached range gained >512 rows, split and rebuild it instead of
|
||||
// declaring the unseen suffix unchanged.
|
||||
const last = rows.at(-1)
|
||||
|
|
@ -59,17 +71,22 @@ export async function readSessionOutline(db: DatabaseSync, scope: HistoryScope,
|
|||
|| chunkEnd >= maximum
|
||||
if (changed) {
|
||||
for (const row of project.iterate(...params, after, chunkEnd)) {
|
||||
signal.throwIfAborted()
|
||||
const tools = Number(row.tools)
|
||||
entries.push({ id: String(row.id), seq: Number(row.seq), type: row.type as OutlineEntry["type"],
|
||||
tools, reasoning: Number(row.reasoning),
|
||||
...(tools ? { toolName: boundedToolName(row.tool_name) } : {}) })
|
||||
if (entries.length % 128 === 0) await yieldTurn(undefined, { signal })
|
||||
if (entries.length % 128 === 0 || performance.now() - turnStarted >= 8) {
|
||||
await yieldTurn(undefined, { signal })
|
||||
turnStarted = performance.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
checkpoints.push({ after, through: chunkEnd, digest, changed })
|
||||
after = chunkEnd
|
||||
examined += rows.length
|
||||
await yieldTurn(undefined, { signal })
|
||||
turnStarted = performance.now()
|
||||
// Old ranges shrink after deletions. Reserve a whole block before reading
|
||||
// another; the page budget is not necessarily a multiple of 512 anymore.
|
||||
} while (after < through && examined <= 16384 - 512 && checkpoints.length < 512)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { DatabaseSync } from "node:sqlite"
|
||||
import { readSessionOutline } from "./outline-index"
|
||||
|
||||
for (const phase of ["headers", "projection"] as const) test(`outline ${phase} admit pending cancellation before consuming an expensive checkpoint`, async (t) => {
|
||||
const db = new DatabaseSync(":memory:")
|
||||
let reads = 0
|
||||
let clock = 0
|
||||
const controller = new AbortController()
|
||||
let pending: ReturnType<typeof setImmediate> | undefined
|
||||
const freeReads = phase === "projection" ? 512 : 0
|
||||
// Charge a deterministic cost to SQLite payload access instead of asserting
|
||||
// wall-clock timing on CI. The real SQL/iterator/transaction still execute.
|
||||
db.function("payload", (data) => {
|
||||
reads++
|
||||
if (reads > freeReads) {
|
||||
clock += 2
|
||||
pending ??= setImmediate(() => controller.abort())
|
||||
}
|
||||
return data
|
||||
})
|
||||
t.mock.method(performance, "now", () => clock)
|
||||
db.exec(`CREATE TABLE session_v2(id TEXT,directory TEXT,project_id TEXT,workspace_id TEXT,revert TEXT);
|
||||
CREATE TABLE stored_messages(id TEXT PRIMARY KEY,session_id TEXT,type TEXT,seq INTEGER,data TEXT,time_updated INTEGER);
|
||||
CREATE UNIQUE INDEX stored_session_seq ON stored_messages(session_id,seq);
|
||||
CREATE VIEW session_message AS SELECT id,session_id,type,seq,payload(data) AS data,time_updated FROM stored_messages;
|
||||
INSERT INTO session_v2 VALUES ('s','/repo','p',NULL,NULL);`)
|
||||
const insert = db.prepare("INSERT INTO stored_messages VALUES (?,'s','assistant',?,?,0)")
|
||||
for (let i = 0; i < 512; i++) insert.run(`m${i}`, i, '{"content":[{"type":"tool","name":"read"}]}')
|
||||
try {
|
||||
await assert.rejects(readSessionOutline(db, { directory: "/repo", projectID: "p", sessionID: "s" },
|
||||
undefined, controller.signal), /abort/i)
|
||||
assert(reads - freeReads < 32, `cancelled scan accessed ${reads - freeReads} expensive payloads before giving the event loop a turn`)
|
||||
assert.equal(db.isTransaction, false, "cancellation releases its read snapshot")
|
||||
} finally { if (pending) clearImmediate(pending); db.close() }
|
||||
})
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
import assert from "node:assert/strict"
|
||||
import http from "node:http"
|
||||
import { test } from "node:test"
|
||||
import Fastify from "fastify"
|
||||
import replyFrom from "@fastify/reply-from"
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode/client"
|
||||
import type { Logger } from "../../logger"
|
||||
import { createRuntimeFetch } from "../../opencode/compatibility/transport"
|
||||
import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation"
|
||||
import { registerInstanceProxyRoutes, type InstanceProxyWorkspaceManager } from "../http-server"
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => { resolve = done })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function send(url: string, method = "POST") {
|
||||
let request!: http.ClientRequest
|
||||
const result = new Promise<number>((resolve, reject) => {
|
||||
request = http.request(url, { method, agent: false, headers: { "content-type": "application/json" } }, response => {
|
||||
response.resume()
|
||||
response.once("end", () => resolve(response.statusCode!))
|
||||
})
|
||||
request.once("error", reject)
|
||||
request.end(method === "POST" ? "{}" : undefined)
|
||||
})
|
||||
void result.catch(() => undefined)
|
||||
return { request, result }
|
||||
}
|
||||
|
||||
type Phase = "connection" | "profile" | "session" | "ownership" | "snapshot" | "environment" | "forward"
|
||||
|
||||
async function harness(phase: Phase, nativePreflight = false) {
|
||||
const blocked = deferred<void>(), started = deferred<void>(), closed = deferred<void>()
|
||||
const cleanupFinished = deferred<void>()
|
||||
const nativeClosed = deferred<void>()
|
||||
let signal: AbortSignal | undefined
|
||||
const calls = { session: 0, snapshot: 0, environment: 0, forward: 0, complete: 0, invalidation: 0 }
|
||||
const hold = async (at: Phase, requestSignal?: AbortSignal) => {
|
||||
if (phase !== at) return
|
||||
signal = requestSignal
|
||||
started.resolve()
|
||||
// Deliberately ignore cancellation in the fake: the observer must also
|
||||
// retire locally and fence continuations from non-cancellable shared work.
|
||||
await blocked.promise
|
||||
}
|
||||
const upstream = Fastify()
|
||||
upstream.all("/*", async (request, reply) => {
|
||||
if (nativePreflight && request.method === "GET" && request.url === "/api/session/held") {
|
||||
reply.raw.once("close", () => nativeClosed.resolve())
|
||||
await hold("session")
|
||||
return { id: "held", location: { directory: "/repo" } }
|
||||
}
|
||||
calls.forward++
|
||||
await hold("forward")
|
||||
calls.complete++
|
||||
return { path: request.url }
|
||||
})
|
||||
await upstream.listen({ host: "127.0.0.1", port: 0 })
|
||||
const upstreamAddress = upstream.server.address()
|
||||
assert.ok(upstreamAddress && typeof upstreamAddress === "object")
|
||||
const endpoint = `http://127.0.0.1:${upstreamAddress.port}`
|
||||
const native = OpenCode.make({ baseUrl: endpoint, fetch: createRuntimeFetch({ url: endpoint }) })
|
||||
const client = {
|
||||
session: {
|
||||
get: async ({ sessionID }: { sessionID: string }, options?: { signal?: AbortSignal }) => {
|
||||
calls.session++
|
||||
if (nativePreflight && sessionID === "held") return native.session.get({ sessionID }, options)
|
||||
if (sessionID !== "witness") await hold("session", options?.signal)
|
||||
return { id: sessionID, location: { directory: "/repo" } }
|
||||
},
|
||||
environment: async (_input: unknown, options?: { signal?: AbortSignal }) => {
|
||||
calls.environment++
|
||||
await hold("environment", options?.signal)
|
||||
},
|
||||
instructions: { entry: { remove: async () => {}, put: async () => {} } },
|
||||
},
|
||||
} as unknown as OpenCodeClient
|
||||
const connection = {
|
||||
endpoint: { url: endpoint }, client,
|
||||
fetch: createRuntimeFetch({ url: endpoint }),
|
||||
assertCurrent() {}, invalidate() { calls.invalidation++ },
|
||||
profile: async (requestSignal?: AbortSignal) => { await hold("profile", requestSignal); return "modern" as const },
|
||||
}
|
||||
const manager = {
|
||||
get: () => ({ id: "workspace", path: "/repo" }),
|
||||
getSharedServiceConnection: async () => { await hold("connection"); return connection },
|
||||
getSharedServiceEndpoint: async () => connection.endpoint,
|
||||
getSharedServiceClient: async () => client,
|
||||
getSharedServiceFetch: async () => connection.fetch,
|
||||
getInstanceAuthorizationHeader: () => undefined,
|
||||
getServiceDirectory: () => "/repo",
|
||||
getServiceDirectoryForPath: async () => "/repo",
|
||||
getServicePathForPath: async () => "/repo",
|
||||
getWorktreeIdentityForPath: async () => "repo",
|
||||
getSessionEnvironment: async (_id: string, requestSignal?: AbortSignal) => {
|
||||
calls.snapshot++
|
||||
await hold("snapshot", requestSignal)
|
||||
return {}
|
||||
},
|
||||
ownsDirectory: async () => true,
|
||||
ownsLocation: async (_id: string, _location: unknown, _client?: unknown, requestSignal?: AbortSignal) => {
|
||||
await hold("ownership", requestSignal)
|
||||
return true
|
||||
},
|
||||
ownsPath: async () => true,
|
||||
} as unknown as InstanceProxyWorkspaceManager
|
||||
const app = Fastify()
|
||||
const captured: Array<{ request: http.IncomingMessage; response: http.ServerResponse; abortedListeners: number; closeListeners: number }> = []
|
||||
app.addHook("onRequest", async (request, reply) => {
|
||||
captured.push({ request: request.raw, response: reply.raw,
|
||||
abortedListeners: request.raw.listenerCount("aborted"), closeListeners: reply.raw.listenerCount("close") })
|
||||
if (!request.url.endsWith("/witness")) reply.raw.once("close", () => {
|
||||
closed.resolve()
|
||||
// The admission race settles and disposes in the following microtasks.
|
||||
setImmediate(() => cleanupFinished.resolve())
|
||||
})
|
||||
})
|
||||
await app.register(replyFrom)
|
||||
const fence = new WorktreeDeletionFence()
|
||||
registerInstanceProxyRoutes(app, {
|
||||
workspaceManager: manager, worktreeDeletionFence: fence,
|
||||
logger: { debug() {}, error() {}, isLevelEnabled() { return false } } as unknown as Logger,
|
||||
})
|
||||
await app.listen({ host: "127.0.0.1", port: 0 })
|
||||
const address = app.server.address()
|
||||
assert.ok(address && typeof address === "object")
|
||||
const base = `http://127.0.0.1:${address.port}/workspaces/workspace/instance/api/session`
|
||||
return {
|
||||
base, calls, blocked, started, closed, cleanupFinished, captured, fence, nativeClosed,
|
||||
signal: () => signal,
|
||||
async dispose() { blocked.resolve(); await app.close(); await upstream.close() },
|
||||
}
|
||||
}
|
||||
|
||||
for (const action of ["prompt", "compact"] as const) {
|
||||
test(`disconnect cancels actual Promise SDK HTTP preflight for ${action}`, { timeout: 10_000 }, async () => {
|
||||
const h = await harness("session", true)
|
||||
try {
|
||||
const pending = send(`${h.base}/held/${action}`)
|
||||
await h.started.promise
|
||||
pending.request.destroy()
|
||||
await h.nativeClosed.promise
|
||||
assert.equal(h.calls.forward, 0)
|
||||
assert.equal(h.calls.environment, 0)
|
||||
assert.equal(h.calls.invalidation, 0)
|
||||
} finally { await h.dispose() }
|
||||
})
|
||||
|
||||
test(`disconnect cancels held ${action} session preflight, without blocking another session`, { timeout: 10_000 }, async t => {
|
||||
const h = await harness("session")
|
||||
try {
|
||||
const pending = send(`${h.base}/held/${action}`)
|
||||
await h.started.promise
|
||||
const witnessStarted = performance.now()
|
||||
const witness = await send(`${h.base}/witness`, "GET").result
|
||||
t.diagnostic(`another session responded while the preflight was held: ${Math.round(performance.now() - witnessStarted)} ms`)
|
||||
assert.equal(witness, 200) // causal independence, not a machine-speed threshold
|
||||
pending.request.destroy()
|
||||
await h.closed.promise
|
||||
await h.cleanupFinished.promise
|
||||
assert.equal(h.signal()?.aborted, true)
|
||||
h.blocked.resolve()
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
assert.equal(h.calls.forward, 1, "only the independent witness reached native forwarding")
|
||||
assert.equal(h.calls.environment, 0)
|
||||
assert.equal(h.calls.invalidation, 0)
|
||||
} finally { await h.dispose() }
|
||||
})
|
||||
}
|
||||
|
||||
for (const phase of ["connection", "profile", "ownership", "snapshot", "environment"] as const) {
|
||||
test(`disconnect retires a held ${phase} boundary and fences late admission`, { timeout: 10_000 }, async () => {
|
||||
const h = await harness(phase)
|
||||
try {
|
||||
const pending = send(`${h.base}/held/prompt`)
|
||||
await h.started.promise
|
||||
pending.request.destroy()
|
||||
await h.closed.promise
|
||||
await h.cleanupFinished.promise
|
||||
if (phase !== "connection") assert.equal(h.signal()?.aborted, true)
|
||||
const callsAtDisconnect = { ...h.calls }
|
||||
h.blocked.resolve()
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
assert.deepEqual(h.calls, callsAtDisconnect, "late shared work must not start any subsequent calls")
|
||||
assert.equal(h.calls.forward, 0)
|
||||
assert.equal(h.calls.invalidation, 0)
|
||||
// A preflight-abandoned mutation cannot keep the deletion fence reserved.
|
||||
await h.fence.run("repo", ["repo"], async () => {})
|
||||
for (const entry of h.captured) {
|
||||
assert.equal(entry.request.listenerCount("aborted"), entry.abortedListeners)
|
||||
assert.equal(entry.response.listenerCount("close"), entry.closeListeners)
|
||||
}
|
||||
} finally { await h.dispose() }
|
||||
})
|
||||
}
|
||||
|
||||
for (const action of ["prompt", "compact"] as const) {
|
||||
test(`disconnect after native ${action} admission does not interrupt, replay or invalidate execution`, { timeout: 10_000 }, async () => {
|
||||
const h = await harness("forward")
|
||||
try {
|
||||
const pending = send(`${h.base}/held/${action}`)
|
||||
await h.started.promise
|
||||
pending.request.destroy()
|
||||
await h.closed.promise
|
||||
await h.cleanupFinished.promise
|
||||
assert.equal(h.calls.forward, 1)
|
||||
h.blocked.resolve()
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
assert.equal(h.calls.complete, 1, "the admitted upstream operation can finish after its HTTP observer leaves")
|
||||
assert.equal(h.calls.forward, 1)
|
||||
assert.equal(h.calls.invalidation, 0)
|
||||
} finally { await h.dispose() }
|
||||
})
|
||||
}
|
||||
|
||||
test("normal POST body close preserves admission; success and denial dispose admission listeners", { timeout: 10_000 }, async () => {
|
||||
const h = await harness("session")
|
||||
try {
|
||||
const pending = send(`${h.base}/held/compact`)
|
||||
await h.started.promise
|
||||
assert.equal(h.captured[0].request.complete, true)
|
||||
assert.equal(h.signal()?.aborted, false)
|
||||
h.blocked.resolve()
|
||||
assert.equal(await pending.result, 200)
|
||||
assert.equal(await send(`${h.base}/held/not-allowed`).result, 403)
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
for (const entry of h.captured) {
|
||||
assert.equal(entry.request.listenerCount("aborted"), entry.abortedListeners)
|
||||
// Node's response pipeline retains its own finished-stream callbacks;
|
||||
// check our admission/forwarding listeners, not those library internals.
|
||||
assert.equal(entry.response.listeners("close").some(listener => ["close", "disconnect"].includes(listener.name)), false)
|
||||
}
|
||||
} finally { await h.dispose() }
|
||||
})
|
||||
|
|
@ -49,6 +49,7 @@ import type { SideCarManager } from "../sidecars/manager"
|
|||
import type { PreviewManager } from "../previews/manager"
|
||||
import { buildPreviewRuntimeBridge, rewritePreviewImportMap, rewritePreviewJavaScriptImports } from "../previews/runtime-bridge"
|
||||
import { forwardRuntimeRequest } from "../opencode/compatibility/proxy"
|
||||
import { requestAdmission } from "./request-admission"
|
||||
import { LOCATION_CONTEXT_HEADER, locationRequestOptions, readLocationContext } from "../opencode/compatibility/location"
|
||||
import { decodeSessionListScope, prepareLocationImport, readRequestLocations, type SessionListScope } from "../opencode/compatibility/proxy-locations"
|
||||
import type { RemoteProxySessionManager } from "./remote-proxy"
|
||||
|
|
@ -433,7 +434,7 @@ export interface InstanceProxyWorkspaceManager {
|
|||
getServicePathForPath?(id: string, candidate: string): Promise<string | undefined>
|
||||
getSharedServiceClient(): Promise<OpenCodeClient>
|
||||
getSessionEnvironment(id: string, signal?: AbortSignal): Promise<Record<string, string>>
|
||||
ownsLocation(id: string, location: LocationRef, client?: OpenCodeClient): ReturnType<WorkspaceManager["ownsLocation"]>
|
||||
ownsLocation(id: string, location: LocationRef, client?: OpenCodeClient, signal?: AbortSignal): ReturnType<WorkspaceManager["ownsLocation"]>
|
||||
ownsDirectory(id: string, directory: string): Promise<boolean>
|
||||
ownsPath(id: string, candidate: string): Promise<boolean>
|
||||
}
|
||||
|
|
@ -649,15 +650,31 @@ export function registerInstanceProxyRoutes(app: FastifyInstance, deps: Instance
|
|||
})
|
||||
}
|
||||
|
||||
async function proxyWorkspaceRequest(args: {
|
||||
interface InstanceProxyRequestArgs {
|
||||
request: FastifyRequest
|
||||
reply: FastifyReply
|
||||
workspaceManager: InstanceProxyWorkspaceManager
|
||||
worktreeDeletionFence: WorktreeDeletionFence
|
||||
logger: Logger
|
||||
pathSuffix?: string
|
||||
}) {
|
||||
}
|
||||
|
||||
async function proxyWorkspaceRequest(args: InstanceProxyRequestArgs) {
|
||||
const admission = requestAdmission(args.request, args.reply)
|
||||
try {
|
||||
admission.signal.throwIfAborted()
|
||||
return await proxyWorkspaceAdmission(args, admission)
|
||||
} catch (error) {
|
||||
if (!admission.signal.aborted) throw error
|
||||
} finally {
|
||||
admission.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
async function proxyWorkspaceAdmission(args: InstanceProxyRequestArgs, admission: ReturnType<typeof requestAdmission>) {
|
||||
const { request, reply, workspaceManager, logger, worktreeDeletionFence } = args
|
||||
const { signal } = admission
|
||||
const wait = admission.wait
|
||||
const workspaceId = (request.params as { id: string }).id
|
||||
const workspace = workspaceManager.get(workspaceId)
|
||||
|
||||
|
|
@ -682,15 +699,18 @@ async function proxyWorkspaceRequest(args: {
|
|||
return
|
||||
}
|
||||
|
||||
const connection = await workspaceManager.getSharedServiceConnection?.(workspaceId)
|
||||
const endpoint = connection?.endpoint ?? await workspaceManager.getSharedServiceEndpoint(workspaceId)
|
||||
const clientForRequest = () => connection ? Promise.resolve(connection.client) : workspaceManager.getSharedServiceClient()
|
||||
const connection = await wait(Promise.resolve(workspaceManager.getSharedServiceConnection?.(workspaceId)))
|
||||
const endpoint = connection?.endpoint ?? await wait(workspaceManager.getSharedServiceEndpoint(workspaceId))
|
||||
const clientForRequest = () => {
|
||||
signal.throwIfAborted()
|
||||
return wait(connection ? Promise.resolve(connection.client) : workspaceManager.getSharedServiceClient())
|
||||
}
|
||||
if (!endpoint) {
|
||||
reply.code(502).send({ error: "OpenCode service is not ready" })
|
||||
return
|
||||
}
|
||||
|
||||
await connection?.profile()
|
||||
await wait(Promise.resolve(connection?.profile(signal)))
|
||||
let locationContext: LocationRef | undefined
|
||||
try { locationContext = readLocationContext(request.headers[LOCATION_CONTEXT_HEADER]) }
|
||||
catch { return reply.code(400).send({ error: "Invalid location context" }) }
|
||||
|
|
@ -707,34 +727,36 @@ async function proxyWorkspaceRequest(args: {
|
|||
return
|
||||
}
|
||||
const client = await clientForRequest()
|
||||
const active = await client.session.active()
|
||||
const active = await wait(client.session.active({ signal }))
|
||||
const entries = await Promise.all(Object.entries(active).map(async ([sessionId, status]) => {
|
||||
try {
|
||||
const session = await client.session.get({ sessionID: sessionId })
|
||||
return await workspaceManager.ownsLocation(workspaceId, session.location, client) ? [sessionId, status] as const : null
|
||||
const session = await wait(client.session.get({ sessionID: sessionId }, { signal }))
|
||||
return await wait(workspaceManager.ownsLocation(workspaceId, session.location, client, signal)) ? [sessionId, status] as const : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}))
|
||||
signal.throwIfAborted()
|
||||
reply.send({ data: Object.fromEntries(entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null)) })
|
||||
return
|
||||
}
|
||||
if (pathname.replace(/\/+$/, "") === "/api/project") {
|
||||
const projects = await (await clientForRequest()).project.list()
|
||||
const projects = await wait((await clientForRequest()).project.list({ signal }))
|
||||
const ownedProjects = await Promise.all(projects.map(async (project) => {
|
||||
if (!await workspaceManager.ownsDirectory(workspaceId, project.canonical)) return null
|
||||
if (!await wait(workspaceManager.ownsDirectory(workspaceId, project.canonical))) return null
|
||||
const sandboxes = (await Promise.all(project.sandboxes.map(async (directory) => (
|
||||
await workspaceManager.ownsDirectory(workspaceId, directory) ? directory : null
|
||||
await wait(workspaceManager.ownsDirectory(workspaceId, directory)) ? directory : null
|
||||
)))).filter((directory): directory is string => directory !== null)
|
||||
return { ...project, sandboxes }
|
||||
}))
|
||||
signal.throwIfAborted()
|
||||
reply.send(ownedProjects.filter((project): project is NonNullable<typeof project> => project !== null))
|
||||
return
|
||||
}
|
||||
const sessionListHasScope = request.method === "GET"
|
||||
&& pathname.replace(/\/+$/, "") === "/api/session"
|
||||
&& (targetUrl.searchParams.has("cursor") || targetUrl.searchParams.has("project"))
|
||||
const sessionListScope = await authorizeSessionList(targetUrl, request.method, workspaceManager, workspaceId, connection?.client)
|
||||
const sessionListScope = await wait(authorizeSessionList(targetUrl, request.method, workspaceManager, workspaceId, connection?.client, signal))
|
||||
if (sessionListScope !== "allowed") {
|
||||
reply.code(sessionListScope === "invalid" ? 400 : 403).send({ error: "Session list does not belong to workspace" })
|
||||
return
|
||||
|
|
@ -796,15 +818,15 @@ async function proxyWorkspaceRequest(args: {
|
|||
return
|
||||
}
|
||||
if (requestLocations.invalid
|
||||
|| !(await allDirectoriesOwned(workspaceManager, workspaceId, requestLocations.directories))
|
||||
|| !(await allLocationsOwned(workspaceManager, workspaceId, requestLocations.locations, connection?.client))) {
|
||||
|| !(await wait(allDirectoriesOwned(workspaceManager, workspaceId, requestLocations.directories)))
|
||||
|| !(await wait(allLocationsOwned(workspaceManager, workspaceId, requestLocations.locations, connection?.client, signal)))) {
|
||||
reply.code(requestLocations.invalid ? 400 : 403).send({ error: "Location does not belong to workspace" })
|
||||
return
|
||||
}
|
||||
const translatedDirectories = new Map<string, string>()
|
||||
for (const directory of new Set(requestLocations.directories)) {
|
||||
const translated = workspaceManager.getServiceDirectoryForPath
|
||||
? await workspaceManager.getServiceDirectoryForPath(workspaceId, directory)
|
||||
? await wait(workspaceManager.getServiceDirectoryForPath(workspaceId, directory))
|
||||
: directory
|
||||
if (!translated) {
|
||||
reply.code(403).send({ error: "Location does not belong to workspace" })
|
||||
|
|
@ -823,12 +845,12 @@ async function proxyWorkspaceRequest(args: {
|
|||
return
|
||||
}
|
||||
if (fileListPath.candidate) {
|
||||
if (!(await workspaceManager.ownsPath(workspaceId, fileListPath.candidate))) {
|
||||
if (!(await wait(workspaceManager.ownsPath(workspaceId, fileListPath.candidate)))) {
|
||||
reply.code(403).send({ error: "Filesystem path does not belong to workspace" })
|
||||
return
|
||||
}
|
||||
const translated = workspaceManager.getServicePathForPath
|
||||
? await workspaceManager.getServicePathForPath(workspaceId, fileListPath.candidate)
|
||||
? await wait(workspaceManager.getServicePathForPath(workspaceId, fileListPath.candidate))
|
||||
: fileListPath.candidate
|
||||
if (!translated) {
|
||||
reply.code(403).send({ error: "Filesystem path does not belong to workspace" })
|
||||
|
|
@ -839,7 +861,7 @@ async function proxyWorkspaceRequest(args: {
|
|||
const mutationIdentities = new Set<string>()
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
for (const directory of requestLocations.directories) {
|
||||
const identity = await workspaceManager.getWorktreeIdentityForPath(workspaceId, directory)
|
||||
const identity = await wait(workspaceManager.getWorktreeIdentityForPath(workspaceId, directory))
|
||||
if (!identity) {
|
||||
reply.code(403).send({ error: "Location does not belong to workspace" })
|
||||
return
|
||||
|
|
@ -848,14 +870,14 @@ async function proxyWorkspaceRequest(args: {
|
|||
}
|
||||
}
|
||||
const serviceBody = replaceRequestDirectories(targetUrl, imported.body, translatedDirectories, pathname, request.method)
|
||||
if (promptFiles.invalid || !(await allPathsOwned(workspaceManager, workspaceId, promptFiles.paths))) {
|
||||
if (promptFiles.invalid || !(await wait(allPathsOwned(workspaceManager, workspaceId, promptFiles.paths)))) {
|
||||
reply.code(promptFiles.invalid ? 400 : 403).send({ error: "Prompt file does not belong to workspace" })
|
||||
return
|
||||
}
|
||||
const translatedPromptPaths = new Map<string, string>()
|
||||
for (const candidate of new Set(promptFiles.paths)) {
|
||||
const translated = workspaceManager.getServicePathForPath
|
||||
? await workspaceManager.getServicePathForPath(workspaceId, candidate)
|
||||
? await wait(workspaceManager.getServicePathForPath(workspaceId, candidate))
|
||||
: candidate
|
||||
if (!translated) {
|
||||
reply.code(403).send({ error: "Prompt file does not belong to workspace" })
|
||||
|
|
@ -870,10 +892,10 @@ async function proxyWorkspaceRequest(args: {
|
|||
directory: requestedDirectory ? translatedDirectories.get(requestedDirectory) ?? serviceDirectory : serviceDirectory,
|
||||
workspaceID: locationContext?.workspaceID ?? explicitLocations.find(location => location.directory === requestedDirectory)?.workspaceID,
|
||||
}
|
||||
const runtimeLocationOptions = locationRequestOptions(runtimeLocation)
|
||||
const runtimeLocationOptions = { ...locationRequestOptions(runtimeLocation), signal }
|
||||
if (pathname.replace(/\/+$/, "") === "/api/pty" && request.method === "GET") {
|
||||
const result = await (await clientForRequest()).pty.list({ location: { directory: runtimeLocation.directory } }, runtimeLocationOptions)
|
||||
const ownership = await Promise.all(result.data.map((pty) => workspaceManager.ownsDirectory(workspaceId, pty.cwd)))
|
||||
const result = await wait((await clientForRequest()).pty.list({ location: { directory: runtimeLocation.directory } }, runtimeLocationOptions))
|
||||
const ownership = await wait(Promise.all(result.data.map((pty) => workspaceManager.ownsDirectory(workspaceId, pty.cwd))))
|
||||
reply.send({ ...result, data: result.data.filter((_, index) => ownership[index]) })
|
||||
return
|
||||
}
|
||||
|
|
@ -881,8 +903,8 @@ async function proxyWorkspaceRequest(args: {
|
|||
const ptyId = getPtyRouteId(pathname)
|
||||
if (ptyId) {
|
||||
try {
|
||||
const pty = await (await clientForRequest()).pty.get({ ptyID: ptyId, location: { directory: runtimeLocation.directory } }, runtimeLocationOptions)
|
||||
if (!(await workspaceManager.ownsDirectory(workspaceId, pty.data.cwd))) {
|
||||
const pty = await wait((await clientForRequest()).pty.get({ ptyID: ptyId, location: { directory: runtimeLocation.directory } }, runtimeLocationOptions))
|
||||
if (!(await wait(workspaceManager.ownsDirectory(workspaceId, pty.data.cwd)))) {
|
||||
reply.code(403).send({ error: "PTY does not belong to workspace" })
|
||||
return
|
||||
}
|
||||
|
|
@ -896,8 +918,8 @@ async function proxyWorkspaceRequest(args: {
|
|||
}
|
||||
|
||||
if (pathname.replace(/\/+$/, "") === "/api/shell" && request.method === "GET") {
|
||||
const result = await (await clientForRequest()).shell.list({ location: { directory: runtimeLocation.directory } }, runtimeLocationOptions)
|
||||
const ownership = await Promise.all(result.data.map((shell) => workspaceManager.ownsDirectory(workspaceId, shell.cwd)))
|
||||
const result = await wait((await clientForRequest()).shell.list({ location: { directory: runtimeLocation.directory } }, runtimeLocationOptions))
|
||||
const ownership = await wait(Promise.all(result.data.map((shell) => workspaceManager.ownsDirectory(workspaceId, shell.cwd))))
|
||||
reply.send({ ...result, data: result.data.filter((_, index) => ownership[index]) })
|
||||
return
|
||||
}
|
||||
|
|
@ -905,8 +927,8 @@ async function proxyWorkspaceRequest(args: {
|
|||
const shellId = getShellRouteId(pathname)
|
||||
if (shellId) {
|
||||
try {
|
||||
const shell = await (await clientForRequest()).shell.get({ id: shellId, location: { directory: runtimeLocation.directory } }, runtimeLocationOptions)
|
||||
if (!(await workspaceManager.ownsDirectory(workspaceId, shell.data.cwd))) {
|
||||
const shell = await wait((await clientForRequest()).shell.get({ id: shellId, location: { directory: runtimeLocation.directory } }, runtimeLocationOptions))
|
||||
if (!(await wait(workspaceManager.ownsDirectory(workspaceId, shell.data.cwd)))) {
|
||||
reply.code(403).send({ error: "Shell does not belong to workspace" })
|
||||
return
|
||||
}
|
||||
|
|
@ -923,7 +945,7 @@ async function proxyWorkspaceRequest(args: {
|
|||
if (sessionId && !isGlobalFormAction(pathname, request.method)) {
|
||||
let session
|
||||
try {
|
||||
session = await (await clientForRequest()).session.get({ sessionID: sessionId })
|
||||
session = await wait((await clientForRequest()).session.get({ sessionID: sessionId }, { signal }))
|
||||
} catch (error) {
|
||||
if (isInvalidRequestError(error)) {
|
||||
reply.code(400).send({ error: "Invalid session ID" })
|
||||
|
|
@ -937,12 +959,12 @@ async function proxyWorkspaceRequest(args: {
|
|||
}
|
||||
throw error
|
||||
}
|
||||
if (!(await workspaceManager.ownsLocation(workspaceId, session.location, await clientForRequest()))) {
|
||||
if (!(await wait(workspaceManager.ownsLocation(workspaceId, session.location, await clientForRequest(), signal)))) {
|
||||
reply.code(403).send({ error: "Session does not belong to workspace" })
|
||||
return
|
||||
}
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
const sessionWorktree = await workspaceManager.getWorktreeIdentityForPath(workspaceId, session.location.directory)
|
||||
const sessionWorktree = await wait(workspaceManager.getWorktreeIdentityForPath(workspaceId, session.location.directory))
|
||||
if (!sessionWorktree) {
|
||||
reply.code(403).send({ error: "Session does not belong to workspace" })
|
||||
return
|
||||
|
|
@ -953,6 +975,7 @@ async function proxyWorkspaceRequest(args: {
|
|||
|
||||
const body = applyDefaultWorkspaceLocation(targetUrl, promptBody, request.method, serviceDirectory, requestLocations.directories.length > 0 || sessionListHasScope, Boolean(sessionId) && !isGlobalFormAction(pathname, request.method))
|
||||
const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||
signal.throwIfAborted()
|
||||
const releaseMutation = request.method === "GET" || request.method === "HEAD"
|
||||
? undefined
|
||||
: worktreeDeletionFence.enter([...mutationIdentities])
|
||||
|
|
@ -965,53 +988,49 @@ async function proxyWorkspaceRequest(args: {
|
|||
try {
|
||||
connection?.assertCurrent()
|
||||
if (request.method === "POST" && /^\/api\/session\/[^/]+\/(?:prompt|command|shell)\/?$/.test(pathname)) {
|
||||
const disconnected = new AbortController()
|
||||
const onDisconnect = () => disconnected.abort()
|
||||
reply.raw.once("close", onDisconnect)
|
||||
try {
|
||||
try {
|
||||
const signal = AbortSignal.any([disconnected.signal, AbortSignal.timeout(15_000)])
|
||||
if (reply.raw.destroyed || request.raw.aborted) disconnected.abort()
|
||||
signal.throwIfAborted()
|
||||
const variables = await workspaceManager.getSessionEnvironment(workspaceId, signal)
|
||||
signal.throwIfAborted()
|
||||
connection?.assertCurrent()
|
||||
await (await clientForRequest()).session.environment({ sessionID: sessionId!, variables }, { signal })
|
||||
signal.throwIfAborted()
|
||||
connection?.assertCurrent()
|
||||
} catch {
|
||||
// Never log the SDK error: it can contain the complete environment body.
|
||||
releaseMutation?.()
|
||||
logger.error({ workspaceId, sessionId }, "Failed to apply profile environment")
|
||||
return reply.code(502).send({ error: SESSION_ENVIRONMENT_FAILED_ERROR_CODE })
|
||||
}
|
||||
if (!pathname.replace(/\/$/, "").endsWith("/shell")) {
|
||||
try {
|
||||
connection?.assertCurrent()
|
||||
await syncSessionGitContext(await clientForRequest(), sessionId!, disconnected.signal)
|
||||
} catch {
|
||||
// Advisory context must not turn Git recovery into another send blocker.
|
||||
// Do not log SDK bodies (they may include unrelated session context).
|
||||
logger.debug({ workspaceId, sessionId }, "Unable to update Git availability context")
|
||||
}
|
||||
}
|
||||
disconnected.signal.throwIfAborted()
|
||||
const environmentSignal = AbortSignal.any([signal, AbortSignal.timeout(15_000)])
|
||||
const variables = await wait(workspaceManager.getSessionEnvironment(workspaceId, environmentSignal))
|
||||
const client = await clientForRequest()
|
||||
environmentSignal.throwIfAborted()
|
||||
connection?.assertCurrent()
|
||||
} finally {
|
||||
reply.raw.off("close", onDisconnect)
|
||||
await wait(client.session.environment({ sessionID: sessionId!, variables }, { signal: environmentSignal }))
|
||||
environmentSignal.throwIfAborted()
|
||||
connection?.assertCurrent()
|
||||
} catch {
|
||||
signal.throwIfAborted()
|
||||
// Never log the SDK error: it can contain the complete environment body.
|
||||
releaseMutation?.()
|
||||
logger.error({ workspaceId, sessionId }, "Failed to apply profile environment")
|
||||
return reply.code(502).send({ error: SESSION_ENVIRONMENT_FAILED_ERROR_CODE })
|
||||
}
|
||||
if (!pathname.replace(/\/$/, "").endsWith("/shell")) {
|
||||
try {
|
||||
connection?.assertCurrent()
|
||||
await wait(syncSessionGitContext(await clientForRequest(), sessionId!, signal))
|
||||
} catch {
|
||||
// Advisory context must not turn Git recovery into another send blocker.
|
||||
// Do not log SDK bodies (they may include unrelated session context).
|
||||
logger.debug({ workspaceId, sessionId }, "Unable to update Git availability context")
|
||||
}
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
connection?.assertCurrent()
|
||||
}
|
||||
if (connection || workspaceManager.getSharedServiceFetch) {
|
||||
const headers = sanitizeInstanceProxyRequestHeaders(request.headers, instanceAuthHeader)
|
||||
delete headers[LOCATION_CONTEXT_HEADER]
|
||||
if (locationContext) Object.assign(headers, locationRequestOptions({ ...locationContext, directory: translatedDirectories.get(locationContext.directory)! }, { includeDirectory: true })?.headers)
|
||||
if (globalFormLocation) headers["x-opencode-directory"] = encodeURIComponent(translatedDirectories.get(globalFormLocation.directory)!)
|
||||
const runtimeFetch = connection?.fetch ?? await wait(workspaceManager.getSharedServiceFetch!())
|
||||
signal.throwIfAborted()
|
||||
return await forwardRuntimeRequest({
|
||||
request, reply, url: targetUrl.toString(), body, headers,
|
||||
fetch: connection?.fetch ?? await workspaceManager.getSharedServiceFetch!(), release: releaseMutation,
|
||||
fetch: runtimeFetch, release: releaseMutation,
|
||||
invalidate: () => connection ? connection.invalidate() : workspaceManager.invalidateSharedServiceConnection?.(),
|
||||
})
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
return reply.from(targetUrl.toString(), {
|
||||
...(body !== request.body ? { body } : {}),
|
||||
rewriteRequestHeaders: (_originalRequest, headers) => {
|
||||
|
|
@ -1151,9 +1170,9 @@ async function allDirectoriesOwned(manager: InstanceProxyWorkspaceManager, works
|
|||
return (await Promise.all(directories.map((directory) => manager.ownsDirectory(workspaceId, directory)))).every(Boolean)
|
||||
}
|
||||
|
||||
async function allLocationsOwned(manager: InstanceProxyWorkspaceManager, workspaceId: string, locations: LocationRef[], client?: OpenCodeClient) {
|
||||
async function allLocationsOwned(manager: InstanceProxyWorkspaceManager, workspaceId: string, locations: LocationRef[], client?: OpenCodeClient, signal?: AbortSignal) {
|
||||
const unique = new Map(locations.map((location) => [JSON.stringify([location.directory, location.workspaceID]), location]))
|
||||
return (await Promise.all([...unique.values()].map((location) => manager.ownsLocation(workspaceId, location, client)))).every(Boolean)
|
||||
return (await Promise.all([...unique.values()].map((location) => manager.ownsLocation(workspaceId, location, client, signal)))).every(Boolean)
|
||||
}
|
||||
|
||||
async function allPathsOwned(manager: InstanceProxyWorkspaceManager, workspaceId: string, paths: string[]) {
|
||||
|
|
@ -1202,6 +1221,7 @@ async function authorizeSessionList(
|
|||
manager: InstanceProxyWorkspaceManager,
|
||||
workspaceId: string,
|
||||
client?: OpenCodeClient,
|
||||
signal?: AbortSignal,
|
||||
): Promise<"allowed" | "invalid" | "foreign"> {
|
||||
if (method !== "GET" || targetUrl.pathname.replace(/\/+$/, "") !== "/api/session") return "allowed"
|
||||
const cursors = targetUrl.searchParams.getAll("cursor")
|
||||
|
|
@ -1212,10 +1232,11 @@ async function authorizeSessionList(
|
|||
// A project cursor embeds its original wide scope; never reinterpret it as
|
||||
// a directory cursor when Git authority is unavailable.
|
||||
if (scope.project && !(await readGitStatus()).available) return "foreign"
|
||||
signal?.throwIfAborted()
|
||||
for (const key of ["directory", "location[directory]", "project", "subpath"]) {
|
||||
targetUrl.searchParams.delete(key)
|
||||
}
|
||||
return ownsSessionListScope(manager, workspaceId, scope, client)
|
||||
return ownsSessionListScope(manager, workspaceId, scope, client, signal)
|
||||
}
|
||||
|
||||
const projects = targetUrl.searchParams.getAll("project")
|
||||
|
|
@ -1231,10 +1252,11 @@ async function authorizeSessionList(
|
|||
const project = projects[0]
|
||||
const subpath = subpaths[0]
|
||||
if (!project || (subpath !== undefined && !isSafeRelativePath(subpath))) return "invalid"
|
||||
const ownership = await ownsSessionListScope(manager, workspaceId, { project, subpath }, client)
|
||||
const ownership = await ownsSessionListScope(manager, workspaceId, { project, subpath }, client, signal)
|
||||
if (ownership !== "allowed") return ownership
|
||||
if (!(await readGitStatus()).available) {
|
||||
if (directory && !await manager.ownsLocation(workspaceId, { directory }, client)) return "foreign"
|
||||
signal?.throwIfAborted()
|
||||
if (directory && !await manager.ownsLocation(workspaceId, { directory }, client, signal)) return "foreign"
|
||||
// Narrow the initial query, preserving native directory-scoped pagination.
|
||||
// Unlike a project cursor this request has no already-established scope.
|
||||
const root = manager.getServiceDirectory?.(workspaceId) ?? manager.get(workspaceId)?.path
|
||||
|
|
@ -1255,20 +1277,25 @@ async function ownsSessionListScope(
|
|||
workspaceId: string,
|
||||
scope: SessionListScope,
|
||||
client?: OpenCodeClient,
|
||||
signal?: AbortSignal,
|
||||
): Promise<"allowed" | "foreign"> {
|
||||
signal?.throwIfAborted()
|
||||
if (scope.directory) {
|
||||
const owned = await manager.ownsLocation(workspaceId, { directory: scope.directory }, client)
|
||||
const owned = await manager.ownsLocation(workspaceId, { directory: scope.directory }, client, signal)
|
||||
return owned ? "allowed" : "foreign"
|
||||
}
|
||||
if (!scope.project) return "foreign"
|
||||
const project = (await (client ?? await manager.getSharedServiceClient()).project.list()).find((candidate) => candidate.id === scope.project)
|
||||
const serviceClient = client ?? await manager.getSharedServiceClient()
|
||||
signal?.throwIfAborted()
|
||||
const project = (await serviceClient.project.list({ signal })).find((candidate) => candidate.id === scope.project)
|
||||
signal?.throwIfAborted()
|
||||
if (!project) return "foreign"
|
||||
const directory = scope.subpath === undefined
|
||||
? project.canonical
|
||||
: /^[A-Za-z]:[\\/]|^\\\\/.test(project.canonical)
|
||||
? path.win32.resolve(project.canonical, scope.subpath)
|
||||
: path.posix.resolve(project.canonical, scope.subpath)
|
||||
const owned = await manager.ownsLocation(workspaceId, { directory }, client)
|
||||
const owned = await manager.ownsLocation(workspaceId, { directory }, client, signal)
|
||||
return owned ? "allowed" : "foreign"
|
||||
}
|
||||
|
||||
|
|
|
|||
45
packages/server/src/server/request-admission.test.ts
Normal file
45
packages/server/src/server/request-admission.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { EventEmitter } from "node:events"
|
||||
import { test } from "node:test"
|
||||
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||
import { requestAdmission } from "./request-admission"
|
||||
|
||||
function fixture() {
|
||||
const request = Object.assign(new EventEmitter(), { aborted: false })
|
||||
const reply = Object.assign(new EventEmitter(), { destroyed: false, writableFinished: false })
|
||||
const admission = requestAdmission({ raw: request } as unknown as FastifyRequest, { raw: reply } as unknown as FastifyReply)
|
||||
return { request, reply, admission }
|
||||
}
|
||||
|
||||
test("normal upload close and completed response close do not revoke admission", () => {
|
||||
const h = fixture()
|
||||
h.request.emit("close")
|
||||
assert.equal(h.admission.signal.aborted, false)
|
||||
h.reply.writableFinished = true
|
||||
h.reply.emit("close")
|
||||
assert.equal(h.admission.signal.aborted, false)
|
||||
h.admission.dispose()
|
||||
assert.equal(h.request.listenerCount("aborted"), 0)
|
||||
assert.equal(h.reply.listenerCount("close"), 0)
|
||||
})
|
||||
|
||||
test("aborted uploads and incomplete response close retire a held observer and dispose listeners", async () => {
|
||||
for (const upload of [true, false]) {
|
||||
const h = fixture()
|
||||
const pending = h.admission.wait(new Promise<void>(() => {}))
|
||||
const rejected = assert.rejects(pending, { name: "AbortError" })
|
||||
if (upload) h.request.emit("aborted")
|
||||
else h.reply.emit("close")
|
||||
await rejected
|
||||
h.admission.dispose()
|
||||
assert.equal(h.request.listenerCount("aborted"), 0)
|
||||
assert.equal(h.reply.listenerCount("close"), 0)
|
||||
}
|
||||
})
|
||||
|
||||
test("already disconnected admission observes late rejected work without unhandled rejection", async () => {
|
||||
const h = fixture()
|
||||
h.reply.emit("close")
|
||||
await assert.rejects(h.admission.wait(Promise.reject(new Error("late shared failure"))), { name: "AbortError" })
|
||||
h.admission.dispose()
|
||||
})
|
||||
41
packages/server/src/server/request-admission.ts
Normal file
41
packages/server/src/server/request-admission.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||
|
||||
/** Cancellation belongs to this HTTP observer, never to shared connection work.
|
||||
* IncomingMessage.close also fires after a normal request body; only an aborted
|
||||
* upload or the downstream response closing before completion revokes admission.
|
||||
*/
|
||||
export function requestAdmission(request: FastifyRequest, reply: FastifyReply) {
|
||||
const controller = new AbortController()
|
||||
const abort = () => controller.abort()
|
||||
const close = () => { if (!reply.raw.writableFinished) abort() }
|
||||
request.raw.once("aborted", abort)
|
||||
reply.raw.once("close", close)
|
||||
if (request.raw.aborted || reply.raw.destroyed) abort()
|
||||
return {
|
||||
signal: controller.signal,
|
||||
async wait<T>(pending: Promise<T>): Promise<T> {
|
||||
if (controller.signal.aborted) {
|
||||
// A caller may already have allocated the Promise. Observe its rejection
|
||||
// even when admission was revoked immediately before this boundary.
|
||||
void pending.catch(() => undefined)
|
||||
controller.signal.throwIfAborted()
|
||||
}
|
||||
let rejectAbort!: () => void
|
||||
const aborted = new Promise<never>((_resolve, reject) => {
|
||||
rejectAbort = () => reject(controller.signal.reason)
|
||||
controller.signal.addEventListener("abort", rejectAbort, { once: true })
|
||||
})
|
||||
try {
|
||||
const result = await Promise.race([pending, aborted])
|
||||
controller.signal.throwIfAborted()
|
||||
return result
|
||||
} finally {
|
||||
controller.signal.removeEventListener("abort", rejectAbort)
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
request.raw.off("aborted", abort)
|
||||
reply.raw.off("close", close)
|
||||
},
|
||||
}
|
||||
}
|
||||
272
packages/server/src/server/routes/auth-responsiveness.test.ts
Normal file
272
packages/server/src/server/routes/auth-responsiveness.test.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import assert from "node:assert/strict"
|
||||
import crypto from "node:crypto"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { performance } from "node:perf_hooks"
|
||||
import { it } from "node:test"
|
||||
import Fastify from "fastify"
|
||||
import pino from "pino"
|
||||
import { AuthManager } from "../../auth/manager"
|
||||
import { registerAuthRoutes } from "./auth"
|
||||
|
||||
const username = "fixture-user"
|
||||
const password = "fixture-password"
|
||||
|
||||
async function fixture(options: { persist?: boolean; generateToken?: boolean; password?: string; dangerouslySkipAuth?: boolean } = {}) {
|
||||
const tempRoot = path.join(os.tmpdir(), "opencode")
|
||||
await fs.mkdir(tempRoot, { recursive: true })
|
||||
const directory = await fs.mkdtemp(path.join(tempRoot, "codenomad-auth-responsive-"))
|
||||
// An existing v1 record, produced independently with the old synchronous API.
|
||||
const salt = Buffer.from("0123456789abcdef")
|
||||
const params = { N: 16384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 }
|
||||
if (options.persist !== false) await fs.writeFile(path.join(directory, "auth.json"), JSON.stringify({
|
||||
version: 1,
|
||||
username,
|
||||
password: {
|
||||
algorithm: "scrypt", saltBase64: salt.toString("base64"),
|
||||
hashBase64: crypto.scryptSync(password, salt, 64, params).toString("base64"),
|
||||
keyLength: 64, params,
|
||||
},
|
||||
userProvided: true,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
}))
|
||||
const manager = new AuthManager({
|
||||
configPath: path.join(directory, "config.yaml"), username, generateToken: false, ...options,
|
||||
}, pino({ level: "silent" }))
|
||||
const app = Fastify({ logger: false })
|
||||
registerAuthRoutes(app, { authManager: manager })
|
||||
return {
|
||||
app, manager, directory,
|
||||
async close() {
|
||||
await app.close()
|
||||
await fs.rm(directory, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function observeWork<T>(work: () => T, state: { pending: number; completed: number; maxCallMs: number }): T {
|
||||
state.pending++
|
||||
const start = performance.now()
|
||||
try {
|
||||
const result = work()
|
||||
state.maxCallMs = Math.max(state.maxCallMs, performance.now() - start)
|
||||
if (result && typeof (result as unknown as Promise<unknown>).then === "function") {
|
||||
return Promise.resolve(result).finally(() => { state.pending--; state.completed++ }) as T
|
||||
}
|
||||
state.pending--
|
||||
state.completed++
|
||||
return result
|
||||
} catch (error) {
|
||||
state.pending--
|
||||
state.completed++
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
it("services a witness endpoint while a bounded burst of login verifications is pending", async (t) => {
|
||||
const f = await fixture()
|
||||
try {
|
||||
const state = { pending: 0, completed: 0, maxCallMs: 0 }
|
||||
f.app.get("/fixture-heartbeat", async () => ({ pending: state.pending, completed: state.completed }))
|
||||
const validate = f.manager.validateLogin.bind(f.manager)
|
||||
let probe: Promise<{ pending: number; completed: number }> | undefined
|
||||
let probeMs = 0
|
||||
f.manager.validateLogin = (...args) => {
|
||||
if (!probe) {
|
||||
const start = performance.now()
|
||||
probe = new Promise((resolve, reject) => setImmediate(() => {
|
||||
f.app.inject({ method: "GET", url: "/fixture-heartbeat" }).then((response) => {
|
||||
probeMs = performance.now() - start
|
||||
resolve(response.json())
|
||||
}, reject)
|
||||
}))
|
||||
}
|
||||
return observeWork(() => validate(...args), state)
|
||||
}
|
||||
const start = performance.now()
|
||||
const responses = await Promise.all(Array.from({ length: 8 }, () => f.app.inject({
|
||||
method: "POST", url: "/api/auth/login", payload: { username, password: "invalid-password" },
|
||||
})))
|
||||
const witness = await probe!
|
||||
t.diagnostic(JSON.stringify({ operation: "login", totalMs: performance.now() - start, probeMs, ...state, witness }))
|
||||
for (const response of responses) {
|
||||
assert.equal(response.statusCode, 401)
|
||||
assert.deepEqual(response.json(), { error: "Invalid credentials" })
|
||||
assert.equal(response.headers["set-cookie"], undefined)
|
||||
}
|
||||
assert.ok(witness.pending > 0, "the HTTP witness must run before all expensive verifications finish")
|
||||
} finally {
|
||||
await f.close()
|
||||
}
|
||||
})
|
||||
|
||||
it("awaits login results for valid credentials, rejects wrong usernames and authenticates the cookie", async () => {
|
||||
const f = await fixture()
|
||||
try {
|
||||
const responses = await Promise.all([
|
||||
{ username, password },
|
||||
{ username: "not-the-user", password },
|
||||
{ username, password: "not-the-password" },
|
||||
].map((payload) => f.app.inject({ method: "POST", url: "/api/auth/login", payload })))
|
||||
assert.deepEqual(responses.map((response) => response.statusCode), [200, 401, 401])
|
||||
assert.deepEqual(responses[0].json(), { ok: true })
|
||||
const cookie = String(responses[0].headers["set-cookie"])
|
||||
assert.match(cookie, /HttpOnly; Path=\/; SameSite=Lax/)
|
||||
const status = await f.app.inject({ method: "GET", url: "/api/auth/status", headers: { cookie } })
|
||||
assert.deepEqual(status.json(), { authenticated: true, username, passwordUserProvided: true })
|
||||
} finally {
|
||||
await f.close()
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects a pending login verified against a password replaced before verification returns", async (t) => {
|
||||
const f = await fixture()
|
||||
let release: (() => void) | undefined
|
||||
let pendingLogin: Promise<unknown> | undefined
|
||||
try {
|
||||
let notifyDerived!: () => void
|
||||
const derived = new Promise<void>((resolve) => { notifyDerived = resolve })
|
||||
const scrypt = crypto.scrypt.bind(crypto)
|
||||
let held = false
|
||||
// Run real scrypt, but hold the first old-password callback until the new
|
||||
// password is durably committed. No scheduler or elapsed-time assumptions.
|
||||
const scryptMock = t.mock.method(crypto, "scrypt", (
|
||||
input: crypto.BinaryLike, salt: crypto.BinaryLike, keyLength: number,
|
||||
options: crypto.ScryptOptions, callback: (error: Error | null, key: Buffer) => void,
|
||||
) => scrypt(input, salt, keyLength, options, (error, key) => {
|
||||
if (input === password && !held) {
|
||||
held = true
|
||||
release = () => { release = undefined; callback(error, key) }
|
||||
notifyDerived()
|
||||
} else {
|
||||
callback(error, key)
|
||||
}
|
||||
}))
|
||||
const login = f.app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } })
|
||||
.then((response) => response)
|
||||
pendingLogin = login
|
||||
await derived
|
||||
const session = f.manager.createSession(username)
|
||||
const changed = await f.app.inject({
|
||||
method: "POST", url: "/api/auth/password", payload: { password: "replacement-password" },
|
||||
headers: { cookie: `${f.manager.getCookieName()}=${session.id}` },
|
||||
})
|
||||
assert.equal(changed.statusCode, 200)
|
||||
assert.equal(await f.manager.validateLogin(username, "replacement-password"), true)
|
||||
release!()
|
||||
const stale = await login
|
||||
assert.equal(stale.statusCode, 401, "a successful derivation of superseded credentials must not authorize a login")
|
||||
assert.deepEqual(stale.json(), { error: "Invalid credentials" })
|
||||
assert.equal(stale.headers["set-cookie"], undefined)
|
||||
assert.equal(scryptMock.mock.callCount(), 3, "do not replay the old login against the replacement password")
|
||||
assert.equal(await f.manager.validateLogin(username, password), false)
|
||||
} finally {
|
||||
release?.()
|
||||
await pendingLogin
|
||||
t.mock.restoreAll()
|
||||
await f.close()
|
||||
}
|
||||
})
|
||||
|
||||
it("preserves unauthenticated, runtime-override and disabled-auth password errors", async () => {
|
||||
for (const options of [{}, { password }, { dangerouslySkipAuth: true }]) {
|
||||
const f = await fixture(options)
|
||||
try {
|
||||
if (!options.dangerouslySkipAuth) {
|
||||
const unauthorized = await f.app.inject({ method: "POST", url: "/api/auth/password", payload: { password } })
|
||||
assert.equal(unauthorized.statusCode, 401)
|
||||
assert.deepEqual(unauthorized.json(), { error: "Unauthorized" })
|
||||
}
|
||||
if (options.password || options.dangerouslySkipAuth) {
|
||||
const session = f.manager.createSession(username)
|
||||
const response = await f.app.inject({
|
||||
method: "POST", url: "/api/auth/password", payload: { password: "next-password" },
|
||||
headers: { cookie: `${f.manager.getCookieName()}=${session.id}` },
|
||||
})
|
||||
assert.equal(response.statusCode, 409)
|
||||
assert.match(response.body, options.password ? /provided via CLI\/env/ : /authentication is disabled/)
|
||||
assert.match(String(response.headers["content-type"]), /^text\/plain/)
|
||||
}
|
||||
if (options.password) assert.equal(await f.manager.validateLogin(username, password), true)
|
||||
if (options.dangerouslySkipAuth) assert.equal(await f.manager.validateLogin("any", "anything"), true)
|
||||
} finally {
|
||||
await f.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps bootstrap password changes ordered and recovers the queue after a persistence failure", async () => {
|
||||
const f = await fixture({ persist: false, generateToken: true })
|
||||
try {
|
||||
assert.equal(await f.manager.validateLogin(username, password), false)
|
||||
assert.deepEqual(f.manager.getStatus(), { username, passwordUserProvided: false })
|
||||
const authPath = path.join(f.directory, "auth.json")
|
||||
await fs.mkdir(authPath)
|
||||
const session = f.manager.createSession(username)
|
||||
const failed = await f.app.inject({
|
||||
method: "POST", url: "/api/auth/password", payload: { password: "failed-password" },
|
||||
headers: { cookie: `${f.manager.getCookieName()}=${session.id}` },
|
||||
})
|
||||
assert.equal(failed.statusCode, 409)
|
||||
assert.match(String(failed.headers["content-type"]), /^text\/plain/)
|
||||
assert.deepEqual(f.manager.getStatus(), { username, passwordUserProvided: false })
|
||||
await fs.rmdir(authPath)
|
||||
const changes = ["first-password", "second-password", "final-password"]
|
||||
await Promise.all(changes.map((next) => f.manager.setPassword(next)))
|
||||
assert.equal(await f.manager.validateLogin(username, changes[0]), false)
|
||||
assert.equal(await f.manager.validateLogin(username, changes[2]), true)
|
||||
const persisted = JSON.parse(await fs.readFile(authPath, "utf8"))
|
||||
assert.equal(persisted.username, username)
|
||||
assert.equal(persisted.userProvided, true)
|
||||
// Independent synchronous verification proves persisted format compatibility.
|
||||
const record = persisted.password
|
||||
assert.equal(crypto.scryptSync(changes[2], Buffer.from(record.saltBase64, "base64"), record.keyLength, record.params)
|
||||
.toString("base64"), record.hashBase64)
|
||||
} finally {
|
||||
await f.close()
|
||||
}
|
||||
})
|
||||
|
||||
it("services a witness endpoint during password derivation and awaits persistence", async (t) => {
|
||||
const f = await fixture()
|
||||
try {
|
||||
const session = f.manager.createSession(username)
|
||||
const cookie = `${f.manager.getCookieName()}=${session.id}`
|
||||
const state = { pending: 0, completed: 0, maxCallMs: 0 }
|
||||
f.app.get("/fixture-heartbeat", async () => ({ pending: state.pending, completed: state.completed }))
|
||||
const setPassword = f.manager.setPassword.bind(f.manager)
|
||||
let probe: Promise<{ pending: number; completed: number }> | undefined
|
||||
let probeMs = 0
|
||||
f.manager.setPassword = (...args) => {
|
||||
if (!probe) {
|
||||
const start = performance.now()
|
||||
probe = new Promise((resolve, reject) => setImmediate(() => {
|
||||
f.app.inject({ method: "GET", url: "/fixture-heartbeat" }).then((response) => {
|
||||
probeMs = performance.now() - start
|
||||
resolve(response.json())
|
||||
}, reject)
|
||||
}))
|
||||
}
|
||||
return observeWork(() => setPassword(...args), state)
|
||||
}
|
||||
const start = performance.now()
|
||||
const responses = await Promise.all(Array.from({ length: 4 }, (_, index) => f.app.inject({
|
||||
method: "POST", url: "/api/auth/password", headers: { cookie },
|
||||
payload: { password: `new-password-${index}` },
|
||||
})))
|
||||
const witness = await probe!
|
||||
t.diagnostic(JSON.stringify({ operation: "password", totalMs: performance.now() - start, probeMs, ...state, witness }))
|
||||
for (const response of responses) {
|
||||
assert.equal(response.statusCode, 200)
|
||||
assert.deepEqual(response.json(), { ok: true, username, passwordUserProvided: true })
|
||||
}
|
||||
assert.equal(await f.manager.validateLogin(username, "new-password-3"), true)
|
||||
const persisted = JSON.parse(await fs.readFile(path.join(f.directory, "auth.json"), "utf8"))
|
||||
assert.equal(persisted.version, 1)
|
||||
assert.equal(persisted.password.algorithm, "scrypt")
|
||||
assert.ok(witness.pending > 0, "the HTTP witness must run before all expensive password changes finish")
|
||||
} finally {
|
||||
await f.close()
|
||||
}
|
||||
})
|
||||
|
|
@ -98,7 +98,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||
|
||||
app.post("/api/auth/login", async (request, reply) => {
|
||||
const body = LoginSchema.parse(request.body ?? {})
|
||||
const ok = deps.authManager.validateLogin(body.username, body.password)
|
||||
const ok = await deps.authManager.validateLogin(body.username, body.password)
|
||||
if (!ok) {
|
||||
reply.code(401).send({ error: "Invalid credentials" })
|
||||
return
|
||||
|
|
@ -147,7 +147,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||
|
||||
const body = PasswordSchema.parse(request.body ?? {})
|
||||
try {
|
||||
const status = deps.authManager.setPassword(body.password)
|
||||
const status = await deps.authManager.setPassword(body.password)
|
||||
reply.send({ ok: true, ...status })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
|
|
|||
|
|
@ -267,10 +267,12 @@ export class WorkspaceManager {
|
|||
return Boolean(hostDirectory && await this.ownsHostDirectory(record, hostDirectory))
|
||||
}
|
||||
|
||||
async ownsLocation(id: string, location: LocationRef, client?: OpenCodeClient): Promise<boolean> {
|
||||
async ownsLocation(id: string, location: LocationRef, client?: OpenCodeClient, signal?: AbortSignal): Promise<boolean> {
|
||||
signal?.throwIfAborted()
|
||||
const record = this.workspaces.get(id)
|
||||
if (!record?.[WORKSPACE_STATE].published) return false
|
||||
const directory = await this.getServiceDirectoryForPath(id, location.directory)
|
||||
signal?.throwIfAborted()
|
||||
if (!directory) return false
|
||||
if (location.workspaceID === undefined) return true
|
||||
// The directory fence alone cannot authorize a legacy native workspace.
|
||||
|
|
@ -279,8 +281,8 @@ export class WorkspaceManager {
|
|||
try {
|
||||
const requested = readLocationRef({ ...location, directory })
|
||||
const resolved = readLocationRef(client
|
||||
? await client.location.get({ location: { directory } }, locationRequestOptions(requested))
|
||||
: await this.sharedService.validateLocation(requested, undefined, record[WORKSPACE_STATE].serviceOptions))
|
||||
? await client.location.get({ location: { directory } }, { ...locationRequestOptions(requested), signal })
|
||||
: await this.sharedService.validateLocation(requested, { signal }, record[WORKSPACE_STATE].serviceOptions))
|
||||
return sameLocation(requested, resolved)
|
||||
} catch {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ export class OpenCodeSharedService {
|
|||
try {
|
||||
connection = await this.connect(serviceOptions, requestOptions?.deadlineAt)
|
||||
const nativeRequestOptions = requestOptions?.signal ? { signal: requestOptions.signal } : undefined
|
||||
return this.invalidateAfterStream(connection.client.event.subscribe(nativeRequestOptions), connection)
|
||||
return this.invalidateAfterStream(connection.client.event.subscribe(nativeRequestOptions), connection, requestOptions?.signal)
|
||||
} catch (error) {
|
||||
if (connection && !requestOptions?.signal?.aborted) this.invalidateConnection(connection)
|
||||
throw error
|
||||
|
|
@ -291,14 +291,28 @@ export class OpenCodeSharedService {
|
|||
}
|
||||
}
|
||||
|
||||
private async *invalidateAfterStream(events: AsyncIterable<OpenCodeEvent>, connection: ServiceConnection) {
|
||||
private async *invalidateAfterStream(events: AsyncIterable<OpenCodeEvent>, connection: ServiceConnection, signal?: AbortSignal) {
|
||||
const iterator = events[Symbol.asyncIterator]()
|
||||
try {
|
||||
for await (const event of events) {
|
||||
while (true) {
|
||||
let result: IteratorResult<OpenCodeEvent>
|
||||
try {
|
||||
result = await iterator.next()
|
||||
} catch (error) {
|
||||
if (!signal?.aborted) this.invalidateConnection(connection)
|
||||
throw error
|
||||
}
|
||||
if (result.done) {
|
||||
if (!signal?.aborted) this.invalidateConnection(connection)
|
||||
return
|
||||
}
|
||||
connection.assertCurrent()
|
||||
yield event
|
||||
// Consumer return/throw and subscriber-local abort are not failures of
|
||||
// the shared source. Uncancelled iterator EOF/errors still invalidate.
|
||||
yield result.value
|
||||
}
|
||||
} finally {
|
||||
this.invalidateConnection(connection)
|
||||
await iterator.return?.()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
116
packages/server/src/workspaces/subscriber-lifetime.test.ts
Normal file
116
packages/server/src/workspaces/subscriber-lifetime.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { test, type TestContext } from "node:test"
|
||||
import type { ServerResponse } from "node:http"
|
||||
import Fastify from "fastify"
|
||||
import type { OpenCodeEvent } from "@opencode/client"
|
||||
import { OpenCodeSharedService } from "./opencode-service"
|
||||
|
||||
async function transport(t: TestContext) {
|
||||
const upstream = Fastify()
|
||||
const streams: ServerResponse[] = []
|
||||
upstream.get("/api/event", (_request, reply) => {
|
||||
reply.raw.setHeader("Content-Type", "text/event-stream")
|
||||
reply.raw.flushHeaders()
|
||||
reply.hijack()
|
||||
streams.push(reply.raw)
|
||||
reply.raw.write(`data: ${JSON.stringify({ id: "connected", created: Date.now(), type: "server.connected", data: {} })}\n\n`)
|
||||
})
|
||||
upstream.get("/api/info", async () => ({ version: "fixture" }))
|
||||
await upstream.listen({ host: "127.0.0.1", port: 0 })
|
||||
const endpoint = { url: `http://127.0.0.1:${(upstream.server.address() as { port: number }).port}` }
|
||||
const service = new OpenCodeSharedService()
|
||||
// Only CLI lifecycle/upstream are fixtures. SDK sharing, cancellation, SSE
|
||||
// parsing and the CodeNomad connection fences remain real.
|
||||
const client = await service.client({ kind: "lifecycle", identity: "isolated-event-transport",
|
||||
lifecycle: { discover: async () => endpoint, ensure: async () => endpoint },
|
||||
})
|
||||
t.after(async () => {
|
||||
await service.shutdown()
|
||||
for (const stream of streams) stream.destroy()
|
||||
await upstream.close()
|
||||
})
|
||||
return { service, client, streams,
|
||||
send(event: OpenCodeEvent) { streams.at(-1)!.write(`data: ${JSON.stringify(event)}\n\n`) },
|
||||
}
|
||||
}
|
||||
|
||||
for (const mode of ["abort", "return", "throw"] as const) {
|
||||
test(`one subscriber's ${mode} cannot invalidate another subscriber or the healthy client`, async t => {
|
||||
const f = await transport(t)
|
||||
const controller = new AbortController()
|
||||
const other = f.client.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
|
||||
t.after(async () => { controller.abort(); await other.return?.() })
|
||||
assert.equal((await other.next()).value?.type, "server.connected")
|
||||
const local = new AbortController()
|
||||
const wrapped = (await f.service.subscribe({ signal: local.signal }))[Symbol.asyncIterator]()
|
||||
t.after(async () => { local.abort(); await wrapped.return?.() })
|
||||
assert.equal((await wrapped.next()).value?.type, "server.connected")
|
||||
assert.equal(f.streams.length, 1, "both consumers must share one physical HTTP stream")
|
||||
if (mode === "abort") {
|
||||
const waiting = wrapped.next()
|
||||
local.abort()
|
||||
assert.equal((await waiting).done, true)
|
||||
} else if (mode === "throw") {
|
||||
const failure = new Error("consumer failure")
|
||||
await assert.rejects(wrapped.throw!(failure), error => error === failure)
|
||||
} else await wrapped.return?.()
|
||||
assert.equal((await f.client.server.info()).version, "fixture")
|
||||
assert.equal((await f.service.acquire()).client, f.client)
|
||||
const next = other.next()
|
||||
const renamed = { id: "after-local-stop", created: Date.now(), type: "session.renamed" as const,
|
||||
durable: { aggregateID: "session", seq: 1, version: 1 as const },
|
||||
data: { sessionID: "session", title: "still shared" },
|
||||
}
|
||||
f.send(renamed)
|
||||
assert.deepEqual((await next).value, renamed)
|
||||
})
|
||||
}
|
||||
|
||||
test("an already-aborted subscriber never opens a stream or invalidates the healthy connection", async t => {
|
||||
const f = await transport(t)
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const wrapped = (await f.service.subscribe({ signal: controller.signal }))[Symbol.asyncIterator]()
|
||||
assert.equal((await wrapped.next()).done, true)
|
||||
assert.equal((await f.service.acquire()).client, f.client)
|
||||
assert.equal(f.streams.length, 0)
|
||||
})
|
||||
|
||||
test("genuine upstream EOF invalidates its connection but late cleanup cannot invalidate its replacement", async t => {
|
||||
const f = await transport(t)
|
||||
const controller = new AbortController()
|
||||
const wrapped = (await f.service.subscribe({ signal: controller.signal }))[Symbol.asyncIterator]()
|
||||
t.after(async () => { controller.abort(); await wrapped.return?.() })
|
||||
assert.equal((await wrapped.next()).value?.type, "server.connected")
|
||||
const ended = wrapped.next()
|
||||
f.streams[0].end()
|
||||
assert.equal((await ended).done, true)
|
||||
const replacement = await f.service.acquire()
|
||||
assert.notEqual(replacement.client, f.client)
|
||||
await wrapped.return?.()
|
||||
assert.equal(await f.service.acquire(), replacement)
|
||||
})
|
||||
|
||||
test("abrupt upstream transport failure still invalidates its connection", async t => {
|
||||
const f = await transport(t)
|
||||
const controller = new AbortController()
|
||||
const wrapped = (await f.service.subscribe({ signal: controller.signal }))[Symbol.asyncIterator]()
|
||||
t.after(async () => { controller.abort(); await wrapped.return?.() })
|
||||
assert.equal((await wrapped.next()).value?.type, "server.connected")
|
||||
const failed = wrapped.next()
|
||||
f.streams[0].destroy()
|
||||
await assert.rejects(failed)
|
||||
assert.notEqual((await f.service.acquire()).client, f.client)
|
||||
})
|
||||
|
||||
test("an obsolete iterator cannot invalidate its replacement connection or publish queued events", async t => {
|
||||
const f = await transport(t)
|
||||
const controller = new AbortController()
|
||||
const wrapped = (await f.service.subscribe({ signal: controller.signal }))[Symbol.asyncIterator]()
|
||||
t.after(async () => { controller.abort(); await wrapped.return?.() })
|
||||
assert.equal((await wrapped.next()).value?.type, "server.connected")
|
||||
f.service.invalidate()
|
||||
const replacement = await f.service.acquire()
|
||||
await assert.rejects(wrapped.next())
|
||||
assert.equal(await f.service.acquire(), replacement)
|
||||
})
|
||||
|
|
@ -912,10 +912,6 @@ export default function PromptInput(props: PromptInputProps) {
|
|||
mode={pickerMode()}
|
||||
onClose={handlePickerClose}
|
||||
onSelect={handlePickerSelect}
|
||||
onSubmitWithoutSelection={() => {
|
||||
handlePickerClose()
|
||||
void handleSend()
|
||||
}}
|
||||
agents={instanceAgents()}
|
||||
commands={promptCommands()}
|
||||
searchQuery={searchQuery()}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { describe, it } from "node:test"
|
|||
|
||||
import { usePromptKeyDown } from "./usePromptKeyDown.ts"
|
||||
|
||||
function setup(submitOnEnter = true) {
|
||||
function setup(submitOnEnter = true, pickerOpen = false) {
|
||||
let prompt = "hello"
|
||||
const sends: Array<boolean | undefined> = []
|
||||
let backgrounds = 0
|
||||
|
|
@ -23,8 +23,8 @@ function setup(submitOnEnter = true) {
|
|||
setPrompt: (value) => { prompt = value },
|
||||
mode: () => "normal",
|
||||
setMode: () => {},
|
||||
isPickerOpen: () => false,
|
||||
closePicker: () => {},
|
||||
isPickerOpen: () => pickerOpen,
|
||||
closePicker: () => { pickerOpen = false },
|
||||
ignoredAtPositions: () => new Set(),
|
||||
setIgnoredAtPositions: () => {},
|
||||
getAttachments: () => [],
|
||||
|
|
@ -38,16 +38,42 @@ function setup(submitOnEnter = true) {
|
|||
selectPreviousHistory: () => false,
|
||||
selectNextHistory: () => false,
|
||||
})
|
||||
const press = (input: Partial<KeyboardEvent>) => handler({
|
||||
key: "Enter",
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
...input,
|
||||
} as KeyboardEvent)
|
||||
const press = (input: Partial<KeyboardEvent>) => {
|
||||
let prevented = false, stopped = false
|
||||
handler({
|
||||
key: "Enter",
|
||||
preventDefault() { prevented = true },
|
||||
stopPropagation() { stopped = true },
|
||||
...input,
|
||||
} as KeyboardEvent)
|
||||
return { prevented, stopped }
|
||||
}
|
||||
return { press, sends, backgrounds: () => backgrounds, prompt: () => prompt }
|
||||
}
|
||||
|
||||
describe("prompt submit shortcuts", () => {
|
||||
it("does not send a key already consumed by a picker that closed during dispatch", () => {
|
||||
const input = setup()
|
||||
input.press({ defaultPrevented: true })
|
||||
input.press({ defaultPrevented: true, ctrlKey: true, shiftKey: true })
|
||||
assert.deepEqual(input.sends, [])
|
||||
assert.equal(input.prompt(), "hello")
|
||||
})
|
||||
|
||||
for (const submitOnEnter of [true, false]) {
|
||||
it(`reserves Enter for an open picker with submitOnEnter=${submitOnEnter}`, () => {
|
||||
const input = setup(submitOnEnter, true)
|
||||
for (const modifiers of [{}, { shiftKey: true }, { ctrlKey: true }, { metaKey: true },
|
||||
{ ctrlKey: true, shiftKey: true }, { metaKey: true, shiftKey: true }]) {
|
||||
const handled = input.press(modifiers)
|
||||
assert.equal(handled.prevented, true, "an empty or not-yet-mounted picker cannot insert a newline")
|
||||
assert.equal(handled.stopped, Boolean(modifiers.ctrlKey || modifiers.metaKey))
|
||||
assert.deepEqual(input.sends, [], "picker Enter cannot send or queue the draft")
|
||||
assert.equal(input.prompt(), "hello")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it("sends normally on Enter and queues only on Mod+Shift+Enter", () => {
|
||||
const input = setup()
|
||||
input.press({})
|
||||
|
|
|
|||
|
|
@ -240,10 +240,17 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
|
|||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
// A picker listener may already have selected an item and closed itself
|
||||
// before this delegated handler runs. Never submit the consumed key.
|
||||
if (e.defaultPrevented) return
|
||||
const isModified = e.metaKey || e.ctrlKey
|
||||
|
||||
// If the picker is open, Enter should select from it.
|
||||
if (!isModified && options.isPickerOpen()) {
|
||||
// The picker owns Enter even when loading, empty or not yet mounted.
|
||||
// Unmodified Enter (including Shift) bubbles to its selection handler;
|
||||
// submit/queue modifiers are swallowed rather than sending the draft.
|
||||
if (options.isPickerOpen()) {
|
||||
e.preventDefault()
|
||||
if (isModified) e.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -263,10 +270,6 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
|
|||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
// If the picker is open, avoid selecting an item on Enter.
|
||||
if (options.isPickerOpen()) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -278,9 +281,6 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
|
|||
// Default: Cmd/Ctrl+Enter submits.
|
||||
if (isModified) {
|
||||
e.preventDefault()
|
||||
if (options.isPickerOpen()) {
|
||||
options.closePicker()
|
||||
}
|
||||
options.onSend(e.shiftKey)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { For, Index, Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { Copy } from "lucide-solid"
|
||||
import type { ToolState } from "../../../types/tool-state"
|
||||
|
|
@ -82,20 +82,23 @@ function TaskToolCallRow(props: {
|
|||
const messageVersion = createMemo(() => record()?.revision ?? 0)
|
||||
const partVersion = createMemo(() => partEntry()?.revision ?? 0)
|
||||
|
||||
const rendered = createMemo(() => {
|
||||
const part = toolPart()
|
||||
if (!part) return null
|
||||
return props.renderToolCall({
|
||||
toolCall: part as any,
|
||||
messageId: messageId(),
|
||||
messageVersion: messageVersion(),
|
||||
partVersion: partVersion(),
|
||||
sessionId: props.sessionId,
|
||||
// Keep one shell for this key. Native page reprojections and unrelated text
|
||||
// revisions must update its props, not discard disclosure/scroll state.
|
||||
// Rows follow their tool identity, not their position in the bounded window.
|
||||
// A new identity still needs a fresh shell with its default disclosure.
|
||||
// Nonzero-arity Show callbacks mount untracked; getters update the child props.
|
||||
return <Show when={props.toolKey} keyed>{(_key) => (
|
||||
<Show when={toolPart()}>{(_part) => props.renderToolCall({
|
||||
get toolCall() { partVersion(); return toolPart()! },
|
||||
get messageId() { return messageId() },
|
||||
get messageVersion() { return messageVersion() },
|
||||
// Native page hydration resets the per-part revision to zero. Let
|
||||
// Markdown use its content hash instead of pinning changed output to 0;
|
||||
// the getter above still fences versioned in-place snapshot updates.
|
||||
get sessionId() { return props.sessionId },
|
||||
forceCollapsed: true,
|
||||
})
|
||||
})
|
||||
|
||||
return <>{rendered()}</>
|
||||
})}</Show>
|
||||
)}</Show>
|
||||
}
|
||||
|
||||
function normalizeStatus(status?: string | null): ToolState["status"] | undefined {
|
||||
|
|
@ -229,15 +232,9 @@ export const taskRenderer: ToolRenderer = {
|
|||
const [childToolsTruncated, setChildToolsTruncated] = createSignal(false)
|
||||
|
||||
let indexedSessionId = ""
|
||||
let indexedMessageCount = 0
|
||||
let indexedMessageTail = ""
|
||||
const indexedPartCounts = new Map<string, number>()
|
||||
|
||||
function resetChildToolIndex(nextSessionId: string) {
|
||||
indexedSessionId = nextSessionId
|
||||
indexedMessageCount = 0
|
||||
indexedMessageTail = ""
|
||||
indexedPartCounts.clear()
|
||||
setChildToolKeys([])
|
||||
setChildToolsTruncated(false)
|
||||
}
|
||||
|
|
@ -265,15 +262,11 @@ export const taskRenderer: ToolRenderer = {
|
|||
keys.unshift(`${messageId}::${partId}`)
|
||||
}
|
||||
if (idx >= oldestScannedIndex) setChildToolsTruncated(true)
|
||||
indexedPartCounts.set(messageId, partIds.length)
|
||||
return keys
|
||||
}
|
||||
|
||||
function fullRescanChildTools(sessionId: string, messageIds: string[]) {
|
||||
indexedSessionId = sessionId
|
||||
indexedMessageCount = messageIds.length
|
||||
indexedMessageTail = messageIds[messageIds.length - 1] ?? ""
|
||||
indexedPartCounts.clear()
|
||||
setChildToolsTruncated(false)
|
||||
|
||||
const nextKeys: string[] = []
|
||||
|
|
@ -284,93 +277,35 @@ export const taskRenderer: ToolRenderer = {
|
|||
const keys = scanMessageToolParts(messageIds[index], 0, scanLimit - nextKeys.length, budget)
|
||||
for (let keyIndex = keys.length - 1; keyIndex >= 0; keyIndex -= 1) nextKeys.unshift(keys[keyIndex])
|
||||
}
|
||||
for (let index = oldestScannedIndex; index < messageIds.length; index += 1) {
|
||||
const messageId = messageIds[index]
|
||||
if (!indexedPartCounts.has(messageId)) indexedPartCounts.set(messageId, store.getMessage(messageId)?.partIds.length ?? 0)
|
||||
}
|
||||
setChildToolsTruncated((truncated) => isTaskScanTruncated(truncated, oldestScannedIndex > 0, isTaskStepListTruncated(nextKeys.length)))
|
||||
setChildToolKeys(nextKeys.slice(-TASK_STEP_RENDER_LIMIT))
|
||||
const keys = nextKeys.slice(-TASK_STEP_RENDER_LIMIT)
|
||||
setChildToolKeys(previous => previous.length === keys.length && previous.every((key, index) => key === keys[index]) ? previous : keys)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const id = childSessionId()
|
||||
const loaded = childSessionLoaded()
|
||||
|
||||
if (!id || !loaded) {
|
||||
if (indexedSessionId) {
|
||||
resetChildToolIndex("")
|
||||
}
|
||||
if (!id || (indexedSessionId && indexedSessionId !== id)) {
|
||||
resetChildToolIndex("")
|
||||
}
|
||||
if (!id) return
|
||||
if (!loaded) {
|
||||
// Invalidation requests a fresh page, but its resident display snapshot
|
||||
// may still be valid. Keep its shells while that read is pending, but
|
||||
// clear membership and truncation when deletion/eviction removed it.
|
||||
if (store.getSessionMessageIds(id).length === 0) resetChildToolIndex("")
|
||||
// Rebuild resident membership after the authoritative page arrives.
|
||||
return
|
||||
}
|
||||
|
||||
// We use the session revision as the reactive change point, but avoid
|
||||
// rescanning the entire session on every update.
|
||||
// Authoritative pages can remove, replace or reorder parts without changing
|
||||
// message counts. Revalidate only bounded structural identities (not output
|
||||
// payloads); unchanged keys retain their array and keyed tool shells.
|
||||
store.getSessionRevision(id)
|
||||
|
||||
untrack(() => {
|
||||
const messageIds = store.getSessionMessageIds(id)
|
||||
|
||||
if (!indexedSessionId || indexedSessionId !== id) {
|
||||
fullRescanChildTools(id, messageIds)
|
||||
return
|
||||
}
|
||||
|
||||
// Detect structural changes (reorder/shrink) and fall back to a full rescan.
|
||||
if (messageIds.length < indexedMessageCount) {
|
||||
fullRescanChildTools(id, messageIds)
|
||||
return
|
||||
}
|
||||
if (indexedMessageCount > 0) {
|
||||
const expectedTailIndex = indexedMessageCount - 1
|
||||
if (expectedTailIndex >= 0 && messageIds[expectedTailIndex] !== indexedMessageTail) {
|
||||
fullRescanChildTools(id, messageIds)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const appendedKeys: string[] = []
|
||||
const budget = { remaining: TASK_MESSAGE_SCAN_LIMIT }
|
||||
|
||||
// Scan any new messages appended since last index.
|
||||
const appendedStart = Math.max(indexedMessageCount, messageIds.length - TASK_MESSAGE_SCAN_LIMIT)
|
||||
if (appendedStart > indexedMessageCount) setChildToolsTruncated(true)
|
||||
for (let idx = appendedStart; idx < messageIds.length && budget.remaining > 0; idx += 1) {
|
||||
const messageId = messageIds[idx]
|
||||
appendedKeys.push(...scanMessageToolParts(messageId, 0, TASK_STEP_RENDER_LIMIT, budget))
|
||||
if (appendedKeys.length > TASK_STEP_RENDER_LIMIT) {
|
||||
setChildToolsTruncated(true)
|
||||
appendedKeys.splice(0, appendedKeys.length - TASK_STEP_RENDER_LIMIT)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan the bounded indexed window so out-of-order updates are not missed.
|
||||
const existingCount = Math.min(indexedMessageCount, messageIds.length)
|
||||
const windowStart = Math.max(0, existingCount - TASK_MESSAGE_SCAN_LIMIT)
|
||||
for (let idx = windowStart; idx < existingCount && budget.remaining > 0; idx += 1) {
|
||||
const messageId = messageIds[idx]
|
||||
const previousPartCount = indexedPartCounts.get(messageId) ?? 0
|
||||
const record = store.getMessage(messageId)
|
||||
const nextPartCount = record?.partIds.length ?? 0
|
||||
if (nextPartCount > previousPartCount) {
|
||||
appendedKeys.push(...scanMessageToolParts(messageId, previousPartCount, TASK_STEP_RENDER_LIMIT, budget))
|
||||
if (appendedKeys.length > TASK_STEP_RENDER_LIMIT) {
|
||||
setChildToolsTruncated(true)
|
||||
appendedKeys.splice(0, appendedKeys.length - TASK_STEP_RENDER_LIMIT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
indexedMessageCount = messageIds.length
|
||||
indexedMessageTail = messageIds[messageIds.length - 1] ?? ""
|
||||
if (indexedPartCounts.size > TASK_MESSAGE_SCAN_LIMIT) {
|
||||
const retainedIds = new Set(messageIds.slice(-TASK_MESSAGE_SCAN_LIMIT))
|
||||
for (const messageId of indexedPartCounts.keys()) if (!retainedIds.has(messageId)) indexedPartCounts.delete(messageId)
|
||||
}
|
||||
|
||||
if (appendedKeys.length > 0) {
|
||||
if (childToolKeys().length + appendedKeys.length > TASK_STEP_RENDER_LIMIT) setChildToolsTruncated(true)
|
||||
setChildToolKeys((prev) => [...prev, ...appendedKeys].slice(-TASK_STEP_RENDER_LIMIT))
|
||||
}
|
||||
fullRescanChildTools(id, store.getSessionMessageIds(id))
|
||||
})
|
||||
})
|
||||
const promptContent = createMemo(() => {
|
||||
|
|
@ -591,12 +526,12 @@ export const taskRenderer: ToolRenderer = {
|
|||
}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<Index each={childToolKeys()}>
|
||||
<For each={childToolKeys()}>
|
||||
{(key) => (
|
||||
<Show when={renderToolCall}>
|
||||
{(render) => (
|
||||
<TaskToolCallRow
|
||||
toolKey={key()}
|
||||
toolKey={key}
|
||||
store={store}
|
||||
sessionId={childSessionId()}
|
||||
renderToolCall={render()}
|
||||
|
|
@ -604,7 +539,7 @@ export const taskRenderer: ToolRenderer = {
|
|||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Index>
|
||||
</For>
|
||||
</div>
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -79,7 +79,6 @@ interface UnifiedPickerProps {
|
|||
mode?: "mention" | "command"
|
||||
onSelect: (item: PickerItem, action: PickerSelectAction) => void
|
||||
onClose: () => void
|
||||
onSubmitWithoutSelection?: () => void
|
||||
agents: Agent[]
|
||||
commands?: CommandInfo[]
|
||||
searchQuery: string
|
||||
|
|
@ -414,12 +413,13 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||
} else if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// Solid delegates textarea key handlers at document too, so propagation
|
||||
// alone cannot keep modified submit shortcuts out of this listener.
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) return
|
||||
const selected = items[selectedIndex()]
|
||||
if (selected) {
|
||||
const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter"
|
||||
props.onSelect(selected, action)
|
||||
} else if (e.key === "Enter" && mode() === "mention") {
|
||||
props.onSubmitWithoutSelection?.()
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
|
|
|
|||
|
|
@ -14,6 +14,23 @@ const logger = {
|
|||
}
|
||||
|
||||
describe("attachEventSourceHandlers", () => {
|
||||
it("separates malformed data from callback failures without reconnect or replay", () => {
|
||||
const source = new FakeEventSource()
|
||||
const errors: string[] = []
|
||||
const received: unknown[] = []
|
||||
let reconnects = 0
|
||||
attachEventSourceHandlers(source as unknown as EventSource, {
|
||||
onEvent(event) { received.push(event); throw new Error("consumer failed") },
|
||||
onError() { reconnects++ },
|
||||
logger: { warn() {}, error(message) { errors.push(message) } },
|
||||
})
|
||||
source.onmessage!(new MessageEvent("message", { data: "malformed" }))
|
||||
source.onmessage!(new MessageEvent("message", { data: '{"type":"fixture"}' }))
|
||||
assert.deepEqual(errors, ["Failed to parse event", "Failed to dispatch event"])
|
||||
assert.deepEqual(received, [{ type: "fixture" }])
|
||||
assert.equal(reconnects, 0)
|
||||
})
|
||||
|
||||
it("requests reconnect when EventSource emits close", () => {
|
||||
const source = new FakeEventSource()
|
||||
let reconnects = 0
|
||||
|
|
|
|||
|
|
@ -20,11 +20,19 @@ export function attachEventSourceHandlers(source: EventSource, options: EventSou
|
|||
let disconnected = false
|
||||
|
||||
source.onmessage = (event) => {
|
||||
let payload: WorkspaceEventPayload
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as WorkspaceEventPayload
|
||||
options.onEvent(payload)
|
||||
payload = JSON.parse(event.data) as WorkspaceEventPayload
|
||||
} catch (error) {
|
||||
options.logger.error("Failed to parse event", error)
|
||||
return
|
||||
}
|
||||
try {
|
||||
options.onEvent(payload)
|
||||
} catch (error) {
|
||||
// Deferred reactive flush errors occur after individual subscribers have
|
||||
// returned. They are application failures, not malformed transport data.
|
||||
options.logger.error("Failed to dispatch event", error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
68
packages/ui/src/lib/server-events-lifecycle.test.ts
Normal file
68
packages/ui/src/lib/server-events-lifecycle.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { spawn, spawnSync } from "node:child_process"
|
||||
import { test } from "node:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const root = fileURLToPath(new URL("../../../../", import.meta.url))
|
||||
const deadlineMs = 30_000
|
||||
|
||||
// Supervise only these private subprocesses. A deadline is a test failure, not
|
||||
// successful cleanup: the imported application must release its own resources.
|
||||
function run(args: string[]): Promise<{ code: number | null; signal: string | null; timedOut: boolean; output: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Node's parent test-child marker otherwise suppresses a nested --test
|
||||
// runner. This child owns its own runner; require its complete test count.
|
||||
const env = { ...process.env }
|
||||
delete env.NODE_TEST_CONTEXT
|
||||
const child = spawn(process.execPath, args, {
|
||||
cwd: root,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
})
|
||||
let output = "", timedOut = false
|
||||
child.stdout.on("data", chunk => { output += chunk })
|
||||
child.stderr.on("data", chunk => { output += chunk })
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true
|
||||
if (!child.pid) return
|
||||
if (process.platform === "win32") {
|
||||
spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" })
|
||||
} else {
|
||||
process.kill(-child.pid, "SIGKILL")
|
||||
}
|
||||
}, deadlineMs)
|
||||
child.once("error", error => { clearTimeout(timeout); reject(error) })
|
||||
child.once("close", (code, signal) => {
|
||||
clearTimeout(timeout)
|
||||
resolve({ code, signal, timedOut, output })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function assertNaturalExit(result: Awaited<ReturnType<typeof run>>): void {
|
||||
assert.equal(result.timedOut, false, `private subprocess exceeded ${deadlineMs}ms; not a natural exit:\n${result.output}`)
|
||||
assert.equal(result.signal, null, result.output)
|
||||
assert.equal(result.code, 0, result.output)
|
||||
}
|
||||
|
||||
test("importing the actual serverEvents singleton without a browser exits naturally", async () => {
|
||||
const module = new URL("./server-events.ts", import.meta.url).href
|
||||
const result = await run(["--conditions=browser", "--import", "tsx", "--input-type=module", "--eval",
|
||||
`await import(${JSON.stringify(module)}); console.log("serverEvents import complete")`,
|
||||
])
|
||||
assert.match(result.output, /serverEvents import complete/)
|
||||
assertNaturalExit(result)
|
||||
})
|
||||
|
||||
test("the actual eleven compaction store tests pass and exit naturally without force-exit", async () => {
|
||||
const result = await run(["--conditions=browser", "--import", "tsx", "--test", "--test-reporter=tap",
|
||||
"packages/ui/src/stores/opencode-compaction.test.ts",
|
||||
])
|
||||
assertNaturalExit(result)
|
||||
assert.match(result.output, /^# tests 11$/m)
|
||||
assert.match(result.output, /^# pass 11$/m)
|
||||
assert.match(result.output, /^# fail 0$/m)
|
||||
assert.match(result.output, /^# cancelled 0$/m)
|
||||
assert.match(result.output, /^# skipped 0$/m)
|
||||
})
|
||||
|
|
@ -34,7 +34,7 @@ class ServerEvents {
|
|||
|
||||
constructor() {
|
||||
authRecovery.onRestored(() => this.restart("authentication restored"))
|
||||
void this.connect()
|
||||
if (typeof window !== "undefined") void this.connect()
|
||||
}
|
||||
|
||||
private async connect() {
|
||||
|
|
@ -72,7 +72,7 @@ class ServerEvents {
|
|||
}
|
||||
logSse("Events stream connected")
|
||||
this.retryDelay = RETRY_BASE_DELAY
|
||||
this.openHandlers.forEach((handler) => handler())
|
||||
this.notify(this.openHandlers, undefined, "open")
|
||||
},
|
||||
onPing: (payload) => {
|
||||
if (generation !== this.connectGeneration) {
|
||||
|
|
@ -149,8 +149,20 @@ class ServerEvents {
|
|||
}
|
||||
|
||||
private dispatch(event: WorkspaceEventPayload) {
|
||||
this.handlers.get("*")?.forEach((handler) => handler(event))
|
||||
this.handlers.get(event.type)?.forEach((handler) => handler(event))
|
||||
this.notify(this.handlers.get("*"), event, event.type)
|
||||
this.notify(this.handlers.get(event.type), event, event.type)
|
||||
}
|
||||
|
||||
private notify<T>(handlers: Set<(value: T) => void> | undefined, value: T, kind: string): void {
|
||||
handlers?.forEach((handler) => {
|
||||
try {
|
||||
handler(value)
|
||||
} catch (error) {
|
||||
// A feature failure must not discard another feature's native event or
|
||||
// interrupt the connection lifecycle. Do not replay failed handlers.
|
||||
log.error("Backend event subscriber failed", { kind, error })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private dispatchBatch(events: WorkspaceEventPayload[]) {
|
||||
|
|
@ -167,7 +179,7 @@ class ServerEvents {
|
|||
}
|
||||
|
||||
private emitTransportStatus(status: WorkspaceEventTransportStatus) {
|
||||
this.statusHandlers.forEach((handler) => handler(status))
|
||||
this.notify(this.statusHandlers, status, "transport-status")
|
||||
}
|
||||
|
||||
on(type: WorkspaceEventType | "*", handler: (event: WorkspaceEventPayload) => void): () => void {
|
||||
|
|
|
|||
56
packages/ui/src/stores/compaction-delta-buffer.ts
Normal file
56
packages/ui/src/stores/compaction-delta-buffer.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { OpenCodeEvent } from "@opencode/client"
|
||||
|
||||
type CompactionDelta = Extract<OpenCodeEvent, { type: "session.compaction.delta" }>
|
||||
const INTERVAL_MS = 250
|
||||
|
||||
// Only payload aggregation lives here. Revision/read authority remains with the
|
||||
// native reducer owner, which cancels or flushes this buffer at its boundaries.
|
||||
export function createCompactionDeltaBuffer<Context>(reduce: (event: CompactionDelta, context: Context) => void) {
|
||||
const pending = new Map<string, {
|
||||
event: CompactionDelta
|
||||
context: Context
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
}>()
|
||||
const keyFor = (instanceId: string, sessionId: string) => `${instanceId}\0${sessionId}`
|
||||
|
||||
const flush = (instanceId: string, sessionId: string) => {
|
||||
const key = keyFor(instanceId, sessionId)
|
||||
const item = pending.get(key)
|
||||
if (!item) return
|
||||
clearTimeout(item.timeout)
|
||||
pending.delete(key)
|
||||
reduce(item.event, item.context)
|
||||
}
|
||||
return {
|
||||
flush,
|
||||
push(instanceId: string, sessionId: string, event: CompactionDelta, context: Context) {
|
||||
const key = keyFor(instanceId, sessionId)
|
||||
const item = pending.get(key)
|
||||
if (item) {
|
||||
item.event = { ...event, data: { ...event.data, text: item.event.data.text + event.data.text } }
|
||||
item.context = context
|
||||
return
|
||||
}
|
||||
pending.set(key, {
|
||||
event,
|
||||
context,
|
||||
timeout: setTimeout(() => flush(instanceId, sessionId), INTERVAL_MS),
|
||||
})
|
||||
},
|
||||
cancel(instanceId: string, sessionId: string) {
|
||||
const key = keyFor(instanceId, sessionId)
|
||||
const item = pending.get(key)
|
||||
if (!item) return
|
||||
clearTimeout(item.timeout)
|
||||
pending.delete(key)
|
||||
},
|
||||
clear(instanceId: string) {
|
||||
const prefix = `${instanceId}\0`
|
||||
for (const [key, item] of pending) {
|
||||
if (!key.startsWith(prefix)) continue
|
||||
clearTimeout(item.timeout)
|
||||
pending.delete(key)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -139,51 +139,6 @@ const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null
|
|||
const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map())
|
||||
const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(new Map())
|
||||
|
||||
const COMPACTION_PROJECTION_INTERVAL_MS = 250
|
||||
const pendingCompactionProjections = new Map<string, {
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
project: () => void
|
||||
}>()
|
||||
|
||||
function compactionProjectionKey(instanceId: string, sessionId: string): string {
|
||||
return `${instanceId}\0${sessionId}`
|
||||
}
|
||||
|
||||
function scheduleCompactionProjection(instanceId: string, sessionId: string, project: () => void): void {
|
||||
const key = compactionProjectionKey(instanceId, sessionId)
|
||||
const pending = pendingCompactionProjections.get(key)
|
||||
if (pending) {
|
||||
pending.project = project
|
||||
return
|
||||
}
|
||||
|
||||
const next = {
|
||||
project,
|
||||
timeout: setTimeout(() => {
|
||||
pendingCompactionProjections.delete(key)
|
||||
next.project()
|
||||
}, COMPACTION_PROJECTION_INTERVAL_MS),
|
||||
}
|
||||
pendingCompactionProjections.set(key, next)
|
||||
}
|
||||
|
||||
function cancelCompactionProjection(instanceId: string, sessionId: string): void {
|
||||
const key = compactionProjectionKey(instanceId, sessionId)
|
||||
const pending = pendingCompactionProjections.get(key)
|
||||
if (!pending) return
|
||||
clearTimeout(pending.timeout)
|
||||
pendingCompactionProjections.delete(key)
|
||||
}
|
||||
|
||||
function clearCompactionProjections(instanceId: string): void {
|
||||
const prefix = `${instanceId}\0`
|
||||
for (const [key, pending] of pendingCompactionProjections) {
|
||||
if (!key.startsWith(prefix)) continue
|
||||
clearTimeout(pending.timeout)
|
||||
pendingCompactionProjections.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Interruption queues per instance
|
||||
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, PermissionRequest[]>>(new Map())
|
||||
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
||||
|
|
@ -1218,7 +1173,7 @@ function updateInstance(id: string, updates: Partial<Instance>) {
|
|||
clearSessionCatalogState(id)
|
||||
clearCommands(id)
|
||||
clearInstanceMetadata(id)
|
||||
clearCompactionProjections(id)
|
||||
destroyOpenCodeData(id)
|
||||
volatileInstanceRefreshes.delete(id)
|
||||
for (const sessionId of sessions().get(id)?.keys() ?? []) invalidateSessionMessageLoad(id, sessionId)
|
||||
}
|
||||
|
|
@ -1282,7 +1237,7 @@ function removeInstance(id: string, options: { authoritative?: boolean } = {}) {
|
|||
clearSettledForms(id)
|
||||
clearPendingFormQueue(id)
|
||||
clearInstanceMetadata(id)
|
||||
clearCompactionProjections(id)
|
||||
destroyOpenCodeData(id)
|
||||
clearPermissionAutoAcceptForInstance(id)
|
||||
clearSyncedYoloSessionsForInstance(id)
|
||||
initialHydrations.delete(id)
|
||||
|
|
@ -1953,10 +1908,6 @@ function handleInstanceInvalidation(instanceId: string, event: Parameters<NonNul
|
|||
? event.data.form.sessionID
|
||||
: undefined
|
||||
const isCompactionDelta = event.type === "session.compaction.delta"
|
||||
if (event.type === "server.connected") clearCompactionProjections(instanceId)
|
||||
if (sessionId && (event.type === "session.compaction.ended" || event.type === "session.compaction.failed")) {
|
||||
cancelCompactionProjection(instanceId, sessionId)
|
||||
}
|
||||
const projectMessages = (data: ReturnType<typeof applyOpenCodeDataEvent>, preserveOmitted = true, force = false) => {
|
||||
if (sessionId && event.type.startsWith("session.")
|
||||
&& activeSessionId().get(instanceId) !== sessionId
|
||||
|
|
@ -1964,7 +1915,6 @@ function handleInstanceInvalidation(instanceId: string, event: Parameters<NonNul
|
|||
invalidateSessionMessageLoad(instanceId, sessionId)
|
||||
}
|
||||
if (isCompactionDelta && !force) {
|
||||
if (sessionId) scheduleCompactionProjection(instanceId, sessionId, () => projectMessages(data, preserveOmitted, true))
|
||||
return
|
||||
}
|
||||
if (sessionId && (force || event.type.startsWith("session.")) && (
|
||||
|
|
@ -1992,10 +1942,14 @@ function handleInstanceInvalidation(instanceId: string, event: Parameters<NonNul
|
|||
}
|
||||
}
|
||||
}
|
||||
const data = applyOpenCodeDataEvent(instanceId, instance.folder, event, project, (next) => {
|
||||
const data = applyOpenCodeDataEvent(instanceId, instance.folder, event, (next) => {
|
||||
if (isCompactionDelta) projectMessages(next, true, true)
|
||||
else project(next)
|
||||
}, (next) => {
|
||||
projectMessages(next, false, true)
|
||||
schedulePendingRequestReconciliation(instanceId)
|
||||
})
|
||||
}, Boolean(sessionId && activeSessionId().get(instanceId) !== sessionId
|
||||
&& !messageStoreBus.getInstance(instanceId)?.getSessionMessageIds(sessionId).length))
|
||||
project(data)
|
||||
if (sessionId && (event.type === "permission.asked" || event.type === "permission.replied")) {
|
||||
const current = data.session.permission.list(sessionId) ?? []
|
||||
|
|
|
|||
171
packages/ui/src/stores/opencode-compaction.test.ts
Normal file
171
packages/ui/src/stores/opencode-compaction.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { sdkManager } from "../lib/sdk-manager.ts"
|
||||
import { sseManager } from "../lib/sse-manager.ts"
|
||||
import {
|
||||
applyOpenCodeDataEvent, destroyOpenCodeData, finishOpenCodeDataEvent,
|
||||
getOpenCodeMessageRevision, invalidateOpenCodeSessionContent,
|
||||
} from "./opencode-data.ts"
|
||||
|
||||
const model = { providerID: "fixture", id: "fixture" }
|
||||
function fixture(instanceId: string) {
|
||||
let sequence = 0, projections = 0
|
||||
const client: any = {
|
||||
session: { active: async () => ({}), get: async () => ({ id: "s" }),
|
||||
inbox: { list: async () => [] }, form: { list: async () => [] } },
|
||||
permission: { list: async () => [] },
|
||||
message: { list: async () => ({ data: [], cursor: {} }) },
|
||||
}
|
||||
;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client)
|
||||
let data!: ReturnType<typeof applyOpenCodeDataEvent>
|
||||
const emit = (type: string, payload: any = {}) => {
|
||||
data = applyOpenCodeDataEvent(instanceId, "/fixture", {
|
||||
type, id: `event-${++sequence}`, created: sequence,
|
||||
data: { sessionID: "s", ...payload }, location: { directory: "/fixture" },
|
||||
} as any, () => { projections++ })
|
||||
return data
|
||||
}
|
||||
return {
|
||||
emit, client, projections: () => projections,
|
||||
data: () => data,
|
||||
start: (id = "compact") => emit("session.compaction.started", { inputID: id, reason: "manual", recent: "" }),
|
||||
delta: (text: string) => emit("session.compaction.delta", { text }),
|
||||
summary: (id = "compact") => (data.session.message.get("s", id) as any)?.summary,
|
||||
cleanup: () => { destroyOpenCodeData(instanceId); sdkManager.destroyClientsForInstance(instanceId) },
|
||||
}
|
||||
}
|
||||
|
||||
test("compaction chunks are fenced immediately, joined once, and preserve same-session order", ctx => {
|
||||
ctx.mock.timers.enable({ apis: ["setTimeout"] })
|
||||
const f = fixture("compaction-order")
|
||||
try {
|
||||
f.start()
|
||||
const revision = getOpenCodeMessageRevision("compaction-order", "s")
|
||||
for (let index = 0; index < 128; index++) f.delta(String(index))
|
||||
assert.equal(getOpenCodeMessageRevision("compaction-order", "s"), revision + 128)
|
||||
assert.equal(f.summary(), "")
|
||||
assert.equal(f.projections(), 0)
|
||||
ctx.mock.timers.tick(250)
|
||||
assert.equal(f.summary(), Array.from({ length: 128 }, (_, i) => String(i)).join(""))
|
||||
assert.equal(f.projections(), 1)
|
||||
f.delta("prefix")
|
||||
f.start("second")
|
||||
assert(f.summary()!.endsWith("prefix"), "second start flushes preceding running compaction")
|
||||
f.delta("second text")
|
||||
ctx.mock.timers.tick(250)
|
||||
assert.equal(f.summary("second"), "second text")
|
||||
assert.equal(f.projections(), 3)
|
||||
f.delta("pending")
|
||||
f.emit("session.compaction.ended", { reason: "manual", text: "final", recent: "" })
|
||||
ctx.mock.timers.tick(500)
|
||||
assert.equal(f.summary("second"), "final")
|
||||
assert.equal(f.projections(), 3, "ended supersedes buffered fragments without replay")
|
||||
} finally { f.cleanup() }
|
||||
})
|
||||
|
||||
for (const boundary of ["failed", "delete", "reconnect", "prune", "revert", "dispose"] as const) {
|
||||
test(`pending compaction text cannot survive ${boundary}`, ctx => {
|
||||
ctx.mock.timers.enable({ apis: ["setTimeout"] })
|
||||
const instanceId = `compaction-${boundary}`
|
||||
const f = fixture(instanceId)
|
||||
try {
|
||||
f.start()
|
||||
f.delta("must not reappear")
|
||||
if (boundary === "failed") f.emit("session.compaction.failed", { reason: "manual", error: { type: "fixture", message: "failed" } })
|
||||
if (boundary === "delete") f.emit("session.deleted")
|
||||
if (boundary === "reconnect") f.emit("server.connected")
|
||||
if (boundary === "prune") invalidateOpenCodeSessionContent(instanceId, "s")
|
||||
if (boundary === "revert") f.emit("session.revert.committed", { to: "compact" })
|
||||
if (boundary === "dispose") destroyOpenCodeData(instanceId)
|
||||
ctx.mock.timers.tick(500)
|
||||
assert.equal(f.projections(), 0)
|
||||
if (boundary === "revert" || boundary === "delete") assert.equal(f.summary(), undefined)
|
||||
f.start("fresh")
|
||||
f.delta("new")
|
||||
ctx.mock.timers.tick(250)
|
||||
if (boundary !== "prune") assert.equal(f.summary("fresh"), "new")
|
||||
} finally { f.cleanup() }
|
||||
})
|
||||
}
|
||||
|
||||
test("idle flushes pending compaction before retiring its reducer", ctx => {
|
||||
ctx.mock.timers.enable({ apis: ["setTimeout"] })
|
||||
const f = fixture("compaction-idle")
|
||||
try {
|
||||
f.start()
|
||||
f.delta("last text")
|
||||
f.emit("session.idle")
|
||||
assert.equal(f.summary(), "last text")
|
||||
finishOpenCodeDataEvent("compaction-idle", { type: "session.idle", data: { sessionID: "s" } } as any)
|
||||
ctx.mock.timers.tick(500)
|
||||
assert.equal(f.projections(), 1)
|
||||
} finally { f.cleanup() }
|
||||
})
|
||||
|
||||
test("same session IDs in distinct instances keep separate buffers and flush authority", ctx => {
|
||||
ctx.mock.timers.enable({ apis: ["setTimeout"] })
|
||||
const first = fixture("compaction-instance-first"), second = fixture("compaction-instance-second")
|
||||
try {
|
||||
first.start(); second.start()
|
||||
first.delta("A"); second.delta("B")
|
||||
first.emit("session.usage.updated", { cost: 0, tokens: {} })
|
||||
assert.equal(first.summary(), "A")
|
||||
assert.equal(second.summary(), "")
|
||||
ctx.mock.timers.tick(250)
|
||||
assert.equal(second.summary(), "B")
|
||||
assert.equal(first.projections(), 1)
|
||||
assert.equal(second.projections(), 1)
|
||||
} finally { first.cleanup(); second.cleanup() }
|
||||
})
|
||||
|
||||
test("SDK history reads flush pending chunks and reject a pre-delta page without duplication", async () => {
|
||||
const instanceId = "compaction-native-read"
|
||||
const previous = sseManager.getStatuses
|
||||
sseManager.getStatuses = () => new Map([[instanceId, "connected"]])
|
||||
const f = fixture(instanceId)
|
||||
const replies: Array<(value: any) => void> = []
|
||||
const page = (summary: string) => ({ data: [{ id: "compact", type: "compaction", status: "running",
|
||||
reason: "manual", summary, recent: "", model, time: { created: 1 } }], cursor: {} })
|
||||
f.client.message.list = () => new Promise(resolve => replies.push(resolve))
|
||||
try {
|
||||
const data = f.start()
|
||||
const read = data.session.message.sync("s")
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.equal(replies.length, 1)
|
||||
f.delta("A")
|
||||
replies[0](page(""))
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.equal(replies.length, 2, "admitted buffered delta fences stale SDK read")
|
||||
replies[1](page("A"))
|
||||
await read
|
||||
assert.equal(f.summary(), "A", "authoritative page must not double-append buffered text")
|
||||
f.delta("B")
|
||||
data.session.message.invalidate("s")
|
||||
const nextRead = data.session.message.sync("s")
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.equal(f.summary(), "AB", "pending text is reduced before a new native read")
|
||||
replies[2](page("AB"))
|
||||
await nextRead
|
||||
assert.equal(f.summary(), "AB")
|
||||
assert.equal(f.projections(), 2)
|
||||
} finally { f.cleanup(); sseManager.getStatuses = previous }
|
||||
})
|
||||
|
||||
test("rotation at the 200-message boundary retains exactly one running compaction and its pending chunks", async () => {
|
||||
const f = fixture("compaction-rotation")
|
||||
try {
|
||||
for (let index = 0; index < 200; index++) {
|
||||
f.emit("session.step.started", { assistantMessageID: `m${index}`, agent: "build", model })
|
||||
f.emit("session.step.ended", { assistantMessageID: `m${index}`, finish: "stop" })
|
||||
}
|
||||
f.start()
|
||||
f.delta("A"); f.delta("B")
|
||||
// Same-session terminal delivery forces native order while the start is
|
||||
// still queued behind the bounded SDK snapshot rotation.
|
||||
f.emit("session.usage.updated", { cost: 0, tokens: {} })
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.equal(f.summary(), "AB")
|
||||
assert.equal(f.data().session.message.list("s").length, 200)
|
||||
assert.equal(f.data().session.message.list("s").filter(message => message.type === "compaction").length, 1)
|
||||
} finally { f.cleanup() }
|
||||
})
|
||||
|
|
@ -8,12 +8,14 @@ import { MESSAGE_WINDOW_PAGE_SIZE } from "./message-v2/message-window"
|
|||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { createCompactionDeltaBuffer } from "./compaction-delta-buffer"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
type DataEntry = {
|
||||
data: Data
|
||||
emit: (event: OpenCodeEvent) => void
|
||||
fencePendingReads: (sessionId: string) => void
|
||||
syncMessages: (sessionId: string, messages: SessionMessageInfo[], isCurrent: () => boolean) => Promise<boolean>
|
||||
syncAuthoritative: (sessionId: string, isCurrent: () => boolean) => Promise<boolean>
|
||||
dispose: () => void
|
||||
|
|
@ -57,6 +59,18 @@ const messageRevisions = new Map<string, number>()
|
|||
const fullDataRevisions = new Map<string, number>()
|
||||
const instanceGenerations = new Map<string, number>()
|
||||
const instanceDataRevisions = new Map<string, ReturnType<typeof createSignal<number>>>()
|
||||
const unobservedCompactions = new Set<string>()
|
||||
type CompactionDeltaContext = {
|
||||
instanceId: string
|
||||
directory: string
|
||||
onDeferred?: (data: Data) => void
|
||||
onResynced?: (data: Data) => void
|
||||
}
|
||||
const coalescedCompactionEvents = new WeakSet<object>()
|
||||
const compactionDeltas = createCompactionDeltaBuffer<CompactionDeltaContext>((event, context) => {
|
||||
coalescedCompactionEvents.add(event)
|
||||
applyOpenCodeDataEvent(context.instanceId, context.directory, event, context.onDeferred, context.onResynced)
|
||||
})
|
||||
let nextInstanceGeneration = 0
|
||||
|
||||
function messageRevisionKey(instanceId: string, sessionId: string): string {
|
||||
|
|
@ -130,6 +144,10 @@ function createDataEntry(instanceId: string, directory: string): DataEntry {
|
|||
if (disposed || getOpenCodeInstanceGeneration(instanceId) !== instanceGeneration) {
|
||||
throw new Error("Stale read from disposed OpenCode projection")
|
||||
}
|
||||
// A current native page can already contain buffered text.
|
||||
// Reduce it before taking read authority, never append it
|
||||
// again to a page that was fetched after delta admission.
|
||||
compactionDeltas.flush(instanceId, input.sessionID)
|
||||
const snapshot = messageSnapshots.get(input.sessionID)
|
||||
if (snapshot) return { data: [...snapshot].reverse(), cursor: {} }
|
||||
const revision = eventRevisions.get(input.sessionID) ?? 0
|
||||
|
|
@ -137,6 +155,7 @@ function createDataEntry(instanceId: string, directory: string): DataEntry {
|
|||
if (disposed || getOpenCodeInstanceGeneration(instanceId) !== instanceGeneration) {
|
||||
throw new Error("Stale read from disposed OpenCode projection")
|
||||
}
|
||||
compactionDeltas.flush(instanceId, input.sessionID)
|
||||
if ((eventRevisions.get(input.sessionID) ?? 0) !== revision) continue
|
||||
return response
|
||||
}
|
||||
|
|
@ -167,6 +186,9 @@ function createDataEntry(instanceId: string, directory: string): DataEntry {
|
|||
emit(details: OpenCodeEvent) {
|
||||
emit(details)
|
||||
},
|
||||
fencePendingReads(sessionId: string) {
|
||||
eventRevisions.set(sessionId, (eventRevisions.get(sessionId) ?? 0) + 1)
|
||||
},
|
||||
async syncMessages(sessionId: string, messages: SessionMessageInfo[], isCurrent: () => boolean) {
|
||||
messageSnapshots.set(sessionId, messages)
|
||||
try {
|
||||
|
|
@ -573,6 +595,7 @@ export function applyOpenCodeDataEvent(
|
|||
event: OpenCodeEvent,
|
||||
onDeferred?: (data: Data) => void,
|
||||
onResynced?: (data: Data) => void,
|
||||
skipUnobservedCompaction = false,
|
||||
): Data {
|
||||
if (event.type === "server.connected") destroyOpenCodeData(instanceId)
|
||||
const primary = ensureData(instanceId, directory)
|
||||
|
|
@ -581,6 +604,20 @@ export function applyOpenCodeDataEvent(
|
|||
// with sync:false, feeding these events to createData starts HTTP refreshes.
|
||||
if (!/^(session|permission|form)\./.test(event.type)) return primary.data
|
||||
const sessionId = eventSessionId(event)
|
||||
const coalesced = coalescedCompactionEvents.delete(event)
|
||||
if (typeof sessionId === "string" && !coalesced) {
|
||||
if (event.type === "session.compaction.ended" || event.type === "session.compaction.failed"
|
||||
|| event.type === "session.deleted" || event.type === "session.revert.committed") {
|
||||
// Terminal events replace summary text. Destructive events must not let
|
||||
// a delayed fragment resurrect a removed or invalidated compaction.
|
||||
compactionDeltas.cancel(instanceId, sessionId)
|
||||
unobservedCompactions.delete(messageRevisionKey(instanceId, sessionId))
|
||||
} else if (event.type !== "session.compaction.delta") {
|
||||
// A second start or another same-session event observes preceding text
|
||||
// in native order; unrelated sessions never force a flush.
|
||||
compactionDeltas.flush(instanceId, sessionId)
|
||||
}
|
||||
}
|
||||
if (event.type === "session.deleted" && typeof sessionId === "string") {
|
||||
const key = messageRevisionKey(instanceId, sessionId)
|
||||
const transcript = transcriptEntries.get(key)
|
||||
|
|
@ -595,24 +632,49 @@ export function applyOpenCodeDataEvent(
|
|||
fullDataRevisions.delete(key)
|
||||
return transcript?.entry.data ?? primary.data
|
||||
}
|
||||
if (typeof sessionId === "string" && !coalesced) {
|
||||
const key = messageRevisionKey(instanceId, sessionId)
|
||||
fullDataRevisions.set(key, (fullDataRevisions.get(key) ?? 0) + 1)
|
||||
if (eventAffectsMessages(event)) messageRevisions.set(key, (messageRevisions.get(key) ?? 0) + 1)
|
||||
if (event.type === "session.inbox.cancelled" || event.type === "session.revert.committed") bumpMutationRevision(key)
|
||||
if (skipUnobservedCompaction && event.type.startsWith("session.compaction.") && !transcriptEntries.has(key)) {
|
||||
// No native reducer/visible transcript is observing this session. The
|
||||
// next activation loads authoritative messages instead of storing deltas.
|
||||
if (event.type === "session.compaction.started") unobservedCompactions.add(key)
|
||||
if (event.type === "session.compaction.ended" || event.type === "session.compaction.failed") unobservedCompactions.delete(key)
|
||||
return primary.data
|
||||
}
|
||||
}
|
||||
const transcript = typeof sessionId === "string"
|
||||
? ensureTranscript(instanceId, sessionId, directory)
|
||||
: undefined
|
||||
const entry = transcript?.entry ?? primary
|
||||
if (transcript && typeof sessionId === "string") {
|
||||
if (event.type === "session.compaction.started") unobservedCompactions.delete(messageRevisionKey(instanceId, sessionId))
|
||||
// A new execution must not inherit a preceding idle event's deferred cleanup.
|
||||
if (event.type === "session.execution.started" || eventMayAppendMessage(event)) transcript.retireWhenDrained = false
|
||||
const key = messageRevisionKey(instanceId, sessionId)
|
||||
fullDataRevisions.set(key, (fullDataRevisions.get(key) ?? 0) + 1)
|
||||
if (eventAffectsMessages(event)) messageRevisions.set(key, (messageRevisions.get(key) ?? 0) + 1)
|
||||
if (event.type === "session.inbox.cancelled" || event.type === "session.revert.committed") bumpMutationRevision(key)
|
||||
}
|
||||
if (transcript && typeof sessionId === "string") {
|
||||
if (onResynced) transcript.onResynced = onResynced
|
||||
if (event.type === "session.compaction.delta" && unobservedCompactions.delete(messageRevisionKey(instanceId, sessionId))) {
|
||||
// Activation during a previously unobserved compaction has no start row
|
||||
// in the SDK reducer. Recover its exact native summary instead of making
|
||||
// up a start or appending a delta to a REST page that already includes it.
|
||||
transcript.preserveNativePageOnResync = true
|
||||
collapseTranscriptQueue(instanceId, sessionId, transcript)
|
||||
return transcript.entry.data
|
||||
}
|
||||
if (transcript.needsAuthoritativeResync || transcript.resyncing) {
|
||||
collapseTranscriptQueue(instanceId, sessionId, transcript)
|
||||
return transcript.entry.data
|
||||
}
|
||||
if (event.type === "session.compaction.delta" && !coalesced) {
|
||||
// Admission fences both CodeNomad and SDK reads immediately, even though
|
||||
// the reactive payload is reduced only once per interval.
|
||||
entry.fencePendingReads(sessionId)
|
||||
compactionDeltas.push(instanceId, sessionId, event, { instanceId, directory, onDeferred, onResynced })
|
||||
return entry.data
|
||||
}
|
||||
if (transcript.rotating
|
||||
|| (eventMayAppendMessage(event, transcript.entry.data.session.message.list(sessionId)
|
||||
.filter((message) => message.type === "compaction" && message.status === "running").length)
|
||||
|
|
@ -623,6 +685,7 @@ export function applyOpenCodeDataEvent(
|
|||
}
|
||||
}
|
||||
entry.emit(event)
|
||||
if (coalesced) onDeferred?.(entry.data)
|
||||
return entry.data
|
||||
}
|
||||
|
||||
|
|
@ -631,6 +694,8 @@ export function getOpenCodeMessageRevision(instanceId: string, sessionId: string
|
|||
}
|
||||
|
||||
export function invalidateOpenCodeSessionContent(instanceId: string, sessionId: string): void {
|
||||
compactionDeltas.cancel(instanceId, sessionId)
|
||||
unobservedCompactions.delete(messageRevisionKey(instanceId, sessionId))
|
||||
const key = messageRevisionKey(instanceId, sessionId)
|
||||
bumpMutationRevision(key)
|
||||
messageRevisions.set(key, (messageRevisions.get(key) ?? 0) + 1)
|
||||
|
|
@ -720,6 +785,8 @@ export function finishOpenCodeDataEvent(instanceId: string, event: OpenCodeEvent
|
|||
|
||||
export function destroyOpenCodeData(instanceId: string, sessionId?: string): void {
|
||||
if (sessionId !== undefined) {
|
||||
compactionDeltas.cancel(instanceId, sessionId)
|
||||
unobservedCompactions.delete(messageRevisionKey(instanceId, sessionId))
|
||||
const key = messageRevisionKey(instanceId, sessionId)
|
||||
const transcript = transcriptEntries.get(key)
|
||||
if (transcript) {
|
||||
|
|
@ -734,6 +801,10 @@ export function destroyOpenCodeData(instanceId: string, sessionId?: string): voi
|
|||
instanceDataRevision(instanceId)[1]((current) => current + 1)
|
||||
return
|
||||
}
|
||||
compactionDeltas.clear(instanceId)
|
||||
for (const key of unobservedCompactions) {
|
||||
if (key.startsWith(`${instanceId}\0`)) unobservedCompactions.delete(key)
|
||||
}
|
||||
instanceGenerations.set(instanceId, ++nextInstanceGeneration)
|
||||
entries.get(instanceId)?.dispose()
|
||||
entries.delete(instanceId)
|
||||
|
|
|
|||
|
|
@ -13,9 +13,22 @@ test("renderer SSE reconnect invalidates every cached worktree without reading h
|
|||
sources.push(source)
|
||||
return source
|
||||
})
|
||||
// This fixture models a web renderer, not the intentionally passive Node
|
||||
// import. No native bridge or network globals are needed by the mock above.
|
||||
const windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window")
|
||||
t.after(() => {
|
||||
sources.forEach((source) => source.close())
|
||||
if (windowDescriptor) Object.defineProperty(globalThis, "window", windowDescriptor)
|
||||
else Reflect.deleteProperty(globalThis, "window")
|
||||
assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "window"), windowDescriptor)
|
||||
})
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: {} })
|
||||
const { serverEvents } = await import("../lib/server-events")
|
||||
await import("./plugin-controls-events")
|
||||
await tick()
|
||||
assert.equal(sources.length, 1, "the renderer auto-connects through the mocked browser transport")
|
||||
assert.equal(sources[0].closed, false)
|
||||
assert.equal(typeof sources[0].onopen, "function")
|
||||
sources[0].onopen!()
|
||||
let nativeStatuses = 0
|
||||
t.after(serverEvents.on("instance.eventStatus", () => { nativeStatuses++ }))
|
||||
|
|
|
|||
145
packages/ui/tests/browser/compaction-responsiveness.test.ts
Normal file
145
packages/ui/tests/browser/compaction-responsiveness.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { after, before, test } from "node:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { chromium, type Browser, type Page } from "playwright"
|
||||
import { createServer, type ViteDevServer } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
|
||||
let server: ViteDevServer, browser: Browser, url: string
|
||||
before(async () => {
|
||||
server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error",
|
||||
plugins: [solid(), {
|
||||
name: "compaction-counts",
|
||||
enforce: "pre",
|
||||
async transform(source, id) {
|
||||
if (id.endsWith("/stores/instances.ts") && process.env.CODENOMAD_COMPACTION_BASELINE) {
|
||||
return readFile(`${process.env.CODENOMAD_COMPACTION_BASELINE}/instances.ts`, "utf8")
|
||||
}
|
||||
if (!id.endsWith("/stores/opencode-data.ts")) return
|
||||
if (process.env.CODENOMAD_COMPACTION_BASELINE) source = await readFile(`${process.env.CODENOMAD_COMPACTION_BASELINE}/opencode-data.ts`, "utf8")
|
||||
// Observe the actual native reducer boundary, not a reimplemented reducer.
|
||||
return source.replace("for (const listener of listeners)", `
|
||||
if (details.type === "session.compaction.delta") {
|
||||
const counts = (window as any).compactionCounts?.deltas
|
||||
if (counts) counts[details.data.sessionID] = (counts[details.data.sessionID] ?? 0) + 1
|
||||
}
|
||||
for (const listener of listeners)`)
|
||||
.replace("const source = data.session.message.list(sessionId)", `
|
||||
const counts = (window as any).compactionCounts?.projections
|
||||
if (counts) counts[sessionId] = (counts[sessionId] ?? 0) + 1
|
||||
const source = data.session.message.list(sessionId)`)
|
||||
},
|
||||
configureServer(vite) {
|
||||
vite.middlewares.use("/compaction", async (_req, res) => {
|
||||
res.setHeader("Content-Type", "text/html")
|
||||
res.end(await vite.transformIndexHtml("/compaction", '<html><body><div id="root" style="display:flex;width:1100px;height:740px"></div><script type="module" src="/tests/browser/fixtures/compaction-responsiveness.tsx"></script></body></html>'))
|
||||
})
|
||||
},
|
||||
}], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] },
|
||||
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null },
|
||||
})
|
||||
await server.listen()
|
||||
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/compaction`
|
||||
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
|
||||
})
|
||||
after(async () => { await browser?.close(); await server?.close() })
|
||||
|
||||
async function fixture(run: (page: Page) => Promise<void>) {
|
||||
const page = await browser.newPage({ viewport: { width: 1200, height: 800 } })
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", error => errors.push(error.message))
|
||||
await page.route("**/*", route => {
|
||||
const request = new URL(route.request().url())
|
||||
if (request.hostname !== "127.0.0.1") return route.abort()
|
||||
if (request.pathname.startsWith("/api/")) return route.fulfill({ json: {} })
|
||||
return route.continue()
|
||||
})
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 })
|
||||
await page.waitForFunction(() => Boolean((window as any).compactionFixture), undefined, { timeout: 30_000 })
|
||||
await page.waitForTimeout(300)
|
||||
await run(page)
|
||||
assert.deepEqual(errors, [])
|
||||
} finally { await page.close() }
|
||||
}
|
||||
const snapshot = (page: Page) => page.evaluate(() => (window as any).compactionFixture.snapshot())
|
||||
const joined = (count: number) => Array.from({ length: count }, (_, index) => `[${index}]`).join("")
|
||||
|
||||
test("128 active deltas reduce and project once, preserve immediate revision, and end authoritatively", () => fixture(async page => {
|
||||
await page.evaluate(() => { const f = (window as any).compactionFixture; f.start(); f.resetCounts() })
|
||||
const before = await snapshot(page)
|
||||
const dispatchMs = await page.evaluate(() => (window as any).compactionFixture.burst("active"))
|
||||
const pending = await snapshot(page)
|
||||
console.log(JSON.stringify({ scenario: "active128-ingress", dispatchMs, counts: pending.counts }))
|
||||
assert.equal(pending.revision - before.revision, 128)
|
||||
assert.equal(pending.counts.deltas.active ?? 0, 0)
|
||||
assert.equal(pending.counts.projections.active ?? 0, 0)
|
||||
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().active?.length > 0)
|
||||
const flushed = await snapshot(page)
|
||||
assert.equal(flushed.active, joined(128))
|
||||
assert.equal(flushed.counts.deltas.active, 1)
|
||||
assert.equal(flushed.counts.projections.active, 1)
|
||||
assert.equal(flushed.messageLists, 0)
|
||||
console.log(JSON.stringify({ scenario: "active128", dispatchMs, counts: flushed.counts }))
|
||||
await page.evaluate(() => { const f = (window as any).compactionFixture; f.burst("active", 4); f.end("active", "authoritative final") })
|
||||
await page.waitForTimeout(300)
|
||||
const final = await snapshot(page)
|
||||
assert.equal(final.active, "authoritative final")
|
||||
assert.equal(final.counts.deltas.active, 1, "terminal final text cancels pending fragments")
|
||||
}))
|
||||
|
||||
test("spaced native events coalesce per interval without losing chunks or affecting the other session", () => fixture(async page => {
|
||||
await page.evaluate(() => { const f = (window as any).compactionFixture; f.start(); f.resetCounts() })
|
||||
for (let phase = 1; phase <= 2; phase++) {
|
||||
await page.evaluate(() => (window as any).compactionFixture.phase("active"))
|
||||
await page.waitForFunction(phase => (window as any).compactionFixture.snapshot().active ===
|
||||
Array.from({ length: 8 }, (_, index) => `[${index}]`).join("").repeat(phase), phase)
|
||||
const state = await snapshot(page)
|
||||
assert.equal(state.counts.deltas.active, phase)
|
||||
assert.equal(state.counts.projections.active, phase)
|
||||
assert.equal(state.messageLists, 0)
|
||||
}
|
||||
}))
|
||||
|
||||
test("never-loaded inactive compaction allocates no reducer payload; activation and remount hydrate exact final text", () => fixture(async page => {
|
||||
await page.evaluate(() => { const f = (window as any).compactionFixture; f.resetCounts(); f.start("inactive"); f.burst("inactive"); f.end("inactive", "saved summary") })
|
||||
await page.waitForTimeout(300)
|
||||
const inactive = await snapshot(page)
|
||||
assert.deepEqual(inactive.counts, { deltas: {}, projections: {} })
|
||||
assert.equal(inactive.inactive, undefined)
|
||||
assert.equal(inactive.messageLists, 0)
|
||||
await page.evaluate(() => (window as any).compactionFixture.activate("inactive"))
|
||||
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().inactive === "saved summary")
|
||||
await page.evaluate(() => (window as any).compactionFixture.unmount())
|
||||
await page.evaluate(() => (window as any).compactionFixture.remount())
|
||||
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().inactive === "saved summary")
|
||||
assert.equal((await snapshot(page)).active, undefined)
|
||||
}))
|
||||
|
||||
test("activation during an unobserved compaction hydrates then resumes exact streaming summary", () => fixture(async page => {
|
||||
await page.evaluate(() => { const f = (window as any).compactionFixture; f.seed("inactive", 199); f.start("inactive"); f.burst("inactive", 4); f.activate("inactive") })
|
||||
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().inactive === "[0][1][2][3]")
|
||||
await page.evaluate(() => (window as any).compactionFixture.burst("inactive", 2))
|
||||
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().inactive === "[0][1][2][3][0][1]")
|
||||
await page.evaluate(() => { const f = (window as any).compactionFixture; f.resetCounts(); f.burst("inactive", 2) })
|
||||
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().inactive === "[0][1][2][3][0][1][0][1]")
|
||||
const resumed = await snapshot(page)
|
||||
assert.equal(resumed.counts.deltas.inactive, 1)
|
||||
assert.equal(resumed.counts.projections.inactive, 1)
|
||||
assert.equal(resumed.messageLists, 0)
|
||||
assert.equal(resumed.ids.length, 200, "SDK's 20-row recovery must retain the hydrated native 200-row window")
|
||||
}))
|
||||
|
||||
test("a loaded but inactive reducer batches text, does not hydrate its hidden view, and returns without duplication", () => fixture(async page => {
|
||||
await page.evaluate(() => { const f = (window as any).compactionFixture; f.start(); f.activate("inactive"); f.resetCounts(); f.burst("active", 4) })
|
||||
await page.waitForTimeout(300)
|
||||
const hidden = await snapshot(page)
|
||||
assert.equal(hidden.counts.deltas.active, 1)
|
||||
assert.equal(hidden.counts.projections.active ?? 0, 0)
|
||||
assert.equal(hidden.active, "", "hidden visible snapshot is revalidated, not hydrated by every delta")
|
||||
await page.evaluate(() => (window as any).compactionFixture.activate("active"))
|
||||
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().active === "[0][1][2][3]")
|
||||
await page.evaluate(() => (window as any).compactionFixture.burst("active", 2))
|
||||
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().active === "[0][1][2][3][0][1]")
|
||||
}))
|
||||
172
packages/ui/tests/browser/composer-picker-enter.test.ts
Normal file
172
packages/ui/tests/browser/composer-picker-enter.test.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { after, before, test } from "node:test"
|
||||
import { chromium, type Browser, type Route } from "playwright"
|
||||
import type { ViteDevServer } from "vite"
|
||||
import { startDeviceUploadFixture } from "./fixtures/device-upload-server.mjs"
|
||||
|
||||
let server: ViteDevServer, browser: Browser, url: string
|
||||
before(async () => {
|
||||
;({ server, url } = await startDeviceUploadFixture())
|
||||
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
|
||||
})
|
||||
after(async () => { await browser?.close(); await server?.close() })
|
||||
|
||||
async function setup(submitOnEnter = true) {
|
||||
const page = await browser.newPage({ viewport: { width: 1100, height: 800 }, locale: "en-US",
|
||||
permissions: ["clipboard-read", "clipboard-write"] })
|
||||
const prompts: unknown[] = [], searches: Route[] = [], errors: string[] = []
|
||||
page.on("pageerror", error => errors.push(error.message))
|
||||
await page.addInitScript(() => {
|
||||
;(window as any).__CODENOMAD_RUNTIME_HOST__ = "tauri"
|
||||
;(window as any).__CODENOMAD_WINDOW_CONTEXT__ = "local"
|
||||
})
|
||||
await page.route("**/api/**", route => {
|
||||
const target = new URL(route.request().url())
|
||||
if (target.pathname.endsWith("/prompt")) {
|
||||
prompts.push(route.request().postDataJSON())
|
||||
return route.fulfill({ json: { data: {} } })
|
||||
}
|
||||
if (/\/api\/workspaces\/[^/]+\/files(?:\/search)?$/.test(target.pathname)) {
|
||||
searches.push(route)
|
||||
return
|
||||
}
|
||||
return route.fulfill({ json: {} })
|
||||
})
|
||||
await page.goto(url)
|
||||
await page.waitForFunction(() => Boolean((window as any).fixture))
|
||||
await page.evaluate(value => (window as any).fixture.submitOnEnter(value), submitOnEnter)
|
||||
const input = page.locator("textarea:visible").first()
|
||||
const picker = page.locator(".dropdown-surface")
|
||||
const waitSearch = async (count: number) => {
|
||||
const deadline = Date.now() + 5000
|
||||
while (searches.length < count && Date.now() < deadline) await page.waitForTimeout(10)
|
||||
assert.equal(searches.length, count)
|
||||
}
|
||||
const assertDraft = async (draft: string) => {
|
||||
await page.waitForTimeout(50)
|
||||
assert.equal(await input.inputValue(), draft)
|
||||
assert.deepEqual(prompts, [], "no native prompt may be dispatched by picker keys")
|
||||
assert.deepEqual(errors, [])
|
||||
}
|
||||
return { page, input, picker, searches, prompts, errors, waitSearch, assertDraft }
|
||||
}
|
||||
|
||||
for (const submitOnEnter of [true, false]) {
|
||||
test(`empty and pending mention results never send, including modifiers (submitOnEnter=${submitOnEnter})`, async () => {
|
||||
const f = await setup(submitOnEnter)
|
||||
try {
|
||||
await f.input.fill("@missing/path")
|
||||
await f.picker.waitFor()
|
||||
await f.waitSearch(1)
|
||||
await f.input.press("Enter")
|
||||
await f.assertDraft("@missing/path")
|
||||
await f.searches[0].fulfill({ json: [] })
|
||||
await f.page.getByText("No results found", { exact: true }).waitFor()
|
||||
for (const key of ["Enter", "Shift+Enter", "Control+Enter", "Meta+Enter", "Control+Shift+Enter", "Meta+Shift+Enter"]) {
|
||||
await f.input.press(key)
|
||||
await f.assertDraft("@missing/path")
|
||||
assert.equal(await f.picker.count(), 1)
|
||||
}
|
||||
} finally { await f.page.close() }
|
||||
})
|
||||
}
|
||||
|
||||
test("Ctrl+V extends an ignored picker query without sending; empty-result Enter still cannot send", async () => {
|
||||
const f = await setup()
|
||||
try {
|
||||
await f.input.fill("@missing")
|
||||
await f.picker.waitFor(); await f.waitSearch(1)
|
||||
await f.searches[0].fulfill({ json: [] })
|
||||
await f.page.evaluate(() => navigator.clipboard.writeText("/pasted/path"))
|
||||
await f.input.press("Control+v")
|
||||
await f.waitSearch(2)
|
||||
await f.assertDraft("@missing/pasted/path")
|
||||
await f.searches[1].fulfill({ json: [] })
|
||||
await f.input.press("Enter")
|
||||
await f.assertDraft("@missing/pasted/path")
|
||||
await f.input.press("Escape")
|
||||
await f.picker.waitFor({ state: "detached" })
|
||||
await f.assertDraft("@missing/pasted/path")
|
||||
await f.input.press("Enter")
|
||||
await f.page.waitForFunction(() => document.querySelector<HTMLTextAreaElement>("textarea")?.value === "")
|
||||
assert.equal(f.prompts.length, 1, "explicit submission after dismissal remains available")
|
||||
assert.deepEqual(f.errors, [])
|
||||
} finally { await f.page.close() }
|
||||
})
|
||||
|
||||
test("Tab autocomplete and Enter attach a selected file without sending; the next Enter sends", async () => {
|
||||
const f = await setup()
|
||||
try {
|
||||
await f.input.fill("@server")
|
||||
await f.picker.waitFor(); await f.waitSearch(1)
|
||||
await f.searches[0].fulfill({ json: [{ name: "server.txt", path: "server.txt", type: "file" }] })
|
||||
await f.page.getByText("server.txt", { exact: true }).last().waitFor()
|
||||
await f.input.press("Tab")
|
||||
await f.assertDraft("@server.txt")
|
||||
await f.waitSearch(2)
|
||||
await f.searches[1].fulfill({ json: [{ name: "server.txt", path: "server.txt", type: "file" }] })
|
||||
await f.input.press("Enter")
|
||||
await f.picker.waitFor({ state: "detached" })
|
||||
await f.assertDraft("@./server.txt ")
|
||||
assert.equal(await f.page.evaluate(() => (window as any).fixture.attachments().length), 1)
|
||||
await f.input.press("Enter")
|
||||
await f.page.waitForFunction(() => (window as any).fixture.attachments().length === 0)
|
||||
assert.equal(f.prompts.length, 1)
|
||||
assert.deepEqual(f.errors, [])
|
||||
} finally { await f.page.close() }
|
||||
})
|
||||
|
||||
test("an empty command picker swallows Enter rather than submitting the command text", async () => {
|
||||
const f = await setup()
|
||||
try {
|
||||
await f.input.fill("/unknown-command")
|
||||
await f.picker.waitFor()
|
||||
await f.input.press("Enter")
|
||||
await f.assertDraft("/unknown-command")
|
||||
await f.input.press("Control+Shift+Enter")
|
||||
await f.assertDraft("/unknown-command")
|
||||
} finally { await f.page.close() }
|
||||
})
|
||||
|
||||
test("modified Enter cannot send a highlighted file; Shift+Enter retains path-only selection", async () => {
|
||||
const f = await setup(false)
|
||||
try {
|
||||
await f.input.fill("@server")
|
||||
await f.picker.waitFor(); await f.waitSearch(1)
|
||||
await f.searches[0].fulfill({ json: [{ name: "server.txt", path: "server.txt", type: "file" }] })
|
||||
await f.page.getByText("server.txt", { exact: true }).last().waitFor()
|
||||
for (const key of ["Control+Enter", "Control+Shift+Enter", "Meta+Enter", "Meta+Shift+Enter"]) {
|
||||
await f.input.press(key)
|
||||
await f.assertDraft("@server")
|
||||
assert.equal(await f.page.evaluate(() => (window as any).fixture.attachments().length), 0)
|
||||
}
|
||||
await f.input.press("Shift+Enter")
|
||||
await f.picker.waitFor({ state: "detached" })
|
||||
await f.assertDraft("@./server.txt ")
|
||||
assert.deepEqual(await f.page.evaluate(() => (window as any).fixture.attachments().map((a: any) => a.source)),
|
||||
[{ type: "text", value: "./server.txt" }])
|
||||
} finally { await f.page.close() }
|
||||
})
|
||||
|
||||
test("directory Tab navigation and arrow selection remain available without sending", async () => {
|
||||
const f = await setup()
|
||||
try {
|
||||
await f.input.fill("@docs")
|
||||
await f.picker.waitFor(); await f.waitSearch(1)
|
||||
await f.searches[0].fulfill({ json: [{ name: "docs", path: "docs", type: "directory" }] })
|
||||
await f.page.locator('[data-picker-selected="true"]').waitFor()
|
||||
await f.input.press("Tab")
|
||||
await f.assertDraft("@docs/")
|
||||
await f.waitSearch(2)
|
||||
await f.searches[1].fulfill({ json: [
|
||||
{ name: "a.txt", path: "docs/a.txt", type: "file" },
|
||||
{ name: "b.txt", path: "docs/b.txt", type: "file" },
|
||||
] })
|
||||
await f.page.getByText("b.txt", { exact: true }).last().waitFor()
|
||||
await f.input.press("ArrowDown")
|
||||
await f.input.press("Enter")
|
||||
await f.picker.waitFor({ state: "detached" })
|
||||
await f.assertDraft("@./docs/b.txt ")
|
||||
assert.equal(await f.page.evaluate(() => (window as any).fixture.attachments()[0].filename), "b.txt")
|
||||
} finally { await f.page.close() }
|
||||
})
|
||||
140
packages/ui/tests/browser/event-subscriber-isolation.test.ts
Normal file
140
packages/ui/tests/browser/event-subscriber-isolation.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { after, before, test } from "node:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import Fastify, { type FastifyInstance } from "fastify"
|
||||
import pino from "pino"
|
||||
import { chromium, type Browser, type Page } from "playwright"
|
||||
import { createServer, type ViteDevServer } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { EventBus } from "../../../server/src/events/bus"
|
||||
import { ClientConnectionManager } from "../../../server/src/clients/connection-manager"
|
||||
import { registerEventRoutes } from "../../../server/src/server/routes/events"
|
||||
|
||||
let backend: FastifyInstance, server: ViteDevServer, browser: Browser, connections: ClientConnectionManager, url: string
|
||||
const bus = new EventBus()
|
||||
const clients = new Set<() => void>()
|
||||
before(async () => {
|
||||
const logger = pino({ level: "silent" })
|
||||
// Send a body frame immediately so the Vite proxy forwards idle stream headers.
|
||||
bus.publish({ type: "instance.eventStatus", instanceId: "fixture", status: "connected", generation: 1 })
|
||||
connections = new ClientConnectionManager(logger)
|
||||
backend = Fastify()
|
||||
registerEventRoutes(backend, { eventBus: bus, logger, connectionManager: connections,
|
||||
registerClient: close => { clients.add(close); return () => { clients.delete(close) } },
|
||||
})
|
||||
backend.get("/api/auth/status", async () => ({ authenticated: true }))
|
||||
await backend.listen({ host: "127.0.0.1", port: 0 })
|
||||
const target = `http://127.0.0.1:${(backend.server.address() as { port: number }).port}`
|
||||
server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error",
|
||||
plugins: [solid(), { name: "event-subscriber-isolation", configureServer(s) {
|
||||
s.middlewares.use("/fixture", async (_request, response) => {
|
||||
response.setHeader("Content-Type", "text/html")
|
||||
response.end(await s.transformIndexHtml("/fixture", '<html><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/event-subscriber-isolation.tsx"></script></body></html>'))
|
||||
})
|
||||
} }], resolve: { dedupe: ["solid-js"] },
|
||||
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null, proxy: { "/api": { target } } },
|
||||
})
|
||||
await server.listen()
|
||||
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture`
|
||||
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
|
||||
})
|
||||
after(async () => {
|
||||
await browser?.close()
|
||||
connections?.shutdown()
|
||||
for (const close of clients) close()
|
||||
await server?.close()
|
||||
await backend?.close()
|
||||
})
|
||||
|
||||
function emit(id: string) {
|
||||
bus.publish({ type: "instance.event", instanceId: "fixture", event: {
|
||||
id, created: Date.now(), type: "session.renamed", data: { sessionID: "session", title: id },
|
||||
durable: { aggregateID: "session", seq: 1, version: 1 }, location: { directory: "/fixture" },
|
||||
} })
|
||||
}
|
||||
async function setup(page: Page, mode: string) {
|
||||
const pageErrors: string[] = []
|
||||
page.on("pageerror", error => pageErrors.push(error.message))
|
||||
await page.goto(`${url}?mode=${mode}`)
|
||||
await page.locator("[data-events]").waitFor()
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().statuses.includes("connected"))
|
||||
return pageErrors
|
||||
}
|
||||
|
||||
test("a failing wildcard subscriber cannot discard a valid SSE event before native and typed subscribers", async () => {
|
||||
const page = await browser.newPage()
|
||||
try {
|
||||
const pageErrors = await setup(page, "event")
|
||||
emit("first")
|
||||
emit("second")
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().faults === 2)
|
||||
const state = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
assert.deepEqual(state.native, ["first", "second"], "real SSEManager must receive both events despite another subscriber's error")
|
||||
assert.deepEqual(state.typed, ["first", "second"])
|
||||
assert.equal(state.errors.length, 2, "each failed callback is diagnosed, without replaying the event")
|
||||
assert.doesNotMatch(JSON.stringify(state.errors), /Failed to parse event/)
|
||||
assert.deepEqual(pageErrors, [])
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("a deferred Solid derivation error is diagnosed as dispatch, not malformed SSE, without replay", async () => {
|
||||
const page = await browser.newPage()
|
||||
try {
|
||||
const pageErrors = await setup(page, "derived")
|
||||
emit("first")
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().errors.length === 1)
|
||||
emit("second")
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().native.length === 2)
|
||||
const state = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
assert.deepEqual(state.native, ["first", "second"])
|
||||
assert.deepEqual(state.typed, ["first", "second"])
|
||||
assert.equal(state.errors[0][0], "Failed to dispatch event")
|
||||
assert.doesNotMatch(JSON.stringify(state.errors), /Failed to parse event/)
|
||||
assert.deepEqual(pageErrors, [])
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("a failing open subscriber cannot suppress later reconnect reconciliation subscribers", async () => {
|
||||
const page = await browser.newPage()
|
||||
try {
|
||||
const pageErrors = await setup(page, "open")
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().faults === 1)
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.snapshot().opens), 1)
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.restart()), null)
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().faults === 2)
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.snapshot().opens), 2)
|
||||
assert.deepEqual(pageErrors, [])
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("a failing status subscriber cannot abort restart or prevent later transport-status notifications", async () => {
|
||||
const page = await browser.newPage()
|
||||
try {
|
||||
const pageErrors = await setup(page, "status")
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.restart()), null, "restart must still close and replace its connection")
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().opens === 2)
|
||||
const state = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
assert.ok(state.statuses.includes("disconnected"))
|
||||
emit("after-restart")
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().native.length === 1)
|
||||
assert.deepEqual(await page.evaluate(() => (window as any).fixture.snapshot().native), ["after-restart"])
|
||||
assert.deepEqual(pageErrors, [])
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("normal subscribers retain SSE FIFO and unsubscribe removes only its own listener", async () => {
|
||||
const page = await browser.newPage()
|
||||
try {
|
||||
const pageErrors = await setup(page, "normal")
|
||||
emit("first")
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().typed.length === 1)
|
||||
await page.evaluate(() => (window as any).fixture.unsubscribe())
|
||||
emit("second")
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().native.length === 2)
|
||||
const state = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
assert.deepEqual(state.native, ["first", "second"])
|
||||
assert.deepEqual(state.typed, ["first"])
|
||||
assert.deepEqual(state.errors, [])
|
||||
assert.deepEqual(pageErrors, [])
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { runWithDiagnosticCleanup } from "./fixture-diagnostic-boundary"
|
||||
|
||||
async function capture(run: () => Promise<unknown>) {
|
||||
let caught = false
|
||||
let thrown: unknown
|
||||
try {
|
||||
await run()
|
||||
} catch (error) {
|
||||
caught = true
|
||||
thrown = error
|
||||
}
|
||||
return { caught, thrown }
|
||||
}
|
||||
|
||||
test("diagnostic and cleanup failures preserve the primary error identity and stack", async () => {
|
||||
const primary = new Error("primary")
|
||||
const stack = primary.stack
|
||||
const observation = new Error("observation")
|
||||
const cleanup = new Error("cleanup")
|
||||
const observations: unknown[] = []
|
||||
const cleanups: unknown[] = []
|
||||
const result = await capture(() => runWithDiagnosticCleanup({
|
||||
run: async () => { throw primary },
|
||||
diagnose: async () => { throw observation },
|
||||
cleanup: async () => { throw cleanup },
|
||||
onObservationError: error => observations.push(error),
|
||||
onCleanupError: error => cleanups.push(error),
|
||||
}))
|
||||
assert.equal(result.caught, true)
|
||||
assert.equal(result.thrown, primary)
|
||||
assert.equal((result.thrown as Error).stack, stack)
|
||||
assert.deepEqual(observations, [observation])
|
||||
assert.deepEqual(cleanups, [cleanup])
|
||||
})
|
||||
|
||||
test("a successful snapshot and failed cleanup still preserve the primary error", async () => {
|
||||
const primary = new Error("primary")
|
||||
const cleanup = new Error("cleanup")
|
||||
let diagnosed = false
|
||||
const cleanups: unknown[] = []
|
||||
const result = await capture(() => runWithDiagnosticCleanup({
|
||||
run: async () => { throw primary },
|
||||
diagnose: async () => { diagnosed = true },
|
||||
cleanup: async () => { throw cleanup },
|
||||
onObservationError: () => assert.fail("unexpected observation error"),
|
||||
onCleanupError: error => cleanups.push(error),
|
||||
}))
|
||||
assert.equal(diagnosed, true)
|
||||
assert.equal(result.thrown, primary)
|
||||
assert.deepEqual(cleanups, [cleanup])
|
||||
})
|
||||
|
||||
test("falsy primary errors are preserved when cleanup fails", async () => {
|
||||
for (const primary of [undefined, null]) {
|
||||
const cleanup = new Error("cleanup")
|
||||
const cleanups: unknown[] = []
|
||||
const result = await capture(() => runWithDiagnosticCleanup({
|
||||
run: async () => { throw primary },
|
||||
diagnose: async () => {},
|
||||
cleanup: async () => { throw cleanup },
|
||||
onObservationError: () => assert.fail("unexpected observation error"),
|
||||
onCleanupError: error => cleanups.push(error),
|
||||
}))
|
||||
assert.equal(result.caught, true)
|
||||
assert.equal(result.thrown, primary)
|
||||
assert.deepEqual(cleanups, [cleanup])
|
||||
}
|
||||
})
|
||||
|
||||
test("cleanup failure after success remains the test failure", async () => {
|
||||
const cleanup = new Error("cleanup")
|
||||
const result = await capture(() => runWithDiagnosticCleanup({
|
||||
run: async () => "success",
|
||||
diagnose: async () => assert.fail("diagnostics must not run after success"),
|
||||
cleanup: async () => { throw cleanup },
|
||||
onObservationError: () => assert.fail("unexpected observation error"),
|
||||
onCleanupError: () => assert.fail("cleanup must propagate after success"),
|
||||
}))
|
||||
assert.equal(result.caught, true)
|
||||
assert.equal(result.thrown, cleanup)
|
||||
})
|
||||
|
||||
test("success and cleanup success preserve the result", async () => {
|
||||
const result = await runWithDiagnosticCleanup({
|
||||
run: async () => "success",
|
||||
diagnose: async () => assert.fail("diagnostics must not run after success"),
|
||||
cleanup: async () => {},
|
||||
onObservationError: () => assert.fail("unexpected observation error"),
|
||||
onCleanupError: () => assert.fail("unexpected cleanup error"),
|
||||
})
|
||||
assert.equal(result, "success")
|
||||
})
|
||||
29
packages/ui/tests/browser/fixture-diagnostic-boundary.ts
Normal file
29
packages/ui/tests/browser/fixture-diagnostic-boundary.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export async function runWithDiagnosticCleanup<T>(options: {
|
||||
run: () => Promise<T>
|
||||
diagnose: () => Promise<void>
|
||||
cleanup: () => Promise<void>
|
||||
onObservationError: (error: unknown) => void
|
||||
onCleanupError: (error: unknown) => void
|
||||
}): Promise<T> {
|
||||
let primaryFailed = false
|
||||
try {
|
||||
try {
|
||||
return await options.run()
|
||||
} catch (error) {
|
||||
primaryFailed = true
|
||||
try {
|
||||
await options.diagnose()
|
||||
} catch (observationError) {
|
||||
try { options.onObservationError(observationError) } catch {}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await options.cleanup()
|
||||
} catch (cleanupError) {
|
||||
if (!primaryFailed) throw cleanupError
|
||||
try { options.onCleanupError(cleanupError) } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
103
packages/ui/tests/browser/fixtures/compaction-responsiveness.tsx
Normal file
103
packages/ui/tests/browser/fixtures/compaction-responsiveness.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { createSignal, Show } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import SessionView from "../../../src/components/session/session-view"
|
||||
import { ConfigProvider } from "../../../src/stores/preferences"
|
||||
import { I18nProvider } from "../../../src/lib/i18n"
|
||||
import { ThemeProvider } from "../../../src/lib/theme"
|
||||
import { sdkManager } from "../../../src/lib/sdk-manager"
|
||||
import { addInstance } from "../../../src/stores/instances"
|
||||
import { sessions, setSessions, setActiveSession, setProviders } from "../../../src/stores/session-state"
|
||||
import { messageStoreBus } from "../../../src/stores/message-v2/bus"
|
||||
import { sseManager } from "../../../src/lib/sse-manager"
|
||||
import { getOpenCodeMessageRevision } from "../../../src/stores/opencode-data"
|
||||
import "../../../src/index.css"
|
||||
|
||||
const instanceId = "compaction-fixture", model = { providerID: "fixture", id: "fixture" }
|
||||
let sequence = 1000, messageLists = 0
|
||||
const nativeMessages = new Map<string, any[]>()
|
||||
const [selected, select] = createSignal("active")
|
||||
const [visible, show] = createSignal(true)
|
||||
const client: any = {
|
||||
session: {
|
||||
active: async () => ({}), inbox: { list: async () => [] },
|
||||
form: { list: async () => [] },
|
||||
get: async ({ sessionID }: any) => ({ id: sessionID, title: sessionID,
|
||||
location: { directory: "/fixture" }, time: { created: 1, updated: 1 } }),
|
||||
instructions: { entry: { remove: async () => {}, put: async () => {} } },
|
||||
},
|
||||
model: { default: async () => ({ data: model }) },
|
||||
permission: { list: async () => [] },
|
||||
message: { list: async ({ sessionID, limit = 200 }: any) => {
|
||||
messageLists++
|
||||
return { data: structuredClone((nativeMessages.get(sessionID) ?? []).slice(-limit)).reverse(), cursor: {} }
|
||||
} },
|
||||
}
|
||||
;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client)
|
||||
addInstance({ id: instanceId, folder: "/fixture", port: 0, pid: 0, proxyPath: "", status: "ready", client })
|
||||
setSessions(previous => new Map(previous).set(instanceId, new Map(["active", "inactive"].map(id => [id, {
|
||||
id, instanceId, parentId: null, title: id, agent: "build", model: { providerId: "fixture", modelId: "fixture" },
|
||||
status: "idle", retry: null, idleSince: null, generationRecovery: null, runtimeStatusKnown: true,
|
||||
version: "1", projectID: "fixture", location: { directory: "/fixture" }, cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1, updated: 1 },
|
||||
}]))))
|
||||
setProviders(previous => new Map(previous).set(instanceId, [{ id: "fixture", name: "Fixture", models: [
|
||||
{ id: "fixture", name: "Fixture", providerId: "fixture", limit: { context: 10000, output: 1000 }, cost: { input: 0, output: 0 } },
|
||||
] }]))
|
||||
setActiveSession(instanceId, "active")
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
render(() => <ConfigProvider><I18nProvider><ThemeProvider><Show when={visible()}>
|
||||
<SessionView sessionId={selected()} activeSessions={sessions().get(instanceId)!} instanceId={instanceId}
|
||||
instanceFolder="/fixture" escapeInDebounce={false} isActive={true} />
|
||||
</Show></ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
|
||||
|
||||
const emit = (sessionID: string, type: string, data: any = {}) => {
|
||||
if (type === "session.compaction.started") nativeMessages.set(sessionID, [...(nativeMessages.get(sessionID) ?? []), { id: `compact-${sessionID}`,
|
||||
type: "compaction", status: "running", reason: "manual", summary: "", recent: "", time: { created: sequence } }])
|
||||
if (type === "session.compaction.delta") nativeMessages.get(sessionID)!.at(-1).summary += data.text
|
||||
;(sseManager as any).handleEvent(instanceId, {
|
||||
id: `event_${++sequence}`, type, created: sequence, location: { directory: "/fixture" }, data: { sessionID, ...data },
|
||||
})
|
||||
}
|
||||
const text = (sessionID: string) => {
|
||||
const message = store.getMessage(`compact-${sessionID}`)
|
||||
return message?.partIds.map(id => (message.parts[id].data as any).text).join("")
|
||||
}
|
||||
;(window as any).compactionFixture = {
|
||||
resetCounts: () => { (window as any).compactionCounts = { deltas: {}, projections: {} }; messageLists = 0 },
|
||||
seed: (sessionID: string, count: number) => nativeMessages.set(sessionID, Array.from({ length: count }, (_, index) => ({
|
||||
id: `history-${sessionID}-${String(index).padStart(4, "0")}`, type: "user", text: `History ${index}`, time: { created: index },
|
||||
}))),
|
||||
snapshot: () => ({ counts: (window as any).compactionCounts, messageLists,
|
||||
active: text("active"), inactive: text("inactive"), ids: store.getSessionMessageIds(selected()),
|
||||
revision: getOpenCodeMessageRevision(instanceId, selected()) }),
|
||||
start: (sessionID = "active") => {
|
||||
emit(sessionID, "session.compaction.started", { inputID: `compact-${sessionID}`, reason: "manual", recent: "" })
|
||||
},
|
||||
burst: (sessionID: string, count = 128) => {
|
||||
const started = performance.now()
|
||||
for (let index = 0; index < count; index++) emit(sessionID, "session.compaction.delta", { text: `[${index}]` })
|
||||
return performance.now() - started
|
||||
},
|
||||
phase: async (sessionID: string) => {
|
||||
for (let index = 0; index < 8; index++) {
|
||||
emit(sessionID, "session.compaction.delta", { text: `[${index}]` })
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
},
|
||||
end: (sessionID: string, summary: string) => {
|
||||
nativeMessages.set(sessionID, [{ id: `compact-${sessionID}`, type: "compaction", status: "completed",
|
||||
reason: "manual", summary, recent: "", model, time: { created: 1 } }])
|
||||
emit(sessionID, "session.compaction.ended", { reason: "manual", text: summary, recent: "", model })
|
||||
},
|
||||
fail: (sessionID: string) => emit(sessionID, "session.compaction.failed", {
|
||||
inputID: `compact-${sessionID}`, reason: "manual", error: { type: "fixture", message: "cancelled" },
|
||||
}),
|
||||
activate: (sessionID: string) => {
|
||||
show(false)
|
||||
select(sessionID)
|
||||
setActiveSession(instanceId, sessionID)
|
||||
show(true)
|
||||
},
|
||||
unmount: () => show(false),
|
||||
remount: () => show(true),
|
||||
}
|
||||
|
|
@ -55,6 +55,7 @@ await updatePreferences({ locale: (params.get("locale") || "en") as any })
|
|||
switch: (id: string) => setActiveSession(instanceId, id),
|
||||
active: setActive,
|
||||
mounted: setMounted,
|
||||
submitOnEnter: (value: boolean) => updatePreferences({ promptSubmitOnEnter: value }),
|
||||
move: (directory: string) => setSessions(previous => {
|
||||
const next = new Map(previous), list = new Map(next.get(instanceId))
|
||||
const id = activeSessionId().get(instanceId)!
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import { createMemo, createRoot, createSignal } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import { serverEvents } from "../../../src/lib/server-events"
|
||||
import { sseManager } from "../../../src/lib/sse-manager"
|
||||
import { getLogger } from "../../../src/lib/logger"
|
||||
|
||||
const mode = new URLSearchParams(location.search).get("mode")
|
||||
const native: string[] = [], typed: string[] = [], statuses: string[] = [], errors: unknown[][] = []
|
||||
let faults = 0, opens = 0
|
||||
const [version, setVersion] = createSignal(0)
|
||||
const changed = () => setVersion(value => value + 1)
|
||||
const fail = () => { faults++; changed(); throw new Error("synthetic subscriber failure") }
|
||||
// Replace diagnostics only; dispatch, transport, native reducer entry and the
|
||||
// browser's EventSource are production implementations.
|
||||
getLogger("sse").error = (...args) => { errors.push(args) }
|
||||
if (mode === "event") serverEvents.on("*", event => { if (event.type === "instance.event") fail() })
|
||||
if (mode === "derived") createRoot(() => {
|
||||
const [broken, setBroken] = createSignal(false)
|
||||
createMemo(() => { if (broken()) throw new Error("synthetic deferred derivation failure") })
|
||||
serverEvents.on("*", event => {
|
||||
if (event.type !== "instance.event") return
|
||||
faults++
|
||||
changed()
|
||||
setBroken(true)
|
||||
})
|
||||
})
|
||||
if (mode === "open") serverEvents.onOpen(fail)
|
||||
if (mode === "status") serverEvents.onTransportStatus(status => { if (status === "disconnected") fail() })
|
||||
sseManager.onNativeSessionEvent = (_id, event) => { native.push(event.id); changed() }
|
||||
const unsubscribe = serverEvents.on("instance.event", event => {
|
||||
if (event.type === "instance.event") typed.push(event.event.id)
|
||||
changed()
|
||||
})
|
||||
serverEvents.onOpen(() => { opens++; changed() })
|
||||
serverEvents.onTransportStatus(status => { statuses.push(status); changed() })
|
||||
;(window as any).fixture = {
|
||||
snapshot: () => ({ native, typed, statuses, faults, opens, errors }),
|
||||
restart: () => {
|
||||
try { serverEvents.restart("isolated subscriber fixture"); return null }
|
||||
catch (error) { return error instanceof Error ? error.message : String(error) }
|
||||
},
|
||||
unsubscribe,
|
||||
}
|
||||
render(() => <pre data-events>{version()}{JSON.stringify({ native, typed, statuses, faults, opens })}</pre>, document.getElementById("root")!)
|
||||
|
|
@ -14,6 +14,9 @@ import { setSessions, setActiveSession, setActiveParentSession, setSessionPage,
|
|||
import { ensureWorktreesLoaded } from "../../../src/stores/worktrees"
|
||||
import "../../../src/index.css"
|
||||
|
||||
// Optional, synchronous test-only marks; no observer is installed on native pages.
|
||||
const bootStage = (phase: string) => (window as any).__headerFixtureBoot?.mark(phase)
|
||||
bootStage("imports-complete")
|
||||
const id = "header-windows", sessionId = "session"
|
||||
let interrupts = 0
|
||||
const session: any = { id: sessionId, instanceId: id, parentId: null, title: "Fixture conversation", location: { directory: "/repo" },
|
||||
|
|
@ -37,7 +40,9 @@ setProviders(previous => new Map(previous).set(id, [{ id: "fixture", name: "Fixt
|
|||
setActiveSession(id, sessionId)
|
||||
setActiveParentSession(id, sessionId)
|
||||
setSessionPage(id, [sessionId], false, true)
|
||||
bootStage("worktrees-before")
|
||||
await ensureWorktreesLoaded(id)
|
||||
bootStage("worktrees-after")
|
||||
let executions = 0
|
||||
const escapeStates: boolean[] = []
|
||||
const [menuInstance, setMenuInstance] = createSignal<string | undefined>(id)
|
||||
|
|
@ -64,8 +69,12 @@ function Fixture() {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
bootStage("render-before")
|
||||
render(() => <ConfigProvider><I18nProvider><ThemeProvider><Fixture /></ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
|
||||
bootStage("render-after")
|
||||
bootStage("preferences-before")
|
||||
await updatePreferences({ locale: "en" })
|
||||
bootStage("preferences-after")
|
||||
;(window as any).fixture = {
|
||||
viewAction: (action: string) => viewAction(action),
|
||||
menuInstance: setMenuInstance,
|
||||
|
|
@ -82,3 +91,4 @@ await updatePreferences({ locale: "en" })
|
|||
interrupts: () => interrupts,
|
||||
promptHeight: promptInputHeight,
|
||||
}
|
||||
bootStage("published")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import { OpenCode } from "@opencode/client"
|
||||
import { createSignal } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import { createInstanceFetch } from "../../../src/lib/sdk-manager"
|
||||
import { serverEvents } from "../../../src/lib/server-events"
|
||||
|
||||
// Deliberately small transport fixture, not a replacement session store/UI.
|
||||
// All API requests and SSE use the real production browser adapters.
|
||||
const baseUrl = `${location.origin}/workspaces/workspace/instance/`
|
||||
const client = OpenCode.make({ baseUrl, fetch: createInstanceFetch(baseUrl) })
|
||||
const [selected, setSelected] = createSignal("A")
|
||||
const [loaded, setLoaded] = createSignal("")
|
||||
const [draft, setDraft] = createSignal("")
|
||||
const [opens, setOpens] = createSignal(0)
|
||||
const [events, setEvents] = createSignal(0)
|
||||
const [pending, setPending] = createSignal(false)
|
||||
const errors: string[] = []
|
||||
let catalogueSettled = 0
|
||||
serverEvents.onOpen(() => setOpens(value => value + 1))
|
||||
serverEvents.on("instance.eventStatus", () => setEvents(value => value + 1))
|
||||
|
||||
const fixture = {
|
||||
start(action: "prompt" | "compact") {
|
||||
setPending(true)
|
||||
const operation = action === "prompt"
|
||||
? client.session.prompt({ sessionID: "A", text: "private transport fixture" })
|
||||
: client.session.compact({ sessionID: "A" })
|
||||
void operation.catch(error => errors.push(String(error))).finally(() => setPending(false))
|
||||
},
|
||||
catalogues() {
|
||||
// Five distinct catalogue intents, not foreground session reads: production
|
||||
// createInstanceFetch must admit only two and leave the other three queued.
|
||||
// Distinct URLs avoid Chromium's same-URL cache request coalescing.
|
||||
for (let index = 0; index < 5; index++) {
|
||||
void client.agent.get({ agentID: `fixture-${index}` }).catch(error => errors.push(String(error))).finally(() => catalogueSettled++)
|
||||
}
|
||||
},
|
||||
async info() { return (await fetch("/api/meta")).json() },
|
||||
async witness() { return (await fetch("/api/witness")).json() },
|
||||
snapshot() { return { selected: selected(), loaded: loaded(), draft: draft(), opens: opens(), events: events(), pending: pending(), catalogueSettled, errors } },
|
||||
}
|
||||
;(window as any).fixture = fixture
|
||||
|
||||
render(() => <main>
|
||||
<input aria-label="Draft" value={draft()} onInput={event => setDraft(event.currentTarget.value)} />
|
||||
<button onClick={() => {
|
||||
setSelected("B")
|
||||
void client.session.get({ sessionID: "B" }).then(session => setLoaded(session.id)).catch(error => errors.push(String(error)))
|
||||
}}>Switch to B</button>
|
||||
<output data-selected>{selected()}</output>
|
||||
<output data-loaded>{loaded()}</output>
|
||||
</main>, document.getElementById("root")!)
|
||||
44
packages/ui/tests/browser/fixtures/render-cost-timeline.tsx
Normal file
44
packages/ui/tests/browser/fixtures/render-cost-timeline.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { createSignal } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import MessageTimeline, { type TimelineSegment } from "../../../src/components/message-timeline"
|
||||
import { I18nProvider } from "../../../src/lib/i18n"
|
||||
import { ConfigProvider } from "../../../src/stores/preferences"
|
||||
import { ThemeProvider } from "../../../src/lib/theme"
|
||||
import { applyUiSettings } from "./ui-settings"
|
||||
import { messageStoreBus } from "../../../src/stores/message-v2/bus"
|
||||
import { serverApi } from "../../../src/lib/api-client"
|
||||
import "../../../src/index.css"
|
||||
|
||||
const count = Math.min(50_000, Math.max(10, Number(new URLSearchParams(location.search).get("count") ?? 10_000)))
|
||||
const segments: TimelineSegment[] = Array.from({ length: count }, (_, index) => ({
|
||||
id: `marker-${index}`, messageId: `message-${Math.floor(index / 3)}`, type: index % 3 === 0 ? "user" : index % 3 === 1 ? "tool" : "assistant",
|
||||
label: String(index), tooltip: `Marker ${index}`, toolName: index % 3 === 1 ? "read" : undefined,
|
||||
toolPartIds: index % 3 === 1 ? [`tool-${index}`] : undefined, totalChars: 0,
|
||||
}))
|
||||
// Seed one resident preview; all other excerpts are deterministic HTTP-boundary
|
||||
// answers, never transcript loads. This also exercises Markdown in the tooltip.
|
||||
const store = messageStoreBus.getOrCreate("render-timeline")
|
||||
store.upsertMessage({ id: "message-0", sessionId: "timeline", role: "user", status: "complete", createdAt: 1,
|
||||
parts: [{ id: "preview-text", type: "text", text: "**Resident preview**\n\n" + "bounded excerpt ".repeat(1000) }] })
|
||||
const requests: number[] = []
|
||||
serverApi.fetchOutlinePreviews = async (_instance, _session, ids) => {
|
||||
requests.push(ids.length)
|
||||
return { status: "previews", entries: ids.map(id => ({ id, text: "**Local excerpt**", tools: "Read tool excerpt" })) } as any
|
||||
}
|
||||
const [active, setActive] = createSignal("marker-0")
|
||||
let selected = "", activeCounter = 0
|
||||
await applyUiSettings({ locale: "en" })
|
||||
const start = performance.now()
|
||||
render(() => <ConfigProvider><I18nProvider><ThemeProvider><div class="message-timeline-sidebar" style={{ height: "600px", width: "60px" }}>
|
||||
<MessageTimeline instanceId="render-timeline" sessionId="timeline" segments={segments}
|
||||
activeSegmentId={active()} onSegmentClick={segment => { selected = segment.id }} />
|
||||
</div></ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
|
||||
const mountMs = performance.now() - start
|
||||
;(window as any).fixture = {
|
||||
measure: (updates: number) => {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < updates; index++) setActive(`marker-${++activeCounter % 15}`)
|
||||
return performance.now() - start
|
||||
},
|
||||
snapshot: () => ({ count, selected, active: active(), requests, mountMs }),
|
||||
}
|
||||
172
packages/ui/tests/browser/fixtures/render-cost.tsx
Normal file
172
packages/ui/tests/browser/fixtures/render-cost.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { batch } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import { ConfigProvider } from "../../../src/stores/preferences"
|
||||
import { I18nProvider } from "../../../src/lib/i18n"
|
||||
import { ThemeProvider } from "../../../src/lib/theme"
|
||||
import MessageSection from "../../../src/components/message-section"
|
||||
import { applyUiSettings } from "./ui-settings"
|
||||
import { sdkManager } from "../../../src/lib/sdk-manager"
|
||||
import { addInstance } from "../../../src/stores/instances"
|
||||
import { setSessions, setActiveSession, sessions, messagesLoaded } from "../../../src/stores/session-state"
|
||||
import { sseManager } from "../../../src/lib/sse-manager"
|
||||
import { serverEvents } from "../../../src/lib/server-events"
|
||||
import { loadMessages } from "../../../src/stores/session-api"
|
||||
import { messageStoreBus } from "../../../src/stores/message-v2/bus"
|
||||
import "../../../src/index.css"
|
||||
|
||||
const params = new URLSearchParams(location.search)
|
||||
const mode = params.get("mode") ?? "task"
|
||||
const count = Math.min(1000, Math.max(1, Number(params.get("count") ?? 12)))
|
||||
const outputSize = Math.min(2_000_000, Math.max(1000, Number(params.get("size") ?? 8000)))
|
||||
const legacySummary = params.has("legacy") ? [{ id: "legacy-step", tool: "read", title: "Parent legacy summary", status: "completed" }] : []
|
||||
const instanceId = "render-cost", parentId = "parent", childId = "child"
|
||||
const model = { providerID: "fixture", id: "fixture" }
|
||||
const output = Array.from({ length: Math.ceil(outputSize / "Output 00000 with deterministic text\n".length) }, (_, index) => `Output ${String(index).padStart(5, "0")} with deterministic text\n`).join("").slice(0, outputSize)
|
||||
const tool = (index: number) => ({ id: `step-${index}`, type: "tool", name: "read",
|
||||
time: { created: index + 1, completed: index + 2 },
|
||||
state: { status: "completed", input: { filePath: `file-${index}.txt` }, metadata: {}, content: [{ type: "text", text: output }] } })
|
||||
const history: Record<string, any[]> = mode === "task" ? {
|
||||
[parentId]: [{ id: "parent-message", type: "assistant", agent: "build", model, time: { created: 1, completed: 2 },
|
||||
content: [{ id: "parent-task", type: "tool", name: "subagent", time: { created: 1, completed: 2 },
|
||||
state: { status: "completed", input: { agent: "explore", description: "Deterministic child" }, metadata: { sessionId: childId, summary: legacySummary }, content: [{ type: "text", text: "Task result" }] } }] }],
|
||||
[childId]: [{ id: "child-message", type: "assistant", agent: "build", model, time: { created: 3 },
|
||||
content: Array.from({ length: count }, (_, index) => tool(index)) }],
|
||||
} : {
|
||||
[parentId]: Array.from({ length: count }, (_, index) => ({ id: `history-${index}`, type: "assistant", agent: "build", model,
|
||||
time: { created: index + 1, completed: index + 2 }, content: [{ type: "text", text: `History ${index}\n\n` + "A deterministic paragraph.\n\n".repeat(2 + index % 5) }] })),
|
||||
}
|
||||
const requests: string[] = []
|
||||
let holdChildReads = false
|
||||
let failChildRead = false
|
||||
const heldReads: Array<() => void> = []
|
||||
const client: any = { session: {
|
||||
active: async () => ({}), inbox: { list: async () => ({ data: [] }) },
|
||||
get: async ({ sessionID }: any) => ({ id: sessionID, location: { directory: "/fixture" }, time: { created: 1, updated: 1 } }),
|
||||
}, message: { list: async ({ sessionID, limit = 200, cursor }: any) => {
|
||||
requests.push(sessionID)
|
||||
if (sessionID === childId && holdChildReads) await new Promise<void>(resolve => heldReads.push(resolve))
|
||||
if (sessionID === childId && failChildRead) { failChildRead = false; throw new Error("Deterministic read failure") }
|
||||
const messages = history[sessionID] ?? []
|
||||
const end = cursor ? Number(cursor) : messages.length, start = Math.max(0, end - limit)
|
||||
return { data: messages.slice(start, end).reverse(), cursor: start ? { next: String(start) } : {} }
|
||||
} } }
|
||||
;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client)
|
||||
sseManager.getStatuses = () => new Map([[instanceId, "connected"]])
|
||||
addInstance({ id: instanceId, folder: "/fixture", port: 0, pid: 0, proxyPath: "", status: "ready", client })
|
||||
setSessions(previous => new Map(previous).set(instanceId, new Map(Object.keys(history).map(id => [id, {
|
||||
id, instanceId, parentId: id === parentId ? null : parentId, title: id, location: { directory: "/fixture" },
|
||||
status: "idle", agent: "build", model: { providerId: "fixture", modelId: "fixture" }, time: { created: 1, updated: 1 },
|
||||
} as any]))))
|
||||
setActiveSession(instanceId, parentId)
|
||||
await applyUiSettings({ locale: "en", showMessageTimeline: mode !== "task", toolInputsVisibility: "hidden",
|
||||
toolCallExpansionDefaults: { preset: "custom", thinking: "collapsed", tools: { task: "expanded", read: "collapsed" } } })
|
||||
for (const id of Object.keys(history)) await loadMessages(instanceId, id, { force: true })
|
||||
|
||||
// Instrument a native operation, not a replacement renderer/store. Only ToolCall
|
||||
// snapshot inputs are counted; timings below also work with instrumentation off.
|
||||
let snapshots = 0, snapshotMs = 0
|
||||
const instrumented = params.get("instrument") !== "off"
|
||||
const clone = window.structuredClone.bind(window)
|
||||
if (instrumented) window.structuredClone = ((value: any, options?: StructuredSerializeOptions) => {
|
||||
const matched = value?.type === "tool" && value.id?.startsWith("step-")
|
||||
const start = performance.now(), result = clone(value, options)
|
||||
if (matched) { snapshots++; snapshotMs += performance.now() - start }
|
||||
return result
|
||||
}) as typeof structuredClone
|
||||
const clipboard: string[] = []
|
||||
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: async (text: string) => { clipboard.push(text) } } })
|
||||
let eventId = 100
|
||||
const emit = (type: string, data: any) => (sseManager as any).handleEvent(instanceId, {
|
||||
id: `event-${++eventId}`, type, created: eventId, location: { directory: "/fixture" },
|
||||
data: { sessionID: childId, assistantMessageID: "child-message", ...data },
|
||||
})
|
||||
const startText = () => {
|
||||
if (!history[childId][0].content.some((part: any) => part.type === "text")) history[childId][0].content.push({ type: "text", text: "" })
|
||||
emit("session.text.started", {})
|
||||
}
|
||||
const delta = () => {
|
||||
const text = history[childId][0].content.find((part: any) => part.type === "text")
|
||||
text.text += " x"
|
||||
emit("session.text.delta", { ordinal: count, delta: " x" })
|
||||
}
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
render(() => <ConfigProvider><I18nProvider><ThemeProvider>
|
||||
<main style={{ display: "flex", height: "700px", width: "1000px" }}>
|
||||
<MessageSection instanceId={instanceId} sessionId={parentId} isActive={true} />
|
||||
</main>
|
||||
</ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
|
||||
let added = 0, removed = 0
|
||||
const countSteps = (nodes: NodeList) => Array.from(nodes).reduce((total, node) => total + (node instanceof Element
|
||||
? Number(node.matches('.tool-call[data-part-id^="step-"]')) + node.querySelectorAll('.tool-call[data-part-id^="step-"]').length : 0), 0)
|
||||
const recordMutations = (records: MutationRecord[]) => {
|
||||
for (const record of records) { added += countSteps(record.addedNodes); removed += countSteps(record.removedNodes) }
|
||||
}
|
||||
const observer = instrumented ? new MutationObserver(recordMutations) : undefined
|
||||
observer?.observe(document.querySelector("main")!, { childList: true, subtree: true })
|
||||
;(window as any).fixture = {
|
||||
startText,
|
||||
delta,
|
||||
hold: () => { holdChildReads = true },
|
||||
release: () => { holdChildReads = false; heldReads.splice(0).forEach(resolve => resolve()) },
|
||||
reproject: () => loadMessages(instanceId, childId, { force: true }),
|
||||
replaceChildTools: (indexes: number[], textIndexes: number[] = []) => {
|
||||
// Same native message identity, but a new authoritative part membership.
|
||||
history[childId][0].content = indexes.map(index => textIndexes.includes(index)
|
||||
? { id: `step-${index}`, type: "text", text: output }
|
||||
: tool(index))
|
||||
return loadMessages(instanceId, childId, { force: true })
|
||||
},
|
||||
failRead: () => { failChildRead = true; delta() },
|
||||
clearChild: () => { history[childId] = []; return loadMessages(instanceId, childId, { force: true }) },
|
||||
deleteChild: () => {
|
||||
history[childId] = []
|
||||
// Native deletion arrives through the real dispatcher and its Solid batch,
|
||||
// without rewriting the parent's task metadata or clearing its transcript.
|
||||
;(serverEvents as any).dispatchBatch([{ type: "instance.event", instanceId, event: {
|
||||
id: `event-${++eventId}`, type: "session.deleted", created: eventId,
|
||||
location: { directory: "/fixture" }, data: { sessionID: childId },
|
||||
} }])
|
||||
},
|
||||
toolOutput: () => (store.getMessage("child-message")?.parts["step-0"]?.data as any)?.state?.output,
|
||||
mutateTool: () => batch(() => {
|
||||
// Deliberately keep data identity and message revision unchanged; only the
|
||||
// existing normalized part revision invalidates this in-place update.
|
||||
const set = store.setState as any
|
||||
set("messages", "child-message", "parts", "step-0", "data", "state", "output", "Versioned in-place output")
|
||||
set("messages", "child-message", "parts", "step-0", "revision", (revision: number) => revision + 1)
|
||||
}),
|
||||
toolUpdate: (index: number, text: string) => {
|
||||
history[childId][0].content[index].state.content = [{ type: "text", text }]
|
||||
emit("session.tool.success", { id: `step-${index}`, content: [{ type: "text", text }], metadata: {} })
|
||||
},
|
||||
failTool: (index: number) => {
|
||||
const part = history[childId][0].content.find((part: any) => part.id === `step-${index}`)
|
||||
part.state = { ...part.state, status: "error", error: { message: "Deterministic failure" } }
|
||||
emit("session.tool.failed", { id: part.id, error: part.state.error, metadata: {} })
|
||||
},
|
||||
removeTool: (index: number) => store.removeMessagePart("child-message", `step-${index}`, childId),
|
||||
appendTool: () => {
|
||||
history[childId][0].content.push({ ...tool(count), id: "appended-step", state: {
|
||||
status: "completed", input: { filePath: "new.txt" }, metadata: {}, content: [{ type: "text", text: "New step output" }],
|
||||
} })
|
||||
emit("session.tool.input.started", { id: "appended-step", name: "read" })
|
||||
emit("session.tool.called", { id: "appended-step", input: { filePath: "new.txt" } })
|
||||
emit("session.tool.success", { id: "appended-step", content: [{ type: "text", text: "New step output" }], metadata: {} })
|
||||
},
|
||||
reset: () => { observer?.takeRecords(); snapshots = 0; snapshotMs = 0; added = 0; removed = 0 },
|
||||
measure: (updates: number) => {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < updates; index++) delta()
|
||||
const ms = performance.now() - start
|
||||
if (observer) recordMutations(observer.takeRecords())
|
||||
return { ms, snapshots, snapshotMs, added, removed }
|
||||
},
|
||||
snapshot: () => ({ snapshots, snapshotMs, added, removed, requests, clipboard, output, held: heldReads.length,
|
||||
partIds: store.getMessage("child-message")?.partIds,
|
||||
childRevision: store.getMessage("child-message")?.revision,
|
||||
childMessageIds: [...store.getSessionMessageIds(childId)],
|
||||
childLoaded: messagesLoaded().get(instanceId)?.has(childId) ?? false,
|
||||
childExists: sessions().get(instanceId)?.has(childId) ?? false,
|
||||
parentChildId: (store.getMessage("parent-message")?.parts["parent-task"]?.data as any)?.state?.metadata?.sessionId,
|
||||
loaded: store.getSessionMessageIds(parentId).length }),
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { createSignal } from "solid-js"
|
||||
import { createEffect, createSignal } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import MessagePart from "../../../src/components/message-part"
|
||||
import MessageTimeline from "../../../src/components/message-timeline"
|
||||
|
|
@ -13,6 +13,14 @@ const code = '<span title="<source>">&</span>'
|
|||
const body = ["**Markdown still works**", html, "`" + code + "`", "```html\n" + code + "\n```"].join("\n\n")
|
||||
const malformed = 'Please explain <code><img/src=x onerror="window.htmlExecuted=true"></code> literally\n\ntext <script>if (x<y) z()</script> after'
|
||||
const [literal, setLiteral] = createSignal(true)
|
||||
const traceEnabled = new URLSearchParams(location.search).has("trace")
|
||||
const trace: Array<Record<string, unknown>> = []
|
||||
const recordCache = (kind: string) => {
|
||||
const cache = document.querySelector("#cache")
|
||||
trace.push({ kind, literal: literal(), connected: cache?.isConnected ?? false,
|
||||
html: cache?.innerHTML, time: performance.now() })
|
||||
}
|
||||
if (traceEnabled) createEffect(() => { literal(); queueMicrotask(() => recordCache("literal-effect")) })
|
||||
const part = { id: "cached-part", type: "text" as const, text: html, version: 1,
|
||||
renderCache: { text: html, html: '<div class="cached-html">old sanitized HTML</div>', theme: "light", mode: "1:escaped:wrap" } }
|
||||
|
||||
|
|
@ -28,4 +36,22 @@ render(() => <ConfigProvider><I18nProvider><ThemeProvider>
|
|||
{ id: "assistant-preview", messageId: "assistant-preview", type: "assistant", label: "Assistant preview", tooltip: html, totalChars: html.length },
|
||||
]} /></div>
|
||||
</ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
|
||||
;(window as any).fixture = { html, code, malformed, literal: setLiteral }
|
||||
if (traceEnabled) {
|
||||
const cache = document.querySelector("#cache")
|
||||
if (cache) new MutationObserver(() => recordCache("mutation")).observe(cache, { childList: true, subtree: true })
|
||||
recordCache("mounted")
|
||||
}
|
||||
const tracedSetLiteral = (value: boolean) => {
|
||||
if (traceEnabled) recordCache("setter-before")
|
||||
setLiteral(value)
|
||||
if (traceEnabled) queueMicrotask(() => recordCache("setter-microtask"))
|
||||
}
|
||||
;(window as any).fixture = { html, code, malformed,
|
||||
literal: traceEnabled ? tracedSetLiteral : setLiteral,
|
||||
snapshot: () => ({ literal: literal(), cache: document.querySelector("#cache")?.innerHTML,
|
||||
cachedHtml: document.querySelectorAll("#cache .cached-html").length,
|
||||
trace, renderCache: part.renderCache,
|
||||
resources: performance.getEntriesByType("resource").map(entry => entry.name)
|
||||
.filter(name => /(?:solid|markdown|user-html|\.js(?:\?|$))/.test(name)),
|
||||
}),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,33 @@ import solid from "vite-plugin-solid"
|
|||
import { prepareGitPrototypeAssets } from "./fixtures/git-history-assets.mjs"
|
||||
|
||||
let server: ViteDevServer, browser: Browser, url: string
|
||||
const gitFocusBoundaryInitScript = String.raw`(() => {
|
||||
const trace = []
|
||||
window.gitFocusBoundaryTrace = trace
|
||||
const describeElement = target => target instanceof Element
|
||||
? { tag: target.tagName, id: target.id, className: target.className, partId: target.getAttribute("data-part-id") }
|
||||
: null
|
||||
const pushTrace = entry => {
|
||||
trace.push(entry)
|
||||
if (trace.length > 200) trace.shift()
|
||||
}
|
||||
for (const type of ["focusin", "focusout", "pointerdown", "click"]) {
|
||||
document.addEventListener(type, event => {
|
||||
pushTrace({ kind: type, target: describeElement(event.target), active: describeElement(document.activeElement), time: performance.now() })
|
||||
}, true)
|
||||
}
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
new MutationObserver(records => {
|
||||
for (const record of records) for (const node of record.removedNodes) {
|
||||
if (!(node instanceof Element)) continue
|
||||
if (!node.matches(".git-change-context-widget, .git-change-context-widget-host")
|
||||
&& !node.querySelector(".git-change-context-widget")) continue
|
||||
pushTrace({ kind: "widget-removed", active: describeElement(document.activeElement), time: performance.now() })
|
||||
}
|
||||
}).observe(document.documentElement, { childList: true, subtree: true })
|
||||
}, { once: true })
|
||||
})()`
|
||||
|
||||
test("large Changes inventories keep the reader controls responsive", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 2000, height: 1120 } })
|
||||
try {
|
||||
|
|
@ -592,6 +619,7 @@ test("central diff inserts local lines and revision-qualified history into the r
|
|||
const errors: string[] = []
|
||||
page.on("pageerror", error => errors.push(error.stack ?? error.message))
|
||||
await page.route("**/api/**", route => route.fulfill({ json: {} }))
|
||||
if (process.env.CODENOMAD_BROWSER_BOUNDARY_TRACE) await page.addInitScript({ content: gitFocusBoundaryInitScript })
|
||||
try {
|
||||
await page.goto(`${url}?session=1`)
|
||||
const composer = page.locator(".session-view textarea.prompt-input")
|
||||
|
|
@ -606,6 +634,8 @@ test("central diff inserts local lines and revision-qualified history into the r
|
|||
await insert.click()
|
||||
assert.match(await composer.inputValue(), /Conserver ce brouillon/)
|
||||
assert.match(await composer.inputValue(), /Git Diff: Worktree: \/CodeNomad : File: src\/components\/git-panel.tsx : 3-3/)
|
||||
const composerElement = (await composer.elementHandle())!
|
||||
await page.waitForFunction(element => element === document.activeElement, composerElement)
|
||||
assert.equal(await composer.evaluate(element => element === document.activeElement), true)
|
||||
|
||||
// Native editor keyboard selection must keep the full range, not just the hovered line.
|
||||
|
|
@ -659,7 +689,25 @@ test("central diff inserts local lines and revision-qualified history into the r
|
|||
await page.evaluate(() => (window as any).fixture.switchSession("session"))
|
||||
assert.equal(await composer.inputValue(), draft)
|
||||
assert.deepEqual(errors, [])
|
||||
} finally { await page.close() }
|
||||
} finally {
|
||||
if (process.env.CODENOMAD_BROWSER_BOUNDARY_TRACE) {
|
||||
try {
|
||||
console.error("Git focus boundary trace", await page.evaluate(() => ({
|
||||
events: (window as any).gitFocusBoundaryTrace,
|
||||
active: document.activeElement instanceof Element
|
||||
? { tag: document.activeElement.tagName, id: document.activeElement.id, className: document.activeElement.className }
|
||||
: null,
|
||||
widgets: Array.from(document.querySelectorAll<HTMLElement>(".git-change-context-widget")).map(widget => ({
|
||||
connected: widget.isConnected, rect: widget.getBoundingClientRect().toJSON(),
|
||||
})),
|
||||
selections: (window as any).monaco?.editor?.getEditors?.().map((editor: any) => editor.getSelection?.()),
|
||||
})))
|
||||
} catch (error) {
|
||||
console.error("Git focus boundary trace unavailable", error)
|
||||
}
|
||||
}
|
||||
await page.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("Workspace keyboard focus survives lazy expansion and refresh, including RTL", async () => {
|
||||
|
|
|
|||
133
packages/ui/tests/browser/header-fixture-diagnostics.test.ts
Normal file
133
packages/ui/tests/browser/header-fixture-diagnostics.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { after, before, test } from "node:test"
|
||||
import { createServer, type Server } from "node:http"
|
||||
import type { EventEmitter } from "node:events"
|
||||
import { chromium, type Browser, type Page } from "playwright"
|
||||
import { observeHeaderFixture } from "./header-fixture-diagnostics"
|
||||
import { runWithDiagnosticCleanup } from "./fixture-diagnostic-boundary"
|
||||
|
||||
let browser: Browser, server: Server, url: string
|
||||
before(async () => {
|
||||
server = createServer((request, response) => {
|
||||
response.setHeader("Content-Type", request.url?.startsWith("/entry.js") ? "text/javascript" : "text/html")
|
||||
response.end(request.url?.startsWith("/entry.js") ? "window.fixture = {}" : '<div id="root"></div><script type="module" src="/entry.js?secret=do-not-log"></script>')
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
|
||||
url = `http://127.0.0.1:${(server.address() as { port: number }).port}`
|
||||
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
|
||||
})
|
||||
after(async () => { await browser?.close(); await new Promise<void>(resolve => server?.close(() => resolve())) })
|
||||
|
||||
const observedEvents = ["console", "pageerror", "requestfailed", "response", "framenavigated"] as const
|
||||
// Playwright's real Page is an EventEmitter; its public Page interface omits this inspection method.
|
||||
const listenerCounts = (page: Page) => observedEvents.map(event => (page as unknown as EventEmitter).listenerCount(event))
|
||||
async function capture(run: () => Promise<unknown>) {
|
||||
try { return { result: await run() } } catch (error) { return { error } }
|
||||
}
|
||||
|
||||
test("aborted real entry module is observed before navigation; diagnostic/cleanup failures preserve primary identity", async () => {
|
||||
const page = await browser.newPage(), baseline = listenerCounts(page)
|
||||
const observer = observeHeaderFixture(page), primary = new Error("readiness sentinel"), stack = primary.stack
|
||||
const emissionFailure = new Error("emission"), cleanupFailure = new Error("cleanup")
|
||||
const logs: string[] = [], secondary: unknown[] = []
|
||||
const result = await capture(() => runWithDiagnosticCleanup({
|
||||
run: async () => {
|
||||
await observer.install()
|
||||
await page.route("**/entry.js?*", route => route.abort("failed"))
|
||||
const failed = page.waitForEvent("requestfailed", request => request.url().includes("/entry.js"))
|
||||
await page.goto(url)
|
||||
await failed
|
||||
throw primary
|
||||
},
|
||||
diagnose: () => observer.diagnose(message => { logs.push(message); throw emissionFailure }),
|
||||
cleanup: async () => { observer.detach(); assert.deepEqual(listenerCounts(page), baseline); await page.close(); throw cleanupFailure },
|
||||
onObservationError: error => secondary.push(error),
|
||||
onCleanupError: error => secondary.push(error),
|
||||
}))
|
||||
assert.equal(result.error, primary)
|
||||
assert.equal(primary.stack, stack)
|
||||
assert.deepEqual(secondary, [emissionFailure, cleanupFailure])
|
||||
const report = JSON.parse(logs[0])
|
||||
assert.ok(report.events.some((event: any) => event.kind === "requestfailed" && event.url.endsWith("/entry.js")))
|
||||
assert.ok(report.events.some((event: any) => event.kind === "navigation"))
|
||||
assert.equal(report.snapshot.fixture, "undefined")
|
||||
assert.equal(report.snapshot.boot[0].phase, "document-init")
|
||||
assert.equal(report.snapshot.dom.textarea, false)
|
||||
assert.ok(!logs[0].includes("do-not-log"))
|
||||
})
|
||||
|
||||
test("bounded rings, stage marks and detach use the real browser without asynchronous stage work", async () => {
|
||||
const page = await browser.newPage(), baseline = listenerCounts(page), observer = observeHeaderFixture(page)
|
||||
const logs: string[] = []
|
||||
try {
|
||||
await observer.install()
|
||||
await page.goto(url)
|
||||
const lastConsole = page.waitForEvent("console", message => message.text() === "last")
|
||||
await page.evaluate(() => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
(window as any).__headerFixtureBoot.mark("phase" + i, "x".repeat(1000))
|
||||
console.log("y".repeat(1000))
|
||||
}
|
||||
console.log("last")
|
||||
})
|
||||
await lastConsole
|
||||
await observer.diagnose(message => logs.push(message))
|
||||
const report = JSON.parse(logs[0])
|
||||
assert.equal(report.events.length, 48)
|
||||
assert.ok(report.dropped >= 53)
|
||||
assert.ok(report.events.every((event: any) => !event.text || event.text.length <= 256))
|
||||
assert.equal(report.snapshot.boot.length, 16)
|
||||
assert.equal(report.snapshot.boot.at(-1).phase, "phase99")
|
||||
assert.ok(report.snapshot.boot.every((entry: any) => entry.detail.length <= 256))
|
||||
assert.ok(logs[0].length < 40000)
|
||||
observer.detach()
|
||||
observer.detach()
|
||||
assert.deepEqual(listenerCounts(page), baseline)
|
||||
await page.evaluate(() => console.log("after detach"))
|
||||
await observer.diagnose(message => logs.push(message))
|
||||
assert.deepEqual(JSON.parse(logs[1]).events, report.events)
|
||||
} finally { observer.detach(); await page.close() }
|
||||
})
|
||||
|
||||
test("destroyed execution context snapshot is best effort and cannot replace a primary error", async () => {
|
||||
const page = await browser.newPage(), observer = observeHeaderFixture(page), primary = new Error("primary")
|
||||
const logs: string[] = []
|
||||
const result = await capture(() => runWithDiagnosticCleanup({
|
||||
run: async () => { await page.close(); throw primary },
|
||||
diagnose: () => observer.diagnose(message => logs.push(message)),
|
||||
cleanup: async () => { observer.detach() },
|
||||
onObservationError: () => assert.fail("snapshot rejection must be recorded"),
|
||||
onCleanupError: () => assert.fail("unexpected cleanup error"),
|
||||
}))
|
||||
assert.equal(result.error, primary)
|
||||
assert.match(JSON.parse(logs[0]).observationError, /closed|destroyed/i)
|
||||
})
|
||||
|
||||
test("real pending browser evaluation has a global 2s diagnostic deadline and cleanup still preserves primary", async (t) => {
|
||||
const page = await browser.newPage(), observer = observeHeaderFixture(page), primary = new Error("primary")
|
||||
const evaluate = page.evaluate.bind(page), logs: string[] = []
|
||||
// Replace only the snapshot call with an actual never-resolving renderer evaluation.
|
||||
t.mock.method(page, "evaluate", () => evaluate(() => new Promise(() => {})))
|
||||
const result = await capture(() => runWithDiagnosticCleanup({
|
||||
run: async () => { throw primary },
|
||||
diagnose: () => observer.diagnose(message => logs.push(message)),
|
||||
cleanup: async () => { observer.detach(); await page.close() },
|
||||
onObservationError: () => assert.fail("deadline must be recorded"),
|
||||
onCleanupError: () => assert.fail("unexpected cleanup error"),
|
||||
}))
|
||||
assert.equal(result.error, primary)
|
||||
assert.equal(JSON.parse(logs[0]).observationError, "header snapshot deadline (2000ms)")
|
||||
})
|
||||
|
||||
test("successful run does not diagnose, detaches, and cleanup failure remains red", async () => {
|
||||
const page = await browser.newPage(), baseline = listenerCounts(page), observer = observeHeaderFixture(page)
|
||||
const cleanupFailure = new Error("cleanup after success")
|
||||
const result = await capture(() => runWithDiagnosticCleanup({
|
||||
run: async () => { await observer.install(); await page.goto(url); return "success" },
|
||||
diagnose: () => { assert.fail("no diagnostics after success") },
|
||||
cleanup: async () => { observer.detach(); assert.deepEqual(listenerCounts(page), baseline); await page.close(); throw cleanupFailure },
|
||||
onObservationError: () => assert.fail("no observation error"),
|
||||
onCleanupError: () => assert.fail("cleanup must propagate"),
|
||||
}))
|
||||
assert.equal(result.error, cleanupFailure)
|
||||
})
|
||||
83
packages/ui/tests/browser/header-fixture-diagnostics.ts
Normal file
83
packages/ui/tests/browser/header-fixture-diagnostics.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import type { Page } from "playwright"
|
||||
|
||||
const maxEvents = 48, maxString = 256, snapshotDeadline = 2000
|
||||
const bounded = (value: string) => value.slice(0, maxString)
|
||||
const errorText = (error: unknown) => bounded(error instanceof Error ? error.message : String(error))
|
||||
function locationLabel(value: string) {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
// Never retain credentials, arbitrary query values or fragments.
|
||||
return bounded(url.origin + url.pathname + (url.searchParams.has("v") ? `?v=${bounded(url.searchParams.get("v")!)}` : ""))
|
||||
} catch { return bounded(value.split(/[?#]/)[0]) }
|
||||
}
|
||||
|
||||
// Installed only on observed composer pages, before their entry module loads.
|
||||
// No console output or async work: stages/errors stay in a small document-local ring.
|
||||
export const headerBootScript = `(() => {
|
||||
const entries = [];
|
||||
const mark = (phase, detail = '') => {
|
||||
entries.push({ phase: String(phase).slice(0, 64), detail: String(detail).slice(0, 256), at: performance.now() });
|
||||
if (entries.length > 16) entries.shift();
|
||||
};
|
||||
window.__headerFixtureBoot = { entries, mark };
|
||||
mark('document-init');
|
||||
addEventListener('error', e => mark('window-error', e.message || 'resource error'));
|
||||
addEventListener('unhandledrejection', e => mark('rejection', String(e.reason)));
|
||||
})()`
|
||||
|
||||
export function observeHeaderFixture(page: Page) {
|
||||
const events: Array<Record<string, string | number>> = []
|
||||
let dropped = 0, detached = false
|
||||
const push = (event: Record<string, string | number>) => {
|
||||
events.push(event)
|
||||
if (events.length > maxEvents) { events.shift(); dropped++ }
|
||||
}
|
||||
const consoleMessage = (message: import("playwright").ConsoleMessage) => push({ kind: "console", level: message.type(), text: bounded(message.text()) })
|
||||
const pageError = (error: Error) => push({ kind: "pageerror", text: errorText(error) })
|
||||
const requestFailed = (request: import("playwright").Request) => push({ kind: "requestfailed", url: locationLabel(request.url()), text: bounded(request.failure()?.errorText ?? "") })
|
||||
const response = (result: import("playwright").Response) => {
|
||||
const request = result.request(), url = new URL(result.url())
|
||||
if (["script", "fetch", "xhr", "eventsource", "document"].includes(request.resourceType()) || url.pathname.includes("/api/")) {
|
||||
push({ kind: "response", url: locationLabel(result.url()), status: result.status() })
|
||||
}
|
||||
}
|
||||
const navigation = (frame: import("playwright").Frame) => {
|
||||
if (frame === page.mainFrame()) push({ kind: "navigation", url: locationLabel(frame.url()) })
|
||||
}
|
||||
page.on("console", consoleMessage)
|
||||
page.on("pageerror", pageError)
|
||||
page.on("requestfailed", requestFailed)
|
||||
page.on("response", response)
|
||||
page.on("framenavigated", navigation)
|
||||
|
||||
return {
|
||||
install: () => page.addInitScript(headerBootScript),
|
||||
async diagnose(emit: (message: string) => void) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let snapshot: unknown, observationError: string | undefined
|
||||
try {
|
||||
snapshot = await Promise.race([
|
||||
page.evaluate(() => {
|
||||
const boot = (window as any).__headerFixtureBoot
|
||||
return { readyState: document.readyState, fixture: typeof (window as any).fixture,
|
||||
boot: boot?.entries?.slice(-16).map((entry: any) => ({ phase: String(entry.phase).slice(0, 64), detail: String(entry.detail).slice(0, 256), at: typeof entry.at === "number" ? entry.at : undefined })),
|
||||
dom: { rootChildren: document.getElementById("root")?.childElementCount ?? 0,
|
||||
textarea: Boolean(document.querySelector("textarea.prompt-input")), footer: Boolean(document.querySelector(".prompt-input-footer")) } }
|
||||
}),
|
||||
new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error("header snapshot deadline (2000ms)")), snapshotDeadline) }),
|
||||
])
|
||||
} catch (error) { observationError = errorText(error) }
|
||||
finally { if (timer !== undefined) clearTimeout(timer) }
|
||||
emit(JSON.stringify({ kind: "header-fixture-diagnostic", url: locationLabel(page.url()), dropped, detached, events: events.slice(), snapshot, observationError }))
|
||||
},
|
||||
detach() {
|
||||
if (detached) return
|
||||
detached = true
|
||||
page.off("console", consoleMessage)
|
||||
page.off("pageerror", pageError)
|
||||
page.off("requestfailed", requestFailed)
|
||||
page.off("response", response)
|
||||
page.off("framenavigated", navigation)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@ import { build } from "esbuild"
|
|||
import { chromium, devices, _electron, type ElectronApplication, type Browser } from "playwright"
|
||||
import { createServer, type ViteDevServer } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { runWithDiagnosticCleanup } from "./fixture-diagnostic-boundary"
|
||||
import { observeHeaderFixture } from "./header-fixture-diagnostics"
|
||||
|
||||
let server: ViteDevServer, browser: Browser, url: string
|
||||
before(async () => {
|
||||
|
|
@ -128,50 +130,58 @@ test("native Electron zoom preserves CSS minimums across close/recreate, shared
|
|||
})
|
||||
|
||||
for (const touch of [false, true]) for (const [width, height] of [[320, 740], [360, 740], [390, 844], [430, 932], [932, 430]]) {
|
||||
test(`composer controls fit ${width}px ${touch ? "touch" : "mouse"} with a preserved draft`, async () => {
|
||||
test(`composer controls fit ${width}px ${touch ? "touch" : "mouse"} with a preserved draft`, async (t) => {
|
||||
const page = await browser.newPage({ ...(touch ? devices["Pixel 5"] : {}), viewport: { width, height } })
|
||||
await page.route("**/api/**", route => route.fulfill({ json: {} }))
|
||||
try {
|
||||
await page.goto(url)
|
||||
await page.waitForFunction(() => Boolean((window as any).fixture))
|
||||
await page.evaluate(() => (window as any).fixture.setLocale("fr"))
|
||||
const input = page.locator("textarea.prompt-input")
|
||||
await input.fill("Brouillon mobile conservé")
|
||||
const geometry = await page.evaluate(() => {
|
||||
const footer = document.querySelector(".prompt-input-footer")!.getBoundingClientRect()
|
||||
const buttons = [...document.querySelectorAll<HTMLElement>(".prompt-context-controls .selector-trigger, .prompt-input-footer-actions button")]
|
||||
.filter(el => el.getBoundingClientRect().width > 0).map(el => {
|
||||
const r = el.getBoundingClientRect()
|
||||
return { x: r.x, y: r.y, right: r.right, bottom: r.bottom, width: r.width, height: r.height }
|
||||
})
|
||||
return { footer: { x: footer.x, right: footer.right, bottom: footer.bottom, width: footer.width }, buttons,
|
||||
selectors: [...document.querySelectorAll(".prompt-context-controls .selector-trigger")].map(el => el.getBoundingClientRect().y),
|
||||
actionsY: document.querySelector(".prompt-input-footer-actions")!.getBoundingClientRect().y }
|
||||
})
|
||||
for (const b of geometry.buttons) {
|
||||
assert.ok(b.x >= geometry.footer.x - 1 && b.right <= geometry.footer.right + 1 && b.bottom <= geometry.footer.bottom + 1, JSON.stringify(b))
|
||||
if (touch) assert.ok(b.width >= 32 && b.height >= 32, "dense touch controls retain compact targets")
|
||||
}
|
||||
for (let i = 0; i < geometry.buttons.length; i++) for (const b of geometry.buttons.slice(i + 1)) {
|
||||
const a = geometry.buttons[i]
|
||||
assert.ok(a.right <= b.x + 1 || b.right <= a.x + 1 || a.bottom <= b.y + 1 || b.bottom <= a.y + 1, "controls must not overlap")
|
||||
}
|
||||
assert.equal(new Set(geometry.selectors).size, 1, "selectors retain one compact row")
|
||||
assert.ok(Math.abs(geometry.actionsY - geometry.selectors[0]) <= 1, "actions and selectors always share one row")
|
||||
if (width === 390) {
|
||||
const worktree = page.locator('.prompt-context-controls[data-has-worktree="true"] > .sidebar-selector').first().getByRole("button")
|
||||
assert.match(await worktree.innerText(), /Espace de travail/)
|
||||
await worktree.focus()
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await page.getByRole("listbox").waitFor({ state: "visible" })
|
||||
await page.keyboard.press("Escape")
|
||||
await page.getByRole("listbox").waitFor({ state: "hidden" })
|
||||
const diagnostics = observeHeaderFixture(page)
|
||||
await runWithDiagnosticCleanup({
|
||||
run: async () => {
|
||||
await page.route("**/api/**", route => route.fulfill({ json: {} }))
|
||||
await diagnostics.install()
|
||||
await page.goto(url)
|
||||
await page.waitForFunction(() => Boolean((window as any).fixture))
|
||||
await page.evaluate(() => (window as any).fixture.setLocale("fr"))
|
||||
const input = page.locator("textarea.prompt-input")
|
||||
await input.fill("Brouillon mobile conservé")
|
||||
const geometry = await page.evaluate(() => {
|
||||
const footer = document.querySelector(".prompt-input-footer")!.getBoundingClientRect()
|
||||
const buttons = [...document.querySelectorAll<HTMLElement>(".prompt-context-controls .selector-trigger, .prompt-input-footer-actions button")]
|
||||
.filter(el => el.getBoundingClientRect().width > 0).map(el => {
|
||||
const r = el.getBoundingClientRect()
|
||||
return { x: r.x, y: r.y, right: r.right, bottom: r.bottom, width: r.width, height: r.height }
|
||||
})
|
||||
return { footer: { x: footer.x, right: footer.right, bottom: footer.bottom, width: footer.width }, buttons,
|
||||
selectors: [...document.querySelectorAll(".prompt-context-controls .selector-trigger")].map(el => el.getBoundingClientRect().y),
|
||||
actionsY: document.querySelector(".prompt-input-footer-actions")!.getBoundingClientRect().y }
|
||||
})
|
||||
for (const b of geometry.buttons) {
|
||||
assert.ok(b.x >= geometry.footer.x - 1 && b.right <= geometry.footer.right + 1 && b.bottom <= geometry.footer.bottom + 1, JSON.stringify(b))
|
||||
if (touch) assert.ok(b.width >= 32 && b.height >= 32, "dense touch controls retain compact targets")
|
||||
}
|
||||
for (let i = 0; i < geometry.buttons.length; i++) for (const b of geometry.buttons.slice(i + 1)) {
|
||||
const a = geometry.buttons[i]
|
||||
assert.ok(a.right <= b.x + 1 || b.right <= a.x + 1 || a.bottom <= b.y + 1 || b.bottom <= a.y + 1, "controls must not overlap")
|
||||
}
|
||||
assert.equal(new Set(geometry.selectors).size, 1, "selectors retain one compact row")
|
||||
assert.ok(Math.abs(geometry.actionsY - geometry.selectors[0]) <= 1, "actions and selectors always share one row")
|
||||
if (width === 390) {
|
||||
const worktree = page.locator('.prompt-context-controls[data-has-worktree="true"] > .sidebar-selector').first().getByRole("button")
|
||||
assert.match(await worktree.innerText(), /Espace de travail/)
|
||||
await worktree.focus()
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await page.getByRole("listbox").waitFor({ state: "visible" })
|
||||
await page.keyboard.press("Escape")
|
||||
await page.getByRole("listbox").waitFor({ state: "hidden" })
|
||||
assert.equal(await input.inputValue(), "Brouillon mobile conservé")
|
||||
}
|
||||
if (process.env.CODENOMAD_MOBILE_CAPTURE) await page.screenshot({ path: `${process.env.CODENOMAD_MOBILE_CAPTURE}/${touch ? "touch" : "mouse"}-${width}.png`, scale: "css" })
|
||||
await page.setViewportSize({ width: 600, height: 851 })
|
||||
assert.equal(await input.inputValue(), "Brouillon mobile conservé")
|
||||
}
|
||||
if (process.env.CODENOMAD_MOBILE_CAPTURE) await page.screenshot({ path: `${process.env.CODENOMAD_MOBILE_CAPTURE}/${touch ? "touch" : "mouse"}-${width}.png`, scale: "css" })
|
||||
await page.setViewportSize({ width: 600, height: 851 })
|
||||
assert.equal(await input.inputValue(), "Brouillon mobile conservé")
|
||||
} finally { await page.close() }
|
||||
},
|
||||
diagnose: () => diagnostics.diagnose(message => t.diagnostic(message)),
|
||||
cleanup: async () => { diagnostics.detach(); await page.close() },
|
||||
onObservationError: () => t.diagnostic("header diagnostic emission failed"),
|
||||
onCleanupError: () => t.diagnostic("header page cleanup failed after primary error"),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -442,13 +442,16 @@ async function dragNativeThumb(page: Page, selector: string, fraction: number) {
|
|||
const height = Math.max(18, track * element.clientHeight / element.scrollHeight)
|
||||
const travel = track - height
|
||||
const ratio = element.scrollTop / (element.scrollHeight - element.clientHeight)
|
||||
return { x: rect.right - inset / 2, top: rect.top + inset, height, travel,
|
||||
return { x: rect.right - inset / 2, ratio, travel,
|
||||
y: ratio > 0.99 ? rect.bottom - 16 : ratio < 0.01 ? rect.top + 16 : rect.top + inset + height / 2 + travel * ratio }
|
||||
})
|
||||
await page.mouse.move(geometry.x, geometry.y)
|
||||
await page.mouse.down()
|
||||
await page.waitForTimeout(750)
|
||||
await page.mouse.move(geometry.x, geometry.top + geometry.height / 2 + geometry.travel * fraction, { steps: 12 })
|
||||
// Native drags retain the grab offset within the thumb. At an end we grab
|
||||
// near its edge, so targeting the future centre undershoots the requested
|
||||
// ratio. Move by the scroll-ratio delta from the actual press instead.
|
||||
await page.mouse.move(geometry.x, geometry.y + geometry.travel * (fraction - geometry.ratio), { steps: 12 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
|
|
|
|||
261
packages/ui/tests/browser/http-admission-responsiveness.test.ts
Normal file
261
packages/ui/tests/browser/http-admission-responsiveness.test.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import assert from "node:assert/strict"
|
||||
import type { IncomingMessage, ServerResponse } from "node:http"
|
||||
import type { Socket } from "node:net"
|
||||
import { after, before, test } from "node:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import Fastify from "fastify"
|
||||
import replyFrom from "@fastify/reply-from"
|
||||
import { OpenCode } from "@opencode/client"
|
||||
import pino from "pino"
|
||||
import { chromium, type Browser, type Page } from "playwright"
|
||||
import { createServer, type ViteDevServer } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { EventBus } from "../../../server/src/events/bus"
|
||||
import { ClientConnectionManager } from "../../../server/src/clients/connection-manager"
|
||||
import { createRuntimeFetch } from "../../../server/src/opencode/compatibility/transport"
|
||||
import { registerInstanceProxyRoutes, type InstanceProxyWorkspaceManager } from "../../../server/src/server/http-server"
|
||||
import { registerEventRoutes } from "../../../server/src/server/routes/events"
|
||||
import { registerMetaRoutes } from "../../../server/src/server/routes/meta"
|
||||
import { WorktreeDeletionFence } from "../../../server/src/workspaces/worktree-session-evacuation"
|
||||
|
||||
let browser: Browser, assets: ViteDevServer, assetUrl: string
|
||||
before(async () => {
|
||||
assets = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)),
|
||||
logLevel: "error", plugins: [solid()], resolve: { dedupe: ["solid-js"] },
|
||||
server: { host: "127.0.0.1", port: 0, cors: true, hmr: false, watch: null },
|
||||
})
|
||||
await assets.listen()
|
||||
assetUrl = `http://127.0.0.1:${(assets.httpServer!.address() as { port: number }).port}`
|
||||
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
|
||||
})
|
||||
after(async () => { await browser?.close(); await assets?.close() })
|
||||
|
||||
async function until(predicate: () => boolean, description: string) {
|
||||
// Only a deadlock safety bound, never a responsiveness/performance threshold.
|
||||
const deadline = Date.now() + 10_000
|
||||
while (!predicate()) {
|
||||
if (Date.now() > deadline) throw new Error(`Did not observe ${description}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
async function harness() {
|
||||
// Reuse the production proxy/admission/fetch/fence as in the server disconnect
|
||||
// diagnostic; use a real upstream SDK instead of copying its fake client tree.
|
||||
const held = Promise.withResolvers<void>()
|
||||
let released = false, nativeObservers = 0, nativeHttpA = 0, aborted = 0, heldHandlers = 0
|
||||
let forwardsA = 0, environmentWrites = 0, invalidations = 0, catalogueRequests = 0
|
||||
const nativeHttpCatalogues = new Set<ServerResponse>()
|
||||
const heldMutations = new Set<ServerResponse>()
|
||||
const captured: Array<{ request: IncomingMessage; response: ServerResponse; abortedListeners: number }> = []
|
||||
const clients = new Set<() => void>(), sockets = new Set<Socket>()
|
||||
const backendErrors: string[] = []
|
||||
const versions = new Set<string>(), bus = new EventBus(), fence = new WorktreeDeletionFence()
|
||||
const logger = pino({ level: "silent" }), connections = new ClientConnectionManager(logger)
|
||||
let peakSockets = 0
|
||||
const upstream = Fastify({ forceCloseConnections: true })
|
||||
upstream.get("/api/session/:id", async (request, reply) => {
|
||||
const id = (request.params as { id: string }).id
|
||||
if (id === "A") {
|
||||
nativeHttpA++
|
||||
reply.raw.once("close", () => nativeHttpA--)
|
||||
heldHandlers++
|
||||
try { await held.promise } // intentionally ignore disconnect; exercise late completion
|
||||
finally { heldHandlers-- }
|
||||
}
|
||||
return { data: { id, location: { directory: "/fixture" } } }
|
||||
})
|
||||
upstream.get("/api/agent/:id", async (_request, reply) => {
|
||||
catalogueRequests++
|
||||
nativeHttpCatalogues.add(reply.raw)
|
||||
reply.raw.once("close", () => nativeHttpCatalogues.delete(reply.raw))
|
||||
heldHandlers++
|
||||
try { await held.promise }
|
||||
finally { heldHandlers-- }
|
||||
return []
|
||||
})
|
||||
upstream.get("/api/location", async () => ({ directory: "/fixture" }))
|
||||
upstream.post("/api/session/:id/:action", async () => { forwardsA++; return {} })
|
||||
upstream.put("/api/session/:id/environment", async (_request, reply) => { environmentWrites++; return reply.code(204).send() })
|
||||
await upstream.listen({ host: "127.0.0.1", port: 0 })
|
||||
const endpoint = { url: `http://127.0.0.1:${(upstream.server.address() as { port: number }).port}` }
|
||||
const runtimeFetch = createRuntimeFetch(endpoint)
|
||||
const native = OpenCode.make({ baseUrl: endpoint.url, fetch: runtimeFetch })
|
||||
const client = { ...native, session: { ...native.session, get: async (...args: Parameters<typeof native.session.get>) => {
|
||||
if (args[0].sessionID !== "A") return native.session.get(...args)
|
||||
nativeObservers++
|
||||
const signal = args[1]?.signal, onAbort = () => { aborted++ }
|
||||
signal?.addEventListener("abort", onAbort, { once: true })
|
||||
try { return await native.session.get(...args) }
|
||||
finally { nativeObservers--; signal?.removeEventListener("abort", onAbort) }
|
||||
} } }
|
||||
const connection = { endpoint, client, fetch: runtimeFetch, assertCurrent() {},
|
||||
invalidate() { invalidations++ }, profile: async () => "modern" as const }
|
||||
// Only workspace ownership is supplied here. Reads cross real SDK HTTP; no
|
||||
// daemon discovery, database, provider, filesystem or shared profile is used.
|
||||
const manager = {
|
||||
get: () => ({ id: "workspace", path: "/fixture" }),
|
||||
getSharedServiceConnection: async () => connection,
|
||||
getInstanceAuthorizationHeader: () => undefined,
|
||||
getServiceDirectory: () => "/fixture",
|
||||
getServiceDirectoryForPath: async () => "/fixture",
|
||||
getWorktreeIdentityForPath: async () => "/fixture",
|
||||
getSessionEnvironment: async () => ({}),
|
||||
ownsDirectory: async (_id: string, directory: string) => directory === "/fixture",
|
||||
ownsLocation: async (_id: string, location: { directory: string }, _client: unknown, signal?: AbortSignal) =>
|
||||
location.directory === "/fixture" && (await native.location.get({ location }, { signal })).directory === "/fixture",
|
||||
} as unknown as InstanceProxyWorkspaceManager
|
||||
const backend = Fastify({ forceCloseConnections: true })
|
||||
backend.addHook("onError", async (_request, _reply, error) => { backendErrors.push(error.stack ?? error.message) })
|
||||
backend.server.on("connection", socket => {
|
||||
sockets.add(socket); peakSockets = Math.max(peakSockets, sockets.size)
|
||||
socket.once("close", () => sockets.delete(socket))
|
||||
})
|
||||
backend.addHook("onRequest", async (request, reply) => {
|
||||
versions.add(request.raw.httpVersion)
|
||||
if (request.method === "POST" && /\/session\/A\/(prompt|compact)$/.test(request.url)) {
|
||||
captured.push({ request: request.raw, response: reply.raw, abortedListeners: request.raw.listenerCount("aborted") })
|
||||
heldMutations.add(reply.raw)
|
||||
reply.raw.once("close", () => { setImmediate(() => heldMutations.delete(reply.raw)) })
|
||||
}
|
||||
})
|
||||
await backend.register(replyFrom)
|
||||
registerInstanceProxyRoutes(backend, { workspaceManager: manager, logger, worktreeDeletionFence: fence })
|
||||
registerEventRoutes(backend, { eventBus: bus, logger, connectionManager: connections,
|
||||
registerClient: close => { clients.add(close); return () => { clients.delete(close) } },
|
||||
})
|
||||
backend.get("/api/auth/status", async () => ({ authenticated: true }))
|
||||
backend.get("/api/witness", async () => ({ witness: true }))
|
||||
// Business HTTP/SSE goes directly to Fastify, not through Vite's Node proxy.
|
||||
// Vite serves modules on a separate origin and cannot consume this HTTP budget.
|
||||
backend.get("/fixture", async (_request, reply) => reply.type("text/html").send(
|
||||
`<html><body><div id="root"></div><script type="module" src="${assetUrl}/tests/browser/fixtures/http-admission-responsiveness.tsx"></script></body></html>`,
|
||||
))
|
||||
const meta = { localUrl: "", eventsUrl: "/api/events", host: "127.0.0.1", listeningMode: "local" as const,
|
||||
localPort: 0, hostLabel: "private fixture", workspaceRoot: "/fixture", addresses: [] }
|
||||
registerMetaRoutes(backend, { serverMeta: meta })
|
||||
await backend.listen({ host: "127.0.0.1", port: 0 })
|
||||
meta.localPort = (backend.server.address() as { port: number }).port
|
||||
const url = meta.localUrl = `http://127.0.0.1:${meta.localPort}`
|
||||
return {
|
||||
url, captured, fence,
|
||||
emit() { bus.publish({ type: "instance.eventStatus", instanceId: "workspace", status: "connected", generation: 1 }) },
|
||||
release() { released = true; held.resolve() },
|
||||
snapshot() { return { released, nativeObservers, nativeHttpA, aborted, heldHandlers, forwardsA, environmentWrites, invalidations,
|
||||
catalogueRequests, nativeHttpCatalogues: nativeHttpCatalogues.size, heldMutations: heldMutations.size,
|
||||
sse: clients.size, sockets: sockets.size, peakSockets, versions: [...versions], backendErrors } },
|
||||
async dispose() {
|
||||
held.resolve(); connections.shutdown(); for (const close of clients) close()
|
||||
await backend.close(); await upstream.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(page: Page, h: Awaited<ReturnType<typeof harness>>) {
|
||||
const errors: string[] = [], protocols = new Map<string, string>()
|
||||
page.on("pageerror", error => errors.push(error.message))
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send("Network.enable")
|
||||
cdp.on("Network.responseReceived", ({ response }) => {
|
||||
if (response.url.startsWith(h.url)) protocols.set(new URL(response.url).pathname, response.protocol)
|
||||
})
|
||||
await page.goto(`${h.url}/fixture`)
|
||||
await page.getByRole("button", { name: "Switch to B" }).waitFor()
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().opens === 1)
|
||||
await until(() => h.snapshot().sse === 1, "one real SSE subscription")
|
||||
const previousEvents = await page.evaluate(() => (window as any).fixture.snapshot().events)
|
||||
h.emit()
|
||||
await page.waitForFunction(previous => (window as any).fixture.snapshot().events > previous, previousEvents)
|
||||
return { errors, protocols }
|
||||
}
|
||||
|
||||
for (const scenario of [
|
||||
{ action: "prompt", close: "page", background: false },
|
||||
{ action: "compact", close: "page", background: false },
|
||||
{ action: "prompt", close: "context", background: false },
|
||||
{ action: "compact", close: "context", background: false },
|
||||
{ action: "compact", close: "context", background: true },
|
||||
] as const) {
|
||||
test(`HTTP/1 SSE + held ${scenario.action}: ${scenario.close} reconnect${scenario.background ? " with separate backgroundReads(2) occupancy" : ""}`,
|
||||
{ timeout: 60_000 }, async t => {
|
||||
const h = await harness(), context = await browser.newContext()
|
||||
let reconnectContext = context
|
||||
try {
|
||||
const page = await context.newPage(), diagnostics = await setup(page, h)
|
||||
await page.evaluate(action => (window as any).fixture.start(action), scenario.action)
|
||||
await until(() => h.snapshot().nativeHttpA === 1, "actual SDK session A HTTP preflight")
|
||||
if (scenario.background) {
|
||||
await page.evaluate(() => (window as any).fixture.catalogues())
|
||||
await until(() => h.snapshot().catalogueRequests === 2, "two admitted background catalogue reads")
|
||||
}
|
||||
const started = performance.now()
|
||||
await page.getByRole("textbox", { name: "Draft" }).fill("draft while A is held")
|
||||
await page.getByRole("button", { name: "Switch to B" }).click()
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().loaded === "B" || (window as any).fixture.snapshot().errors.length > 0)
|
||||
assert.equal((await page.evaluate(() => (window as any).fixture.snapshot())).loaded, "B", JSON.stringify({ backend: h.snapshot(), browser: await page.evaluate(() => (window as any).fixture.snapshot()) }))
|
||||
const info = await page.evaluate(() => (window as any).fixture.info())
|
||||
const witness = await page.evaluate(() => (window as any).fixture.witness())
|
||||
assert.deepEqual(info.system, { platform: process.platform, arch: process.arch })
|
||||
assert.deepEqual(witness, { witness: true })
|
||||
const state = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
assert.equal(state.draft, "draft while A is held")
|
||||
assert.equal(state.selected, "B")
|
||||
assert.equal(state.pending, true)
|
||||
assert.deepEqual(state.errors, [])
|
||||
const during = h.snapshot()
|
||||
assert.equal(during.released, false)
|
||||
assert.equal(during.nativeObservers, 1)
|
||||
assert.equal(during.sse, 1)
|
||||
assert.equal(during.forwardsA, 0)
|
||||
assert.equal(during.heldHandlers, scenario.background ? 3 : 1)
|
||||
assert.deepEqual(during.backendErrors, [])
|
||||
if (scenario.background) {
|
||||
assert.equal(during.catalogueRequests, 2, "three catalogue intents must remain queued, not occupy HTTP sockets")
|
||||
assert.equal(during.nativeHttpCatalogues, 2)
|
||||
assert.equal(state.catalogueSettled, 0)
|
||||
}
|
||||
for (const path of ["/api/events", "/api/witness", "/api/meta", "/workspaces/workspace/instance/api/session/B"]) {
|
||||
assert.equal(diagnostics.protocols.get(path), "http/1.1", `${path} must use real Chromium HTTP/1.1`)
|
||||
}
|
||||
t.diagnostic(`browser=${browser.version()} platform=${process.platform}; held-progress=${Math.round(performance.now() - started)} ms; during=${JSON.stringify(during)}`)
|
||||
assert.deepEqual(diagnostics.errors, [])
|
||||
|
||||
const closing = performance.now()
|
||||
if (scenario.close === "page") await page.close()
|
||||
else { await context.close(); reconnectContext = await browser.newContext() }
|
||||
await until(() => {
|
||||
const state = h.snapshot()
|
||||
return state.sse === 0 && state.heldMutations === 0 && state.nativeHttpA === 0
|
||||
&& state.nativeObservers === 0 && state.nativeHttpCatalogues === 0
|
||||
}, "downstream/SSE/upstream observers retired before releasing the held handler")
|
||||
assert.equal(h.snapshot().aborted, 1)
|
||||
for (const entry of h.captured) {
|
||||
assert.equal(entry.request.listenerCount("aborted"), entry.abortedListeners)
|
||||
assert.equal(entry.response.listeners("close").some(listener => ["close", "disconnect"].includes(listener.name)), false)
|
||||
}
|
||||
await h.fence.run("/fixture", ["/fixture"], async () => {})
|
||||
assert.equal(h.snapshot().heldHandlers, scenario.background ? 3 : 1, "upstream handlers stay deliberately held after HTTP observers leave")
|
||||
t.diagnostic(`close-cleanup=${Math.round(performance.now() - closing)} ms; before-release=${JSON.stringify(h.snapshot())}; deletion fence drained`)
|
||||
|
||||
const next = await reconnectContext.newPage(), reconnected = await setup(next, h)
|
||||
await next.getByRole("button", { name: "Switch to B" }).click()
|
||||
await next.waitForFunction(() => (window as any).fixture.snapshot().loaded === "B")
|
||||
assert.deepEqual(await next.evaluate(() => (window as any).fixture.witness()), { witness: true })
|
||||
assert.equal(h.snapshot().released, false, "new page must progress before the old upstream handler is released")
|
||||
assert.equal(h.snapshot().sse, 1)
|
||||
const beforeRelease = h.snapshot()
|
||||
h.release()
|
||||
await until(() => h.snapshot().heldHandlers === 0, "all deliberately held upstream continuations finished")
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
assert.equal(h.snapshot().forwardsA, 0, "late preflight completion must not forward/replay the abandoned mutation")
|
||||
assert.equal(h.snapshot().environmentWrites, 0)
|
||||
assert.equal(h.snapshot().invalidations, 0)
|
||||
assert.equal(h.snapshot().catalogueRequests, beforeRelease.catalogueRequests, "destroyed page cannot restart queued catalogue intents")
|
||||
assert.deepEqual(h.snapshot().versions, ["1.1"])
|
||||
assert.deepEqual(h.snapshot().backendErrors, [])
|
||||
assert.deepEqual(reconnected.errors, [])
|
||||
t.diagnostic(`reconnected-before-release=${JSON.stringify(beforeRelease)}; no late mutation/environment/invalidation`)
|
||||
} finally { await reconnectContext.close(); await context.close(); await h.dispose() }
|
||||
})
|
||||
}
|
||||
344
packages/ui/tests/browser/render-cost.test.ts
Normal file
344
packages/ui/tests/browser/render-cost.test.ts
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { after, before, test } from "node:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { chromium, type Browser, type Page } from "playwright"
|
||||
import { createServer, type ViteDevServer } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
|
||||
let server: ViteDevServer, browser: Browser, url: string
|
||||
let cacheDirectory: string | undefined
|
||||
before(async () => {
|
||||
// Concurrent browser fixtures must not replace this run's optimized modules.
|
||||
cacheDirectory = await mkdtemp(join(tmpdir(), "codenomad-render-vite-"))
|
||||
server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error", cacheDir: cacheDirectory,
|
||||
plugins: [solid(), { name: "render-cost-fixture", configureServer(s) {
|
||||
s.middlewares.use("/fixture", async (req, res) => {
|
||||
res.setHeader("Content-Type", "text/html")
|
||||
const name = req.url?.includes("mode=timeline") ? "render-cost-timeline" : "render-cost"
|
||||
res.end(await s.transformIndexHtml("/fixture", `<html><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/${name}.tsx"></script></body></html>`))
|
||||
})
|
||||
}, async load(id) {
|
||||
// Optional audit-only A/B source override; never changes the checkout.
|
||||
if (process.env.CODENOMAD_RENDER_TASK_BASELINE && id.replaceAll("\\", "/").endsWith("/src/components/tool-call/renderers/task.tsx")) {
|
||||
return readFile(process.env.CODENOMAD_RENDER_TASK_BASELINE, "utf8")
|
||||
}
|
||||
} }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] },
|
||||
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null },
|
||||
})
|
||||
await server.listen()
|
||||
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture`
|
||||
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
|
||||
})
|
||||
after(async () => {
|
||||
await browser?.close()
|
||||
await server?.close()
|
||||
if (cacheDirectory) await rm(cacheDirectory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function open(query: string, run: (page: Page) => Promise<void>) {
|
||||
const page = await browser.newPage({ viewport: { width: 1100, height: 800 }, locale: "en-US" })
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", error => { errors.push(error.message); console.error("fixture-error", error.message) })
|
||||
await page.route("**/*", route => {
|
||||
const request = new URL(route.request().url())
|
||||
if (request.origin !== new URL(url).origin) return route.abort("blockedbyclient")
|
||||
if (request.pathname.startsWith("/api/")) return route.fulfill({ contentType: "application/json", body: "{}" })
|
||||
return route.continue()
|
||||
})
|
||||
try {
|
||||
await page.goto(`${url}?${query}`)
|
||||
await page.waitForFunction(() => Boolean((window as any).fixture))
|
||||
await run(page)
|
||||
assert.deepEqual(errors, [])
|
||||
} finally { await page.close() }
|
||||
}
|
||||
const step = (page: Page, index = 0) => page.locator(`.tool-call[data-part-id="step-${index}"]`)
|
||||
const frames = (page: Page) => page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
|
||||
|
||||
test("unrelated child text keeps mounted task steps, disclosure, scroller and copies", async () => open("mode=task&count=12", async page => {
|
||||
const tool = step(page)
|
||||
await tool.waitFor()
|
||||
await page.evaluate(() => (window as any).fixture.startText())
|
||||
await frames(page)
|
||||
await tool.locator(":scope > .tool-call-header > .tool-call-header-toggle").click()
|
||||
await tool.locator("pre code").waitFor()
|
||||
await frames(page)
|
||||
await tool.evaluate(element => { (window as any).originalStep = element })
|
||||
await page.evaluate(() => (window as any).fixture.reproject())
|
||||
assert.equal(await tool.evaluate(element => element === (window as any).originalStep), true, "authoritative reprojection must not recreate the tool shell")
|
||||
const output = tool.locator(".tool-call-markdown")
|
||||
const box = (await output.boundingBox())!
|
||||
await page.mouse.move(box.x + 30, box.y + 60)
|
||||
await page.mouse.down({ button: "middle" })
|
||||
try {
|
||||
await output.evaluate(element => { (window as any).originalScroller = element; element.scrollTop = 240 })
|
||||
await page.evaluate(() => { (window as any).fixture.hold(); (window as any).fixture.reset(); (window as any).fixture.delta() })
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().held > 0)
|
||||
assert.equal(await tool.evaluate(element => element === (window as any).originalStep), true, "pending display refresh must retain mounted steps")
|
||||
await page.evaluate(() => (window as any).fixture.release())
|
||||
await frames(page)
|
||||
const state = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
console.log("unrelated-update", { snapshots: state.snapshots, childRevision: state.childRevision, added: state.added, removed: state.removed })
|
||||
assert.equal(await tool.evaluate(element => element === (window as any).originalStep), true, "unrelated text must not remount a completed tool")
|
||||
assert.equal(await tool.locator(":scope > .tool-call-header > .tool-call-header-toggle").getAttribute("aria-expanded"), "true")
|
||||
assert.equal(await output.evaluate(element => element === (window as any).originalScroller), true)
|
||||
assert.equal(await output.evaluate(element => element.scrollTop), 240)
|
||||
assert.equal(state.added + state.removed, 0)
|
||||
} finally { await page.mouse.up({ button: "middle" }) }
|
||||
await tool.locator(":scope > .tool-call-header > .tool-call-header-copy").first().click()
|
||||
const state = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
assert.equal(state.clipboard[0], state.output)
|
||||
await page.evaluate(() => (window as any).fixture.toolUpdate(0, "Changed native output"))
|
||||
await page.waitForFunction(() => document.querySelector('.tool-call[data-part-id="step-0"]')?.textContent?.includes("Changed native output"))
|
||||
assert.equal(await tool.evaluate(element => element === (window as any).originalStep), true)
|
||||
await tool.locator(":scope > .tool-call-header > .tool-call-header-copy").first().click()
|
||||
assert.equal((await page.evaluate(() => (window as any).fixture.snapshot())).clipboard.at(-1), "Changed native output")
|
||||
await page.evaluate(() => (window as any).fixture.mutateTool())
|
||||
await page.waitForFunction(() => document.querySelector('.tool-call[data-part-id="step-0"]')?.textContent?.includes("Versioned in-place output"))
|
||||
assert.equal(await tool.evaluate(element => element === (window as any).originalStep), true)
|
||||
await page.evaluate(() => (window as any).fixture.failTool(0))
|
||||
await tool.locator(".tool-call-error-content").waitFor()
|
||||
assert.match((await tool.textContent())!, /Deterministic failure/)
|
||||
await page.evaluate(() => (window as any).fixture.removeTool(0))
|
||||
await tool.waitFor({ state: "detached" })
|
||||
await page.evaluate(() => (window as any).fixture.clearChild())
|
||||
assert.equal(await page.locator('.tool-call[data-part-id^="step-"]').count(), 0, "authoritative empty pages must clear stale displayed steps")
|
||||
}))
|
||||
|
||||
test("a failed child refresh keeps the last display snapshot with an explicit retry error", async () => open("mode=task&count=12", async page => {
|
||||
const tool = step(page)
|
||||
await tool.waitFor()
|
||||
await page.evaluate(() => (window as any).fixture.startText())
|
||||
await frames(page)
|
||||
await tool.locator(":scope > .tool-call-header > .tool-call-header-toggle").click()
|
||||
await tool.locator("pre code").waitFor()
|
||||
await tool.evaluate(element => { (window as any).originalStep = element })
|
||||
await page.evaluate(() => (window as any).fixture.failRead())
|
||||
await page.getByRole("alert").filter({ hasText: "Deterministic read failure" }).waitFor()
|
||||
assert.equal(await tool.evaluate(element => element === (window as any).originalStep), true)
|
||||
assert.equal(await tool.locator(":scope > .tool-call-header > .tool-call-header-toggle").getAttribute("aria-expanded"), "true")
|
||||
await page.getByRole("alert").getByRole("button").click()
|
||||
await page.getByRole("alert").waitFor({ state: "detached" })
|
||||
assert.equal(await tool.evaluate(element => element === (window as any).originalStep), true)
|
||||
}))
|
||||
|
||||
test("native page output must not reuse zero-revision Markdown when a tool is reopened", async () => open("mode=task&count=1", async page => {
|
||||
const tool = step(page), toggle = tool.locator(":scope > .tool-call-header > .tool-call-header-toggle")
|
||||
await tool.waitFor()
|
||||
await toggle.click()
|
||||
await tool.locator("pre code").waitFor()
|
||||
await page.evaluate(() => (window as any).fixture.toolUpdate(0, "Replacement native page output"))
|
||||
await page.waitForFunction(() => (window as any).fixture.toolOutput() === "Replacement native page output")
|
||||
await frames(page)
|
||||
// The old renderer closes on reprojection. Reopen it to distinguish a stale
|
||||
// render-cache hit from the separately asserted disclosure/remount defect.
|
||||
if (await toggle.getAttribute("aria-expanded") === "false") await toggle.click()
|
||||
await tool.locator("pre code").waitFor()
|
||||
await frames(page)
|
||||
assert.equal((await tool.locator("pre code").textContent())?.trim() === "Replacement native page output", true,
|
||||
"the native record changed but the displayed Markdown is stale")
|
||||
await tool.locator(":scope > .tool-call-header > .tool-call-header-copy").first().click()
|
||||
assert.equal((await page.evaluate(() => (window as any).fixture.snapshot())).clipboard.at(-1), "Replacement native page output")
|
||||
}))
|
||||
|
||||
test("changing an indexed task key does not transfer a removed tool's disclosure", async () => open("mode=task&count=200", async page => {
|
||||
const first = step(page)
|
||||
await first.waitFor()
|
||||
await first.locator(":scope > .tool-call-header > .tool-call-header-toggle").click()
|
||||
await first.locator("pre code").waitFor()
|
||||
await page.evaluate(() => (window as any).fixture.appendTool())
|
||||
await first.waitFor({ state: "detached" })
|
||||
assert.equal(await step(page, 1).locator(":scope > .tool-call-header > .tool-call-header-toggle").getAttribute("aria-expanded"), "false")
|
||||
assert.equal(await page.locator('.tool-call[data-part-id="appended-step"]').count(), 1)
|
||||
}))
|
||||
|
||||
for (const retainedIndex of [0, 1]) {
|
||||
test(`authoritative task membership 2->1->2 preserves retained step-${retainedIndex}`, async () => open("mode=task&count=2", async page => {
|
||||
const retained = step(page, retainedIndex)
|
||||
const counter = page.locator('.tool-call[data-part-id="parent-task"] .tool-call-task-section-meta').filter({ hasText: /^\d+ steps$/ })
|
||||
await retained.waitFor()
|
||||
assert.equal(await counter.textContent(), "2 steps")
|
||||
await retained.locator(":scope > .tool-call-header > .tool-call-header-toggle").click()
|
||||
const output = retained.locator(".tool-call-markdown")
|
||||
await retained.locator("pre code").waitFor()
|
||||
await frames(page)
|
||||
await retained.evaluate(element => { (window as any).retainedShell = element })
|
||||
await output.evaluate(element => { (window as any).retainedScroller = element; element.scrollTop = 240 })
|
||||
|
||||
await page.evaluate(index => (window as any).fixture.replaceChildTools([index]), retainedIndex)
|
||||
await frames(page)
|
||||
assert.equal(await page.locator('.tool-call[data-part-id^="step-"]').count(), 1)
|
||||
assert.equal(await counter.textContent(), "1 steps", "removed parts must also leave the membership counter")
|
||||
await page.evaluate(index => (window as any).fixture.replaceChildTools([index, 2]), retainedIndex)
|
||||
await frames(page)
|
||||
assert.equal(await counter.textContent(), "2 steps")
|
||||
assert.equal(await step(page, 2).count(), 1, "new identity at the former cardinality must appear exactly once")
|
||||
assert.equal(await retained.evaluate(element => element === (window as any).retainedShell), true)
|
||||
assert.equal(await retained.locator(":scope > .tool-call-header > .tool-call-header-toggle").getAttribute("aria-expanded"), "true")
|
||||
assert.equal(await output.evaluate(element => element === (window as any).retainedScroller), true)
|
||||
assert.equal(await output.evaluate(element => element.scrollTop), 240)
|
||||
}))
|
||||
}
|
||||
|
||||
test("authoritative task membership replaces and reorders equal-cardinality identities", async () => open("mode=task&count=2", async page => {
|
||||
const retained = step(page, 0)
|
||||
const counter = page.locator('.tool-call[data-part-id="parent-task"] .tool-call-task-section-meta').filter({ hasText: /^\d+ steps$/ })
|
||||
await retained.waitFor()
|
||||
await retained.locator(":scope > .tool-call-header > .tool-call-header-toggle").click()
|
||||
await retained.locator("pre code").waitFor()
|
||||
await retained.evaluate(element => { (window as any).retainedShell = element })
|
||||
await page.evaluate(() => (window as any).fixture.replaceChildTools([2, 0]))
|
||||
await frames(page)
|
||||
assert.equal(await counter.textContent(), "2 steps")
|
||||
assert.deepEqual(await page.locator('.tool-call[data-part-id^="step-"]').evaluateAll(elements => elements.map(element => element.getAttribute("data-part-id"))), ["step-2", "step-0"])
|
||||
assert.equal(await step(page, 1).count(), 0)
|
||||
assert.equal(await retained.evaluate(element => element === (window as any).retainedShell), true)
|
||||
assert.equal(await retained.locator(":scope > .tool-call-header > .tool-call-header-toggle").getAttribute("aria-expanded"), "true")
|
||||
}))
|
||||
|
||||
test("authoritative task membership drops a tool changed to text under the same part id", async () => open("mode=task&count=2", async page => {
|
||||
await step(page, 1).waitFor()
|
||||
await page.evaluate(() => (window as any).fixture.replaceChildTools([0, 1], [1]))
|
||||
await frames(page)
|
||||
const counter = page.locator('.tool-call[data-part-id="parent-task"] .tool-call-task-section-meta').filter({ hasText: /^\d+ steps$/ })
|
||||
assert.equal(await counter.textContent(), "1 steps")
|
||||
assert.equal(await step(page, 1).count(), 0)
|
||||
assert.equal(await step(page, 0).count(), 1)
|
||||
}))
|
||||
|
||||
test("authoritative task membership keeps the 200-step bound and clears stale truncation after shrink", async () => open("mode=task&count=201", async page => {
|
||||
await step(page, 200).waitFor()
|
||||
const counter = page.locator('.tool-call[data-part-id="parent-task"] .tool-call-task-section-meta').filter({ hasText: /^\d+\+? steps$/ })
|
||||
assert.equal(await counter.textContent(), "200+ steps")
|
||||
assert.equal(await page.locator('.tool-call[data-part-id^="step-"]').count(), 200)
|
||||
await page.evaluate(() => (window as any).fixture.replaceChildTools(Array.from({ length: 199 }, (_, index) => index + 2)))
|
||||
await frames(page)
|
||||
assert.equal(await counter.textContent(), "199 steps")
|
||||
assert.equal(await page.locator('.tool-call[data-part-id^="step-"]').count(), 199)
|
||||
assert.equal(await page.locator('.tool-call[data-part-id="parent-task"] .tool-call-diagnostic-message').count(), 0)
|
||||
}))
|
||||
|
||||
for (const { count, legacy } of [{ count: 2, legacy: false }, { count: 201, legacy: false }, { count: 201, legacy: true }]) {
|
||||
test(`native batched child deletion clears task membership (count=${count}, legacy=${legacy})`, async () => open(`mode=task&count=${count}${legacy ? "&legacy=1" : ""}`, async page => {
|
||||
const parent = page.locator('.tool-call[data-part-id="parent-task"]')
|
||||
const counter = parent.locator(".tool-call-task-section-meta").filter({ hasText: /^\d+\+? steps$/ })
|
||||
await step(page, count - 1).waitFor()
|
||||
assert.equal(await counter.textContent(), `${count > 200 ? "200+" : count} steps`)
|
||||
assert.equal(await page.locator('.tool-call[data-part-id^="step-"]').count(), Math.min(count, 200))
|
||||
assert.equal(await parent.locator('[data-task-id="legacy-step"]').count(), 0, "native steps supersede the parent's legacy summary")
|
||||
await parent.evaluate(element => { (window as any).originalParentTask = element })
|
||||
await page.evaluate(() => (window as any).fixture.deleteChild())
|
||||
await frames(page)
|
||||
const state = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
assert.deepEqual(state.childMessageIds, [])
|
||||
assert.equal(state.childLoaded, false)
|
||||
assert.equal(state.childExists, false)
|
||||
assert.equal(state.parentChildId, "child", "native child deletion must not need a parent metadata update")
|
||||
assert.equal(state.loaded, 1, "parent transcript remains resident")
|
||||
assert.equal(await parent.evaluate(element => element === (window as any).originalParentTask), true)
|
||||
assert.equal(await page.locator('.tool-call[data-part-id^="step-"]').count(), 0)
|
||||
assert.equal(await parent.locator(".tool-call-diagnostic-message").count(), 0, "deleted native membership must clear its truncation state")
|
||||
if (legacy) {
|
||||
assert.equal(await counter.textContent(), "1 steps", "fallback count must use the parent's legacy summary, not deleted native keys")
|
||||
assert.equal(await parent.locator('[data-task-id="legacy-step"]').textContent().then(text => text?.includes("Parent legacy summary")), true)
|
||||
} else {
|
||||
assert.equal(await counter.count(), 0, "a deleted child without legacy steps must not retain a ghost count")
|
||||
assert.equal(await parent.getByText("Steps", { exact: true }).count(), 0)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
test("audit sample: deterministic native child deltas (timings are observations, not a CI threshold)", async () => open(`mode=task&count=80&instrument=${process.env.CODENOMAD_RENDER_INSTRUMENT ?? "on"}`, async page => {
|
||||
await step(page, 79).waitFor()
|
||||
await page.evaluate(() => (window as any).fixture.startText())
|
||||
await frames(page)
|
||||
await page.evaluate(() => (window as any).fixture.measure(3))
|
||||
await frames(page)
|
||||
const samples = []
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send("Performance.enable")
|
||||
const metrics = async () => {
|
||||
const { metrics } = await cdp.send("Performance.getMetrics")
|
||||
const ms = (name: string) => metrics.find(metric => metric.name === name)!.value * 1000
|
||||
return { scriptMs: ms("ScriptDuration"), taskMs: ms("TaskDuration"), layoutMs: ms("LayoutDuration"), styleMs: ms("RecalcStyleDuration") }
|
||||
}
|
||||
for (let index = 0; index < 10; index++) {
|
||||
const before = await metrics()
|
||||
const sync = await page.evaluate(() => { const f = (window as any).fixture; f.reset(); return f.measure(1) })
|
||||
await frames(page)
|
||||
const state = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
const after = await metrics()
|
||||
samples.push({ syncMs: sync.ms, scriptMs: after.scriptMs - before.scriptMs, taskMs: after.taskMs - before.taskMs,
|
||||
layoutMs: after.layoutMs - before.layoutMs, styleMs: after.styleMs - before.styleMs,
|
||||
snapshots: state.snapshots, added: state.added, removed: state.removed })
|
||||
}
|
||||
console.log("render-cost-native-deltas", JSON.stringify({ variant: process.env.CODENOMAD_RENDER_TASK_BASELINE ? "baseline" : "fixed",
|
||||
browser: browser.version(), instrumented: process.env.CODENOMAD_RENDER_INSTRUMENT !== "off", samples }))
|
||||
if (!process.env.CODENOMAD_RENDER_TASK_BASELINE && process.env.CODENOMAD_RENDER_INSTRUMENT !== "off") {
|
||||
assert.ok(samples.every(sample => sample.added === 0 && sample.removed === 0), "structural rescans must not remount unchanged task shells")
|
||||
}
|
||||
assert.equal(await page.locator('.tool-call[data-part-id^="step-"]').count(), 80)
|
||||
}))
|
||||
|
||||
test("large tool output bounds rendered text but preserves full clipboard source", async () => open("mode=task&count=1&size=2000000", async page => {
|
||||
const tool = step(page)
|
||||
await tool.waitFor()
|
||||
assert.equal(await tool.locator(".tool-call-details").count(), 0, "collapsed steps must not mount their bodies")
|
||||
const start = await page.evaluate(() => performance.now())
|
||||
await tool.locator(":scope > .tool-call-header > .tool-call-header-toggle").click()
|
||||
await tool.locator("pre code").waitFor()
|
||||
const content = await tool.locator("pre code").textContent()
|
||||
assert.ok(content && content.length <= 10_000)
|
||||
assert.ok((await tool.textContent())!.includes("Output truncated"))
|
||||
console.log("large-output", { renderedCharacters: content!.length, ...await page.evaluate(start => ({ msToObservedRender: performance.now() - start, nodes: document.querySelectorAll("*").length }), start) })
|
||||
await tool.locator(":scope > .tool-call-header > .tool-call-header-copy").first().click()
|
||||
const state = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
assert.equal(state.clipboard[0], state.output)
|
||||
assert.equal(state.output.length, 2_000_000)
|
||||
}))
|
||||
|
||||
test("transcript loads one bounded history page and mounts only virtual rows", async () => open("mode=transcript&count=1000", async page => {
|
||||
await page.waitForFunction(() => document.querySelector(".message-stream")?.textContent?.includes("History 999"))
|
||||
await frames(page)
|
||||
const state = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
const rows = await page.locator("[data-virtual-follow-key]").count()
|
||||
const markers = await page.locator(".timeline-virtual-row").count()
|
||||
console.log("transcript-bounds", { loaded: state.loaded, rows, markers, requests: state.requests })
|
||||
assert.equal(state.loaded, 200)
|
||||
assert.ok(rows < 40)
|
||||
assert.ok(markers < 80)
|
||||
}))
|
||||
|
||||
test("timeline exact extent, bounded DOM and active highlight updates do not mount messages", async () => open("mode=timeline&count=10000", async page => {
|
||||
await page.locator('.message-timeline-segment[data-message-id="message-0"]').first().waitFor()
|
||||
await frames(page)
|
||||
const samples = []
|
||||
for (let index = 0; index < 10; index++) {
|
||||
samples.push(await page.evaluate(() => (window as any).fixture.measure(30)))
|
||||
await frames(page)
|
||||
}
|
||||
console.log("timeline-active-updates", JSON.stringify({ samples, snapshot: await page.evaluate(() => (window as any).fixture.snapshot()) }))
|
||||
const geometry = await page.evaluate(() => {
|
||||
const content = document.querySelector<HTMLElement>(".timeline-virtual-content")!
|
||||
const marker = document.querySelector<HTMLElement>(".timeline-measure button")!
|
||||
const gap = document.querySelector<HTMLElement>(".timeline-measure div")!
|
||||
const markerHeight = parseFloat(getComputedStyle(marker).height), gapHeight = parseFloat(getComputedStyle(gap).height)
|
||||
// Each 3-marker group has a double gap before its tool and following user.
|
||||
let units = 0
|
||||
for (let index = 1; index < 10000; index++) units += index % 3 === 2 ? 1 : 2
|
||||
return { extent: parseFloat(content.style.height), expected: markerHeight * 10000 + gapHeight * units, mounted: document.querySelectorAll(".timeline-virtual-row").length }
|
||||
})
|
||||
console.log("timeline-geometry", geometry)
|
||||
// CSS serialization and native scroll extents round at this magnitude.
|
||||
assert.ok(Math.abs(geometry.extent - geometry.expected) < 1)
|
||||
assert.ok(geometry.mounted < 80)
|
||||
assert.equal(await page.locator(".tool-call, [data-view=message-item]").count(), 0)
|
||||
await page.locator('.message-timeline-segment[data-message-id="message-0"]').first().hover()
|
||||
await page.getByRole("tooltip").locator("strong").waitFor()
|
||||
assert.ok((await page.getByRole("tooltip").textContent())!.length <= 4096)
|
||||
await page.locator('.message-timeline-segment[data-message-id="message-0"]').first().click()
|
||||
assert.equal((await page.evaluate(() => (window as any).fixture.snapshot())).selected, "marker-0")
|
||||
}))
|
||||
|
|
@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"
|
|||
import { chromium, type Browser, type Page } from "playwright"
|
||||
import { createServer, type ViteDevServer } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { runWithDiagnosticCleanup } from "./fixture-diagnostic-boundary"
|
||||
|
||||
const root = fileURLToPath(new URL("../..", import.meta.url))
|
||||
let server: ViteDevServer, browser: Browser, baseUrl: string
|
||||
|
|
@ -34,7 +35,15 @@ after(async () => { await browser?.close(); await server?.close() })
|
|||
async function open(name: string, run: (page: Page) => Promise<void>) {
|
||||
const page = await browser.newPage({ viewport: { width: 1100, height: 700 }, locale: "en-US" })
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", error => errors.push(error.message))
|
||||
const boundaryDiagnostics: Array<Record<string, unknown>> = []
|
||||
page.on("pageerror", error => {
|
||||
errors.push(error.message)
|
||||
if (process.env.CODENOMAD_BROWSER_BOUNDARY_TRACE) boundaryDiagnostics.push({ kind: "pageerror", message: error.message, stack: error.stack })
|
||||
})
|
||||
if (process.env.CODENOMAD_BROWSER_BOUNDARY_TRACE) {
|
||||
page.on("console", message => boundaryDiagnostics.push({ kind: "console", type: message.type(), text: message.text() }))
|
||||
page.on("requestfailed", request => boundaryDiagnostics.push({ kind: "requestfailed", url: request.url(), error: request.failure()?.errorText }))
|
||||
}
|
||||
await page.addInitScript(`(() => {
|
||||
window.fixtureScrollEvents = [];
|
||||
for (const type of ['wheel', 'scroll', 'pointerdown']) document.addEventListener(type, event => {
|
||||
|
|
@ -46,18 +55,23 @@ async function open(name: string, run: (page: Page) => Promise<void>) {
|
|||
}, { capture: true, passive: true });
|
||||
})()`)
|
||||
await page.route("**/api/**", route => route.fulfill({ contentType: route.request().url().includes("events") ? "text/event-stream" : "application/json", body: "" }))
|
||||
try {
|
||||
await page.goto(`${baseUrl}/fixture?${name}`)
|
||||
await page.waitForFunction(() => Boolean((window as any).fixture))
|
||||
await run(page)
|
||||
assert.deepEqual(errors, [])
|
||||
} catch (error) {
|
||||
console.error("Browser fixture failure", name, await page.evaluate(() => ({
|
||||
state: (window as any).fixture?.snapshot?.(), events: (window as any).fixtureScrollEvents,
|
||||
streams: Array.from(document.querySelectorAll(".message-stream")).map(el => ({ top: el.scrollTop, height: el.scrollHeight, viewport: el.clientHeight })),
|
||||
})))
|
||||
throw error
|
||||
} finally { await page.close() }
|
||||
await runWithDiagnosticCleanup({
|
||||
run: async () => {
|
||||
await page.goto(`${baseUrl}/fixture?${name}`)
|
||||
await page.waitForFunction(() => Boolean((window as any).fixture))
|
||||
await run(page)
|
||||
assert.deepEqual(errors, [])
|
||||
},
|
||||
diagnose: async () => {
|
||||
console.error("Browser fixture failure", name, await page.evaluate(() => ({
|
||||
state: (window as any).fixture?.snapshot?.(), events: (window as any).fixtureScrollEvents,
|
||||
streams: Array.from(document.querySelectorAll(".message-stream")).map(el => ({ top: el.scrollTop, height: el.scrollHeight, viewport: el.clientHeight })),
|
||||
})), { boundaryDiagnostics })
|
||||
},
|
||||
cleanup: () => page.close(),
|
||||
onObservationError: error => console.error("Browser fixture diagnostic capture failed", name, error, { boundaryDiagnostics }),
|
||||
onCleanupError: error => console.error("Browser fixture cleanup failed", name, error, { boundaryDiagnostics }),
|
||||
})
|
||||
}
|
||||
|
||||
test("user HTML remains literal through live delivery and history while assistant HTML still renders", async () => {
|
||||
|
|
@ -80,7 +94,7 @@ test("user HTML remains literal through live delivery and history while assistan
|
|||
})
|
||||
|
||||
test("user HTML mode preserves Markdown, pasted disclosures and code source and fences legacy caches", async () => {
|
||||
await open("user-html", async page => {
|
||||
await open(process.env.CODENOMAD_BROWSER_BOUNDARY_TRACE ? "user-html&trace=1" : "user-html", async page => {
|
||||
await page.locator("#root").evaluate(root => { root.style.display = "block"; root.style.height = "auto" })
|
||||
await page.locator("#user strong").waitFor()
|
||||
await page.locator("#assistant .sample-html").waitFor()
|
||||
|
|
|
|||
57
scripts/profile-outline-responsiveness.mts
Normal file
57
scripts/profile-outline-responsiveness.mts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { DatabaseSync } from "node:sqlite"
|
||||
import { performance } from "node:perf_hooks"
|
||||
import { setImmediate as turn } from "node:timers/promises"
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { buildSync } from "esbuild"
|
||||
import { readSessionOutline as currentOutline } from "../packages/server/src/opencode/session-pruning/outline-index"
|
||||
|
||||
const baseline = process.argv.includes("--baseline")
|
||||
let readSessionOutline = currentOutline
|
||||
if (baseline) {
|
||||
// Compile the immutable pre-mission source without changing the working tree.
|
||||
const directory = fileURLToPath(new URL("../packages/server/src/opencode/session-pruning/", import.meta.url))
|
||||
const source = execFileSync("git", ["show", "366830f6:packages/server/src/opencode/session-pruning/outline-index.ts"],
|
||||
{ cwd: directory, encoding: "utf8" })
|
||||
const built = buildSync({ stdin: { contents: source, loader: "ts", resolveDir: directory }, bundle: true,
|
||||
platform: "node", format: "esm", write: false })
|
||||
readSessionOutline = (await import(`data:text/javascript;base64,${Buffer.from(built.outputFiles[0].text).toString("base64")}`)).readSessionOutline
|
||||
}
|
||||
|
||||
// Synthetic memory-only database. Never discovers or opens an OpenCode database.
|
||||
const count = Number(process.argv[2] ?? 256)
|
||||
const bytes = Number(process.argv[3] ?? 262144)
|
||||
if (!Number.isInteger(count) || count < 1 || count > 1024 || !Number.isInteger(bytes) || bytes < 1 || bytes > 1048576) {
|
||||
throw new Error("Use 1–1024 rows and 1–1048576 payload bytes per row")
|
||||
}
|
||||
const db = new DatabaseSync(":memory:")
|
||||
try {
|
||||
db.exec(`CREATE TABLE session_v2(id TEXT,directory TEXT,project_id TEXT,workspace_id TEXT,revert TEXT);
|
||||
CREATE TABLE session_message(id TEXT PRIMARY KEY,session_id TEXT,type TEXT,seq INTEGER,data TEXT,time_updated INTEGER DEFAULT 0);
|
||||
CREATE UNIQUE INDEX session_message_session_seq_idx ON session_message(session_id,seq);
|
||||
INSERT INTO session_v2 VALUES ('s','/repo','p',NULL,NULL);`)
|
||||
const data = JSON.stringify({ content: [{ type: "tool", name: "read", state: { content: [{ type: "text", text: "x".repeat(bytes) }] } }] })
|
||||
const insert = db.prepare("INSERT INTO session_message(id,session_id,type,seq,data) VALUES (?,'s','assistant',?,?)")
|
||||
for (let i = 0; i < count; i++) insert.run(`m${i}`, i, data)
|
||||
for (let sample = 0; sample < 3; sample++) {
|
||||
await turn()
|
||||
const gaps: number[] = []
|
||||
let previous = performance.now()
|
||||
const start = previous
|
||||
let stopped = false
|
||||
const tick = () => {
|
||||
if (stopped) return
|
||||
const now = performance.now()
|
||||
gaps.push(now - previous)
|
||||
previous = now
|
||||
setImmediate(tick)
|
||||
}
|
||||
setImmediate(tick)
|
||||
const result = await readSessionOutline(db, { directory: "/repo", projectID: "p", sessionID: "s" }, undefined, new AbortController().signal)
|
||||
const elapsed = performance.now() - start
|
||||
await turn()
|
||||
stopped = true
|
||||
console.log(JSON.stringify({ baseline, sample, count, bytes, entries: result.status === "outline" ? result.entries.length : -1,
|
||||
elapsedMs: Math.round(elapsed), maxTurnGapMs: Math.round(Math.max(...gaps)), turns: gaps.length }))
|
||||
}
|
||||
} finally { db.close() }
|
||||
383
scripts/test-compaction-isolation-native.mjs
Normal file
383
scripts/test-compaction-isolation-native.mjs
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
// Explicit CLI only: no discovery, shared service, user database or external provider.
|
||||
import assert from "node:assert/strict"
|
||||
import { execFileSync, spawn } from "node:child_process"
|
||||
import { createHash } from "node:crypto"
|
||||
import { createServer } from "node:http"
|
||||
import { mkdtemp, mkdir, realpath, writeFile } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { setTimeout as delay } from "node:timers/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { OpenCode } from "@opencode/client"
|
||||
import { build } from "esbuild"
|
||||
|
||||
const cli = process.argv[2]
|
||||
if (!cli || !path.isAbsolute(cli)) throw new Error("Pass an absolute isolated CLI executable")
|
||||
const contextFlag = process.argv.indexOf("--context-mib")
|
||||
const contextMiB = contextFlag < 0 ? 0 : Number(process.argv[contextFlag + 1])
|
||||
if (![0, 2, 8, 32].includes(contextMiB)) throw new Error("--context-mib accepts only 0, 2, 8 or 32")
|
||||
const skipOutline = process.argv.includes("--skip-outline")
|
||||
const temporaryRoot = path.join(os.tmpdir(), "opencode")
|
||||
await mkdir(temporaryRoot, { recursive: true })
|
||||
const root = await realpath(await mkdtemp(path.join(temporaryRoot, "compaction-isolation-")))
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCODE_|XDG_)/i.test(key)))
|
||||
for (const key of ["XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"]) env[key] = path.join(root, key)
|
||||
Object.assign(env, {
|
||||
HOME: root, USERPROFILE: root, OPENCODE_TEST_HOME: root,
|
||||
OPENCODE_CONFIG_DIR: path.join(root, "config"), OPENCODE_DB: path.join(root, "test.db"),
|
||||
OPENCODE_SERVER_PASSWORD: "private-compaction-fixture", OPENCODE_CONFIG_PROJECT_DISABLE: "1",
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1",
|
||||
})
|
||||
const runtimeVersion = execFileSync(cli, ["--version"], { cwd: root, env, encoding: "utf8" }).trim().replace(/^opencode2? v/, "")
|
||||
assert.match(runtimeVersion, /^2\.0\.(19|21)$/, "This fixture qualifies only the requested runtimes")
|
||||
await Promise.all(["config", "plugin", "pruning"].map(name => mkdir(path.join(root, name), { recursive: true })))
|
||||
const bundle = await build({
|
||||
entryPoints: [fileURLToPath(new URL("../packages/server/src/opencode/session-pruning/plugin.ts", import.meta.url))],
|
||||
bundle: true, platform: "node", format: "esm", target: "node22", write: false,
|
||||
banner: { js: 'import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);' },
|
||||
})
|
||||
await writeFile(path.join(root, "pruning", "index.mjs"), bundle.outputFiles[0].contents)
|
||||
await writeFile(path.join(root, "plugin", "index.ts"), `
|
||||
export default { id:'private-compaction-fixture', async setup(ctx) {
|
||||
await ctx.session.hook('http.request', event => {
|
||||
event.request.headers.set('x-fixture-kind', event.kind)
|
||||
event.request.headers.set('x-fixture-session', event.sessionID)
|
||||
})
|
||||
await ctx.tool.transform(editor => editor.add({ name:'isolation_probe', description:'Inert fixture',
|
||||
input:{type:'object',properties:{}}, options:{codemode:false}, execute:async()=>({content:'Synthetic tool result'}) }))
|
||||
} }
|
||||
`)
|
||||
|
||||
const gates = new Map(), requests = [], toolIssued = new Set()
|
||||
const gate = key => {
|
||||
let release
|
||||
const promise = new Promise(resolve => { release = resolve })
|
||||
const value = { promise, release, released: false }
|
||||
gates.set(key, value)
|
||||
return () => { value.released = true; release(); gates.delete(key) }
|
||||
}
|
||||
const summary = ["## Objective", "## Requirements", "## Decisions", "## Work State", "### Completed",
|
||||
"### Active", "### Blocked", "## Next Move", "## Relevant Files", "## Important Context"]
|
||||
.map(heading => `${heading}\n- Synthetic isolation fixture`).join("\n\n")
|
||||
let providerError
|
||||
const provider = createServer(async (request, response) => {
|
||||
try {
|
||||
const started = performance.now()
|
||||
let raw = ""
|
||||
for await (const chunk of request) raw += chunk
|
||||
const bodyReceived = performance.now()
|
||||
const body = JSON.parse(raw), kind = request.headers["x-fixture-kind"], sessionID = request.headers["x-fixture-session"]
|
||||
const record = { kind, sessionID, started, bodyReceived, received: performance.now(), bytes: Buffer.byteLength(raw),
|
||||
messageCount: body.messages?.length, markerCount: (raw.match(/NATIVE_CONTEXT_CANARY_\d+/g) ?? []).length,
|
||||
payloadFillBytes: (raw.match(/Z{1024,}/g) ?? []).reduce((sum, fill) => sum + fill.length, 0) }
|
||||
requests.push(record)
|
||||
const pending = gates.get(`${kind}:${sessionID}`)
|
||||
if (pending) await pending.promise
|
||||
if (response.destroyed) return
|
||||
const text = kind === "compaction" ? summary : "Synthetic conclusion"
|
||||
if (!body.stream) {
|
||||
response.setHeader("Content-Type", "application/json")
|
||||
response.end(JSON.stringify({ id: "aux", choices: [{ message: { role: "assistant", content: text }, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } }))
|
||||
return
|
||||
}
|
||||
response.setHeader("Content-Type", "text/event-stream")
|
||||
const chunk = (delta, finish_reason = null) => response.write(`data: ${JSON.stringify({
|
||||
id: "fixture", object: "chat.completion.chunk", model: "fixture",
|
||||
choices: [{ index: 0, delta, finish_reason }],
|
||||
...(finish_reason ? { usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } } : {}),
|
||||
})}\n\n`)
|
||||
chunk({ role: "assistant" })
|
||||
const tool = kind === "primary" && !toolIssued.has(sessionID)
|
||||
if (tool) {
|
||||
toolIssued.add(sessionID)
|
||||
chunk({ tool_calls: [{ index: 0, id: `inert-${sessionID}`, type: "function", function: { name: "isolation_probe", arguments: "{}" } }] })
|
||||
} else {
|
||||
// Exercise real compaction delta events, without an artificial slow observer.
|
||||
for (let i = 0; i < text.length; i += 16) chunk({ content: text.slice(i, i + 16) })
|
||||
}
|
||||
chunk({}, tool ? "tool_calls" : "stop")
|
||||
response.end("data: [DONE]\n\n")
|
||||
record.responded = performance.now()
|
||||
} catch (error) { providerError = error; response.destroy(error) }
|
||||
})
|
||||
await new Promise(resolve => provider.listen(0, "127.0.0.1", resolve))
|
||||
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
|
||||
model: "fixture/fixture",
|
||||
// Fixture-only: preserve the entire imported corpus in a manual summary request.
|
||||
...(contextMiB ? { compaction: { auto: false, keep: { tokens: 0 } } } : {}),
|
||||
providers: { fixture: { package: "@opencode/ai/providers/openai-compatible", settings: {
|
||||
baseURL: `http://127.0.0.1:${provider.address().port}/v1`, apiKey: "fixture-only",
|
||||
}, models: { fixture: contextMiB ? { limit: { context: 16777216, output: 8192 } } : {} } } },
|
||||
plugins: [path.join(root, "plugin"), { package: path.join(root, "pruning"), options: { databasePath: env.OPENCODE_DB } }],
|
||||
})
|
||||
let output = "", observerError, spawnError
|
||||
const child = spawn(cli, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--log-level", "debug", "--print-logs"], { cwd: root, env, windowsHide: true })
|
||||
const stopped = new Promise(resolve => child.once("close", resolve))
|
||||
child.on("error", error => { spawnError = error })
|
||||
child.stdout.on("data", data => { output += data })
|
||||
child.stderr.on("data", data => { output += data })
|
||||
const streams = new AbortController(), events = [], measurements = []
|
||||
let phase = "setup"
|
||||
const report = { runtimeVersion, root, pid: child.pid, contextMiB, skipOutline, measurements, requests, outline: undefined,
|
||||
pruningBundleSha256: createHash("sha256").update(bundle.outputFiles[0].contents).digest("hex") }
|
||||
let observer, client, releaseA, releaseB
|
||||
const options = () => ({ signal: AbortSignal.timeout(15_000) })
|
||||
async function until(predicate, timeout = 30_000) {
|
||||
const end = performance.now() + timeout
|
||||
while (performance.now() < end) {
|
||||
if (providerError) throw providerError
|
||||
if (observerError) throw observerError
|
||||
if (spawnError) throw spawnError
|
||||
if (child.exitCode !== null) throw new Error("Private server exited early")
|
||||
if (await predicate()) return
|
||||
await delay(20)
|
||||
}
|
||||
throw new Error("Private fixture condition timed out")
|
||||
}
|
||||
async function measured(name, call) {
|
||||
const started = performance.now()
|
||||
const startedPhase = phase
|
||||
try { const value = await call(); const finished = performance.now(); measurements.push({ name, started, finished, startedPhase, finishedPhase: phase, ms: finished - started, status: "ok" }); return value }
|
||||
catch (error) { const finished = performance.now(); measurements.push({ name, started, finished, startedPhase, finishedPhase: phase, ms: finished - started, status: "failed" }); throw error }
|
||||
}
|
||||
const wait = sessionID => client.session.wait({ sessionID }, options())
|
||||
function distribution(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return { count: sorted.length, min: sorted[0], median: sorted[Math.floor(sorted.length / 2)], max: sorted.at(-1) }
|
||||
}
|
||||
try {
|
||||
await until(() => /http:\/\/127\.0\.0\.1:\d+/.test(output))
|
||||
const baseUrl = output.match(/http:\/\/127\.0\.0\.1:\d+/)[0]
|
||||
report.baseUrl = baseUrl
|
||||
client = OpenCode.make({ baseUrl, headers: { Authorization: `Basic ${Buffer.from("opencode:private-compaction-fixture").toString("base64")}` } })
|
||||
const info = await client.server.info(options())
|
||||
assert.equal(info.version, runtimeVersion)
|
||||
assert.equal(info.pid, child.pid, "The endpoint must belong to the child we created")
|
||||
observer = (async () => {
|
||||
for await (const event of client.event.subscribe({ signal: streams.signal })) {
|
||||
events.push({ phase, type: event.type, sessionID: event.data?.sessionID, created: event.created, received: Date.now() })
|
||||
}
|
||||
})().catch(error => { if (!streams.signal.aborted) observerError = error })
|
||||
await until(() => events.some(event => event.type === "server.connected"))
|
||||
const location = { directory: root }
|
||||
let a = await client.session.create({ location, title: "Private compaction A" }, options())
|
||||
const b = await client.session.create({ location, title: "Private witness B" }, options())
|
||||
report.sessions = { a: a.id, b: b.id }
|
||||
await until(async () => {
|
||||
const plugins = (await client.plugin.list({ location }, options())).data
|
||||
const failed = plugins.filter(item => item.source.type !== "builtin" && item.state.status === "failed")
|
||||
assert.equal(failed.length, 0, JSON.stringify(failed))
|
||||
return plugins.some(item => item.id === "codenomad-session-pruning" && item.state.status === "active")
|
||||
})
|
||||
phase = "seed"
|
||||
for (const session of [a, b]) {
|
||||
await client.session.prompt({ sessionID: session.id, text: "Use isolation_probe, then conclude." }, options())
|
||||
await wait(session.id)
|
||||
}
|
||||
const nativeMessages = (await client.message.list({ sessionID: a.id, limit: 20 }, options())).data
|
||||
const template = nativeMessages.find(message => message.type === "assistant" && message.content.some(part => part.type === "tool"))
|
||||
assert(template, "A real native assistant/tool template must exist")
|
||||
const userTemplate = nativeMessages.find(message => message.type === "user")
|
||||
assert(userTemplate)
|
||||
const tool = template.content.find(part => part.type === "tool")
|
||||
assert.equal(tool.state.status, "completed")
|
||||
if (contextMiB) {
|
||||
const exportedA = await client.session.export({ sessionID: a.id }, options())
|
||||
const count = contextMiB * 4, bytes = 262144
|
||||
let fillBytes = 0
|
||||
const imported = Array.from({ length: count }, (_, index) => {
|
||||
const marker = `NATIVE_CONTEXT_CANARY_${String(index).padStart(4, "0")}\n`
|
||||
const text = marker + "Z".repeat(bytes - marker.length)
|
||||
fillBytes += bytes - marker.length
|
||||
return [
|
||||
{ ...structuredClone(userTemplate), id: `msg_context_user_${String(index).padStart(4, "0")}`, text: `Inspect synthetic item ${index}.` },
|
||||
{ ...structuredClone(template), id: `msg_context_assistant_${String(index).padStart(4, "0")}`,
|
||||
content: [{ ...structuredClone(tool), id: `tool_context_${index}`, state: { ...structuredClone(tool.state), content: [{ type: "text", text }] } }] },
|
||||
]
|
||||
}).flat()
|
||||
// Native manual compaction retains the current turn even with keep.tokens=0.
|
||||
// A small trailing turn puts every large tool result in the summarized prefix.
|
||||
imported.push(
|
||||
{ ...structuredClone(userTemplate), id: "msg_context_tail_user", text: "Corpus complete; retain this small trailing turn." },
|
||||
{ ...structuredClone(template), id: "msg_context_tail_assistant", content: [{ type: "text", text: "Synthetic trailing conclusion" }] },
|
||||
)
|
||||
await client.session.remove({ sessionID: a.id }, options())
|
||||
a = await client.session.import({ ...exportedA, messages: imported, location }, { signal: AbortSignal.timeout(60_000) })
|
||||
report.sessions.a = a.id
|
||||
const stored = await client.session.context({ sessionID: a.id }, { signal: AbortSignal.timeout(60_000) })
|
||||
const storedTools = stored.filter(message => message.type === "assistant").flatMap(message => message.content.filter(part => part.type === "tool"))
|
||||
const storedBytes = storedTools.reduce((sum, part) => sum + part.state.content.reduce((total, content) => total + Buffer.byteLength(content.text ?? ""), 0), 0)
|
||||
assert.equal(storedTools.length, count)
|
||||
assert.equal(storedBytes, contextMiB * 1048576)
|
||||
assert.equal(stored.length, imported.length)
|
||||
report.contextCorpus = { turns: count + 1, payloadTurns: count, messages: imported.length, payloadBytes: storedBytes, payloadFillBytes: fillBytes,
|
||||
keepTokens: 0, modelContextLimit: 16777216 }
|
||||
}
|
||||
|
||||
// Seeding is outside the timed compaction/outline intervals and uses native import.
|
||||
let corpus
|
||||
if (!skipOutline) {
|
||||
const empty = await client.session.create({ location, title: "Private outline corpus" }, options())
|
||||
const exported = await client.session.export({ sessionID: empty.id }, options())
|
||||
await client.session.remove({ sessionID: empty.id }, options())
|
||||
const payload = "x".repeat(262144)
|
||||
const messages = Array.from({ length: 128 }, (_, index) => ({ ...structuredClone(template), id: `msg_isolation_${index}`,
|
||||
content: [{ ...structuredClone(tool), id: `tool_isolation_${index}`, state: { ...structuredClone(tool.state), content: [{ type: "text", text: payload }] } }],
|
||||
}))
|
||||
corpus = await client.session.import({ ...exported, messages, location }, { signal: AbortSignal.timeout(60_000) })
|
||||
}
|
||||
|
||||
// Flush seed observations before the measured phase; import is not compaction.
|
||||
await delay(100)
|
||||
phase = "assembly-before-provider"
|
||||
const compactStart = performance.now()
|
||||
releaseA = gate(`compaction:${a.id}`)
|
||||
const admission = measured("A.compact.admission", () => client.session.compact({ sessionID: a.id }, options()))
|
||||
admission.catch(() => {})
|
||||
let probingAssembly = true
|
||||
const assemblyProbes = (async () => {
|
||||
while (probingAssembly && !requests.some(item => item.kind === "compaction" && item.sessionID === a.id)) {
|
||||
await Promise.all([
|
||||
measured("assembly.info", () => client.server.info(options())),
|
||||
measured("assembly.B.get", () => client.session.get({ sessionID: b.id }, options())),
|
||||
])
|
||||
if (probingAssembly) await delay(5)
|
||||
}
|
||||
})()
|
||||
assemblyProbes.catch(() => {})
|
||||
const earlyB = (async () => {
|
||||
await measured("assembly.B.prompt.admission", () => client.session.prompt({ sessionID: b.id, text: "Conclude during A summary assembly." }, options()))
|
||||
await measured("assembly.B.wait", () => wait(b.id))
|
||||
assert.equal((await client.session.get({ sessionID: b.id }, options())).outcome, "succeeded")
|
||||
})()
|
||||
earlyB.catch(() => {})
|
||||
const admitted = await admission
|
||||
assert.equal(typeof admitted.time.created, "number")
|
||||
try { await until(() => requests.some(item => item.kind === "compaction" && item.sessionID === a.id)) }
|
||||
finally { probingAssembly = false; await assemblyProbes }
|
||||
const assembled = requests.find(item => item.kind === "compaction" && item.sessionID === a.id)
|
||||
const admittedAt = measurements.find(item => item.name === "A.compact.admission").finished
|
||||
report.compactionAssembly = { submissionToProviderHeadersMs: assembled.started - compactStart,
|
||||
admissionResponseToProviderHeadersMs: assembled.started - admittedAt,
|
||||
submissionToProviderBodyMs: assembled.bodyReceived - compactStart, providerParseMs: assembled.received - assembled.bodyReceived,
|
||||
providerBytes: assembled.bytes, providerMessages: assembled.messageCount, markerCount: assembled.markerCount, payloadFillBytes: assembled.payloadFillBytes }
|
||||
if (contextMiB) {
|
||||
assert.equal(assembled.markerCount, report.contextCorpus.payloadTurns, "Provider must receive every corpus tool marker")
|
||||
assert.equal(assembled.payloadFillBytes, report.contextCorpus.payloadFillBytes, "Stored corpus bytes must really reach the compaction provider")
|
||||
}
|
||||
await until(() => events.some(event => event.sessionID === a.id && event.type === "session.compaction.started"))
|
||||
await earlyB
|
||||
assert(!events.some(event => event.sessionID === a.id && event.type === "session.compaction.ended"))
|
||||
report.earlyBCompletedBeforeReleaseA = true
|
||||
report.assemblyWitnesses = measurements.filter(item => item.name.startsWith("assembly.")).map(item => ({ ...item,
|
||||
startedBeforeProviderHeaders: item.started < assembled.started, completedBeforeProviderHeaders: item.finished < assembled.started,
|
||||
completedBeforeProviderBody: item.finished < assembled.bodyReceived }))
|
||||
for (const name of ["assembly.info", "assembly.B.get", "assembly.B.prompt.admission"]) {
|
||||
assert(report.assemblyWitnesses.some(item => item.name === name && item.startedBeforeProviderHeaders), `${name} must be issued before A reaches the provider`)
|
||||
}
|
||||
report.preProviderLatencyMs = Object.fromEntries([...new Set(report.assemblyWitnesses.map(item => item.name))]
|
||||
.map(name => [name, distribution(report.assemblyWitnesses.filter(item => item.name === name && item.startedBeforeProviderHeaders).map(item => item.ms))]))
|
||||
report.whollyPreProviderLatencyMs = Object.fromEntries([...new Set(report.assemblyWitnesses.map(item => item.name))]
|
||||
.map(name => [name, distribution(report.assemblyWitnesses.filter(item => item.name === name && item.completedBeforeProviderHeaders).map(item => item.ms))]))
|
||||
report.bCompletedBeforeProviderHeaders = report.assemblyWitnesses.some(item => item.name === "assembly.B.wait" && item.completedBeforeProviderHeaders)
|
||||
phase = "held-compaction"
|
||||
let aFinished = false
|
||||
const waitingA = wait(a.id).then(() => { aFinished = true })
|
||||
// Attach a rejection handler immediately; still await the actual result later.
|
||||
waitingA.catch(() => {})
|
||||
for (let sample = 0; sample < 4; sample++) {
|
||||
await Promise.all([
|
||||
measured("held.info", () => client.server.info(options())),
|
||||
measured("held.A.get", () => client.session.get({ sessionID: a.id }, options())),
|
||||
measured("held.B.get", () => client.session.get({ sessionID: b.id }, options())),
|
||||
measured("held.active", () => client.session.active(options())),
|
||||
])
|
||||
await delay(50)
|
||||
}
|
||||
phase = "both-held"
|
||||
releaseB = gate(`primary:${b.id}`)
|
||||
const beforeB = requests.length
|
||||
await measured("held.B.prompt.admission", () => client.session.prompt({ sessionID: b.id, text: "Conclude while A summary is held." }, options()))
|
||||
await until(() => requests.slice(beforeB).some(item => item.kind === "primary" && item.sessionID === b.id))
|
||||
report.activeWhileBothHeld = await measured("both-held.active", () => client.session.active(options()))
|
||||
assert(report.activeWhileBothHeld[a.id] && report.activeWhileBothHeld[b.id], "Both native executions must be active")
|
||||
await measured("both-held.B.get", () => client.session.get({ sessionID: b.id }, options()))
|
||||
releaseB(); releaseB = undefined
|
||||
await measured("A-held.B.wait", () => wait(b.id))
|
||||
const completedB = await client.session.get({ sessionID: b.id }, options())
|
||||
assert.equal(completedB.outcome, "succeeded", "B must succeed, not simply stop after an error")
|
||||
const messagesB = (await client.message.list({ sessionID: b.id, limit: 8 }, options())).data
|
||||
assert(messagesB.some(message => message.type === "assistant" && message.content.some(part => part.type === "text" && part.text === "Synthetic conclusion")))
|
||||
assert.equal(aFinished, false, "B completed independently before releasing A")
|
||||
assert(!events.some(event => event.sessionID === a.id && event.type === "session.compaction.ended"))
|
||||
report.bCompletedBeforeReleaseA = true
|
||||
|
||||
if (!skipOutline) {
|
||||
phase = "outline-under-held-compaction"
|
||||
let outlining = true
|
||||
const outlineStart = performance.now(), heartbeats = []
|
||||
const heartbeat = (async () => {
|
||||
while (outlining) {
|
||||
const start = performance.now()
|
||||
await client.server.info(options())
|
||||
heartbeats.push({ startedMs: start - outlineStart, ms: performance.now() - start })
|
||||
if (outlining) await delay(5)
|
||||
}
|
||||
})()
|
||||
heartbeat.catch(() => {})
|
||||
try {
|
||||
const outline = (await measured("A-held.outline", () => client.rpc.call({ rpcID: "codenomad.session-pruning", method: "outline", location,
|
||||
input: { sessionID: corpus.id } }, options()))).output
|
||||
assert.equal(outline.status, "outline", JSON.stringify(outline))
|
||||
assert.equal(outline.entries.length, 128)
|
||||
assert(outline.entries.every(entry => entry.tools === 1 && entry.toolName === tool.name))
|
||||
const elapsedMs = performance.now() - outlineStart
|
||||
report.outline = { count: 128, payloadBytes: 262144, elapsedMs, heartbeats,
|
||||
completedHeartbeatsDuringOutline: heartbeats.filter(item => item.startedMs + item.ms <= elapsedMs).length }
|
||||
} finally { outlining = false; await heartbeat }
|
||||
}
|
||||
assert.equal(aFinished, false)
|
||||
phase = "held-compaction"
|
||||
// A genuine pending provider wait, not merely a race through a fast reply.
|
||||
await delay(1_000)
|
||||
await measured("held-final.B.get", () => client.session.get({ sessionID: b.id }, options()))
|
||||
await measured("held-final.info", () => client.server.info(options()))
|
||||
assert.equal(aFinished, false)
|
||||
report.aHeldMs = performance.now() - compactStart
|
||||
const releasedAt = performance.now()
|
||||
phase = "summary-release"
|
||||
releaseA(); releaseA = undefined
|
||||
await waitingA
|
||||
report.aCompletionAfterReleaseMs = performance.now() - releasedAt
|
||||
const context = await client.session.context({ sessionID: a.id }, options())
|
||||
assert(context.some(message => message.type === "compaction" && message.status === "completed"), "Real native compaction must finish successfully")
|
||||
await until(() => events.some(event => event.sessionID === a.id && event.type === "session.compaction.ended"))
|
||||
assert(!events.some(event => event.sessionID === a.id && event.type === "session.compaction.failed"))
|
||||
report.eventCounts = Object.fromEntries([...new Set(events.map(event => event.type))].map(type => [type, events.filter(event => event.type === type).length]))
|
||||
const business = events.filter(event => event.type.startsWith("session.") && typeof event.created === "number")
|
||||
report.eventDeliveryLagByPhaseMs = Object.fromEntries([...new Set(business.map(event => event.phase))]
|
||||
.map(name => [name, distribution(business.filter(event => event.phase === name).map(event => event.received - event.created))]))
|
||||
report.events = events
|
||||
report.latencyByOperationMs = Object.fromEntries([...new Set(measurements.map(item => item.name))]
|
||||
.map(name => [name, distribution(measurements.filter(item => item.name === name).map(item => item.ms))]))
|
||||
report.status = "passed"
|
||||
console.log(JSON.stringify({ ...report, events: undefined, measurements: undefined, requests: undefined, assemblyWitnesses: undefined }, null, 2))
|
||||
console.log(`PASS: B completed before releasing A's provider; corpus=${contextMiB} MiB, outline=${!skipOutline}`)
|
||||
} catch (error) {
|
||||
report.status = "failed"
|
||||
report.error = String(error.stack ?? error)
|
||||
throw error
|
||||
} finally {
|
||||
releaseA?.(); releaseB?.()
|
||||
for (const value of gates.values()) value.release()
|
||||
streams.abort()
|
||||
await observer
|
||||
child.kill()
|
||||
await stopped
|
||||
provider.closeAllConnections()
|
||||
await new Promise(resolve => provider.close(resolve))
|
||||
await writeFile(path.join(root, "server.log"), output)
|
||||
await writeFile(path.join(root, "result.json"), JSON.stringify(report, null, 2))
|
||||
console.log(`Private fixture retained at ${root}; only its child was stopped`)
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import assert from "node:assert/strict"
|
|||
import { setTimeout as delay } from "node:timers/promises"
|
||||
import { tsImport } from "tsx/esm/api"
|
||||
|
||||
export async function testNativeEventRelay({ client, location }) {
|
||||
export async function testNativeEventRelay({ client, location, service }) {
|
||||
const { EventBus } = await tsImport("../packages/server/src/events/bus.ts", import.meta.url)
|
||||
const { InstanceEventBridge } = await tsImport("../packages/server/src/workspaces/instance-events.ts", import.meta.url)
|
||||
const controller = new AbortController()
|
||||
|
|
@ -18,8 +18,8 @@ export async function testNativeEventRelay({ client, location }) {
|
|||
list: () => records,
|
||||
ownsDirectory: owns,
|
||||
ownsLocation: (id, ref) => owns(id, ref.directory),
|
||||
getSharedServiceClient: async () => client,
|
||||
subscribeToSharedService: signal => client.event.subscribe({ signal }),
|
||||
getSharedServiceClient: () => service ? service.client() : Promise.resolve(client),
|
||||
subscribeToSharedService: signal => service ? service.subscribe({ signal }) : client.event.subscribe({ signal }),
|
||||
invalidateWorktrees() {},
|
||||
}
|
||||
bus.on("instance.eventStatus", event => { if (event.status === "connected") connected = true })
|
||||
|
|
@ -43,6 +43,26 @@ export async function testNativeEventRelay({ client, location }) {
|
|||
try {
|
||||
bus.publish({ type: "workspace.started", workspace: records[0] })
|
||||
await until(() => connected && nativeConnected)
|
||||
if (service) {
|
||||
for (const mode of ["abort", "return", "throw"]) {
|
||||
const local = new AbortController()
|
||||
const wrapped = (await service.subscribe({ signal: local.signal }))[Symbol.asyncIterator]()
|
||||
try {
|
||||
assert.equal((await wrapped.next()).value.type, "server.connected")
|
||||
if (mode === "abort") {
|
||||
const next = wrapped.next()
|
||||
local.abort()
|
||||
assert.equal((await next).done, true)
|
||||
} else if (mode === "throw") {
|
||||
const failure = new Error("isolated subscriber failure")
|
||||
await assert.rejects(wrapped.throw(failure), error => error === failure)
|
||||
} else await wrapped.return()
|
||||
await client.server.info()
|
||||
assert.equal((await service.acquire()).client, client, `${mode} must retain the shared native client`)
|
||||
} finally { local.abort(); await wrapped.return() }
|
||||
}
|
||||
console.log("PASS: native service subscriber abort/return/throw retain the healthy shared connection")
|
||||
}
|
||||
const session = await client.session.create({ location: { directory: location.directory } })
|
||||
sessions.push(session.id)
|
||||
const titles = Array.from({ length: 8 }, (_, index) => `relay-fixture-${index}`)
|
||||
|
|
@ -56,6 +76,13 @@ export async function testNativeEventRelay({ client, location }) {
|
|||
await until(() => renames(routed.filter(x => x.instanceId === "slow").map(x => x.event)).length === titles.length)
|
||||
assert.deepEqual(renames(routed.filter(x => x.instanceId === "slow").map(x => x.event)), titles)
|
||||
console.log("PASS: native relay isolates slow recipients, preserves session ordering and does not block another shared SDK subscriber")
|
||||
if (service) {
|
||||
bridge.shutdown()
|
||||
await client.session.update({ sessionID: session.id, title: "after-relay-shutdown" })
|
||||
await until(() => renames(native).includes("after-relay-shutdown"))
|
||||
assert.equal((await service.acquire()).client, client)
|
||||
console.log("PASS: relay shutdown leaves another native subscriber and shared client requests usable")
|
||||
}
|
||||
} finally {
|
||||
release(false)
|
||||
bridge.shutdown()
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { testNativeWorktreeFamily } from "./test-worktree-families-native.mjs"
|
|||
import { testNativeWorktreeManagement } from "./test-native-worktree-management.mjs"
|
||||
import { testNativeEventRelay } from "./test-native-event-relay.mjs"
|
||||
|
||||
export async function testNativeLocationIdentity({ client, connection, root }) {
|
||||
export async function testNativeLocationIdentity({ client, connection, root, service }) {
|
||||
const { contractProfile, runtimeIdentity } = await tsImport("../packages/server/src/opencode/compatibility/runtime.ts", import.meta.url)
|
||||
const { locationRequestOptions } = await tsImport("../packages/server/src/opencode/compatibility/location.ts", import.meta.url)
|
||||
const profile = connection.profile ? await connection.profile() : contractProfile(runtimeIdentity(connection.endpoint))
|
||||
|
|
@ -36,7 +36,7 @@ export async function testNativeLocationIdentity({ client, connection, root }) {
|
|||
const rootLocation = await client.location.get({ location: { directory } })
|
||||
const worktreeLocation = await client.location.get({ location: { directory: worktree } })
|
||||
assert.equal(rootLocation.project.id, worktreeLocation.project.id)
|
||||
await testNativeEventRelay({ client, location: rootLocation })
|
||||
await testNativeEventRelay({ client, location: rootLocation, service })
|
||||
await testNativeWorktreeFamily({ client, profile, rootLocation, worktreeLocation })
|
||||
await testNativeWorktreeManagement({ client, root })
|
||||
if (profile !== "legacy") {
|
||||
|
|
|
|||
57
scripts/test-render-cost-audit.mjs
Normal file
57
scripts/test-render-cost-audit.mjs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { execFileSync, spawnSync } from "node:child_process"
|
||||
import { createHash } from "node:crypto"
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { dirname, join, resolve } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
// Read-only source A/B in isolated loopback Chromium fixtures. No checkout,
|
||||
// desktop, daemon, database or user-profile mutation is needed.
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..")
|
||||
const ui = join(root, "packages/ui")
|
||||
const reference = "47f9e43e3fa1a3be59c0f94932a7af0a4be20660"
|
||||
const source = "packages/ui/src/components/tool-call/renderers/task.tsx"
|
||||
const repetitions = Number(process.argv[2] ?? 3)
|
||||
if (!Number.isInteger(repetitions) || repetitions < 1 || repetitions > 10) throw new Error("Use 1–10 repetitions")
|
||||
const directory = mkdtempSync(join(tmpdir(), "codenomad-render-cost-"))
|
||||
const baselinePath = join(directory, "task-baseline.tsx")
|
||||
writeFileSync(baselinePath, execFileSync("git", ["show", `${reference}:${source}`], { cwd: root }))
|
||||
const runs = []
|
||||
for (let repetition = 0; repetition < repetitions; repetition++) {
|
||||
for (const variant of ["baseline", "fixed"]) {
|
||||
const env = { ...process.env, CODENOMAD_RENDER_INSTRUMENT: "off" }
|
||||
if (variant === "baseline") env.CODENOMAD_RENDER_TASK_BASELINE = baselinePath
|
||||
else delete env.CODENOMAD_RENDER_TASK_BASELINE
|
||||
const result = spawnSync(process.execPath, ["--import", "tsx", "--test", "--test-concurrency=1",
|
||||
"--test-name-pattern=^audit sample:", "tests/browser/render-cost.test.ts"], {
|
||||
cwd: ui, env, encoding: "utf8", windowsHide: true, timeout: 120_000,
|
||||
})
|
||||
writeFileSync(join(directory, `${repetition + 1}-${variant}.log`), `${result.stdout ?? ""}\n${result.stderr ?? ""}`)
|
||||
if (result.status !== 0) throw new Error(`Fixture failed (${variant}): ${result.error ?? result.stderr}; logs: ${directory}`)
|
||||
const match = result.stdout.match(/render-cost-native-deltas (\{[^\r\n]*\})/)
|
||||
if (!match) throw new Error(`No metrics in ${variant}; logs: ${directory}`)
|
||||
const metrics = JSON.parse(match[1])
|
||||
if (metrics.variant !== variant || metrics.instrumented || metrics.samples.length !== 10) throw new Error("Invalid A/B fixture metadata")
|
||||
runs.push({ repetition: repetition + 1, ...metrics })
|
||||
console.log(`${repetition + 1}/${repetitions} ${variant}: 10 samples recorded`)
|
||||
}
|
||||
}
|
||||
function stats(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const mean = values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
return { n: values.length, median: (sorted[(sorted.length - 1) >> 1] + sorted[sorted.length >> 1]) / 2,
|
||||
min: sorted[0], max: sorted.at(-1), mean,
|
||||
sampleStandardDeviation: Math.sqrt(values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (values.length - 1)) }
|
||||
}
|
||||
const summary = Object.fromEntries(["baseline", "fixed"].map(variant => {
|
||||
const samples = runs.filter(run => run.variant === variant).flatMap(run => run.samples)
|
||||
return [variant, Object.fromEntries(["syncMs", "scriptMs", "taskMs", "layoutMs", "styleMs"].map(key => [key, stats(samples.map(sample => sample[key]))]))]
|
||||
}))
|
||||
const artifact = { reference, fixedSourceSha256: createHash("sha256").update(readFileSync(join(root, source))).digest("hex"),
|
||||
browserPath: process.env.CODENOMAD_BROWSER_PATH ?? "Playwright default", node: process.version,
|
||||
repetitions, scenario: "80 completed read steps × 8,000 characters; 3 warmup deltas, 10 single native text deltas per run",
|
||||
notes: "CDP cumulative CPU deltas through two animation frames; not frame latency. Instrumentation off. Other host activity is not controlled.",
|
||||
summary, runs }
|
||||
writeFileSync(join(directory, "metrics.json"), JSON.stringify(artifact, null, 2))
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
console.log(`Raw metrics and logs: ${join(directory, "metrics.json")}`)
|
||||
|
|
@ -165,7 +165,7 @@ try {
|
|||
assert.equal((await client.server.info()).version, runtimeVersion)
|
||||
console.log(`Testing official runtime ${runtimeVersion}`)
|
||||
const { testNativeLocationIdentity } = await import("./test-opencode-location-native.mjs")
|
||||
await testNativeLocationIdentity({ client, connection, root })
|
||||
await testNativeLocationIdentity({ client, connection, root, service: sharedService })
|
||||
const { testNativeProxy } = await import("./test-opencode-proxy-native.mjs")
|
||||
await testNativeProxy({ client, baseUrl, root, runtimeFetch, connection, authorization: `Basic ${Buffer.from("opencode:isolated-pruning-fixture").toString("base64")}`,
|
||||
exercise: async (proxy, sessionID) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue