Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions packages/app/e2e/performance/unit/mock-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,49 @@ test("applies message latency after a list response gate is released", async ()
expect(performance.now() - released).toBeGreaterThanOrEqual(20)
expect(events).toEqual(["start", "before", "page", "end", "fulfill"])
})

test("serves current message list and lookup response contracts", async () => {
let handler: ((route: Route) => Promise<void>) | undefined
const responses: unknown[] = []
const message = {
info: { id: "message", role: "user" as const, time: { created: 1 } },
parts: [{ type: "text", text: "hello" }],
}
const page = {
route: (_url: string, callback: (route: Route) => Promise<void>) => {
handler = callback
return Promise.resolve()
},
} as unknown as Page
await mockOpenCodeServer(page, {
protocol: "v2",
provider: {},
directory: "C:/OpenCode",
project: {},
sessions: [{ id: "session" }],
pageMessages: () => ({ items: [message] }),
message: () => message,
})

const route = (url: string) =>
({
request: () => ({ url: () => url, method: () => "GET" }),
fulfill: (input: { body?: string }) => {
responses.push(JSON.parse(input.body ?? "null"))
return Promise.resolve()
},
fallback: () => Promise.resolve(),
}) as unknown as Route

await handler!(route("http://127.0.0.1:4096/api/session/session/message"))
await handler!(route("http://127.0.0.1:4096/api/session/session/message/message"))

expect(responses).toEqual([
{
data: [{ id: "message", type: "user", time: { created: 1 }, text: "hello" }],
context: [],
cursor: {},
},
{ data: { id: "message", type: "user", time: { created: 1 }, text: "hello" } },
])
})
19 changes: 17 additions & 2 deletions packages/app/e2e/utils/mock-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const projectMatch = path.match(/^\/project\/([^/]+)$/)
if (projectMatch) return json(route, config.project)

const currentMessageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
if (currentMessageMatch) {
config.onMessage?.({ sessionID: currentMessageMatch[1]!, messageID: currentMessageMatch[2]! })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const message = config.message?.(currentMessageMatch[1]!, currentMessageMatch[2]!)
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
return json(route, { data: currentMessage(message) })
}

const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/)
if (messageMatch) {
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
Expand Down Expand Up @@ -288,6 +297,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (cursor) cursors.set(cursor, pageData.cursor!)
return json(route, {
data: pageData.items.map(currentMessage).reverse(),
context: [],
cursor: { next: cursor },
})
}
Expand All @@ -306,7 +316,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (!pageData.cursor) return json(route, pageData.items)
const cursor = `cursor_${++nextCursor}`
cursors.set(cursor, pageData.cursor)
return json(route, pageData.items, { "x-next-cursor": cursor })
const next = new URL(url)
next.searchParams.delete("after")
next.searchParams.delete("oldest")
next.searchParams.set("limit", limit.toString())
next.searchParams.set("before", cursor)
return json(route, pageData.items, { "x-next-cursor": cursor, Link: `<${next.toString()}>; rel="next"` })
}

if (url.port === targetPort && targetPort !== appPort) return json(route, {})
Expand Down Expand Up @@ -429,7 +444,7 @@ function json(route: Route, body: unknown, headers?: Record<string, string>, sta
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
"access-control-expose-headers": "x-next-cursor",
"access-control-expose-headers": "Link, X-Next-Cursor",
...headers,
},
body: JSON.stringify(body ?? null),
Expand Down
13 changes: 2 additions & 11 deletions packages/app/src/components/session/session-context-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { useSessionLayout } from "@/pages/session/session-layout"
import { selectVisibleUserMessages } from "@/pages/session/timeline/model"
import { getSessionContext } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format"
Expand Down Expand Up @@ -113,18 +114,8 @@ export function SessionContextTab() {
{ equals: same },
)

const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
{ equals: same },
)

const visibleUserMessages = createMemo(
() => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
},
() => selectVisibleUserMessages(messages(), info()?.revert),
emptyUserMessages,
{ equals: same },
)
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/context/directory-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const sessionFields = new Set([
"question",
"message",
"session_message",
"revert_preview",
"part",
"part_text_accum_delta",
])
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/context/global-sync/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ function directoryState() {
limit: 5,
message: {},
session_message: {},
revert_preview: {},
part: {},
part_text_accum_delta: {},
})
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/context/global-sync/child-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ export function createChildStoreManager(input: {
limit: 5,
message: {},
session_message: {},
revert_preview: {},
part: {},
part_text_accum_delta: {},
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ const baseState = (input: Partial<State> = {}) =>
limit: 10,
message: {},
session_message: {},
revert_preview: {},
part: {},
part_text_accum_delta: {},
...input,
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/context/global-sync/session-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
import type { RevertPreview } from "./types"

const msg = (id: string, sessionID: string) =>
({
Expand Down Expand Up @@ -30,6 +31,7 @@ describe("app session cache", () => {
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
session_message: Record<string, never[] | undefined>
revert_preview: Record<string, RevertPreview | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
Expand All @@ -40,6 +42,7 @@ describe("app session cache", () => {
todo: { ses_1: [] as Todo[] },
message: {},
session_message: {},
revert_preview: { ses_1: { messageID: "msg_1", userCount: 0, hasMore: false, items: [] } },
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
permission: { ses_1: [] as PermissionRequest[] },
question: { ses_1: [] as QuestionRequest[] },
Expand All @@ -56,6 +59,7 @@ describe("app session cache", () => {
expect(store.session_status.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined()
expect(store.question.ses_1).toBeUndefined()
expect(store.revert_preview.ses_1).toBeUndefined()
})

test("dropSessionCaches clears message-backed parts", () => {
Expand All @@ -66,6 +70,7 @@ describe("app session cache", () => {
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
session_message: Record<string, never[] | undefined>
revert_preview: Record<string, RevertPreview | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
Expand All @@ -76,6 +81,7 @@ describe("app session cache", () => {
todo: {},
message: { ses_1: [m] },
session_message: {},
revert_preview: {},
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {},
question: {},
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/context/global-sync/session-cache.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import type { RevertPreview } from "./types"

export const SESSION_CACHE_LIMIT = 40

Expand All @@ -10,6 +11,7 @@ type SessionCache = {
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
session_message: Record<string, SessionMessageInfo[] | undefined>
revert_preview: Record<string, RevertPreview | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
Expand All @@ -33,6 +35,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
delete store.message[sessionID]
delete store.todo[sessionID]
delete store.session_message[sessionID]
delete store.revert_preview[sessionID]
delete store.session_diff[sessionID]
delete store.session_status[sessionID]
delete store.permission[sessionID]
Expand Down
12 changes: 12 additions & 0 deletions packages/app/src/context/global-sync/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ export type ProjectMeta = {
}
}

export type RevertPreview = {
messageID: string
userCount: number
hasMore: boolean
nextMessageID?: string
continuationMessageID?: string
items: { id: string; text: string }[]
}

export type State = {
status: "loading" | "partial" | "complete"
agent: Agent[]
Expand Down Expand Up @@ -77,6 +86,9 @@ export type State = {
session_message: {
[sessionID: string]: SessionMessageInfo[]
}
revert_preview: {
[sessionID: string]: RevertPreview | undefined
}
part: {
[messageID: string]: Part[]
}
Expand Down
Loading
Loading