Skip to content

Commit 905cd60

Browse files
committed
Merge remote-tracking branch 'origin/dev' into httpapi-codegen
# ------------------------ >8 ------------------------ # Do not modify or remove the line above. # Everything below it will be ignored. # # Conflicts: # packages/server/src/groups/session.ts
2 parents e03f5dd + e439456 commit 905cd60

305 files changed

Lines changed: 6793 additions & 5728 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ jobs:
6565
6666
- name: Run unit tests
6767
timeout-minutes: 20
68-
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=none
68+
run: GITHUB_ACTIONS=false bun turbo test
6969
env:
7070
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
7171

AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ const table = sqliteTable("session", {
137137

138138
## Testing
139139

140-
- Avoid mocks as much as possible
140+
- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option.
141141
- Test actual implementation, do not duplicate logic into tests
142142
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
143143

@@ -152,7 +152,7 @@ const table = sqliteTable("session", {
152152
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
153153
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
154154
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
155-
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work.
156-
- Keep delivery vocabulary explicit. Prompts steer by default and coalesce into the active activity at the next safe provider-turn boundary. Explicit `queue` inputs open FIFO future activities one at a time after the active activity settles.
155+
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
156+
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
157157
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
158158
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.

CONTEXT.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ An expected temporary inability to observe a **Context Source** value; the runti
3939
**Safe Provider-Turn Boundary**:
4040
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
4141

42+
**Admitted Prompt**:
43+
A durable user input accepted into the Session inbox but not yet included in **Session History**.
44+
45+
**Prompt Promotion**:
46+
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
47+
48+
**Provider Turn**:
49+
One request to a model provider and the response projected from that request.
50+
51+
**Session Drain**:
52+
One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
53+
4254
**Model Tool Output**:
4355
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
4456

@@ -82,6 +94,11 @@ _Avoid_: Response envelope
8294
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
8395
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
8496
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
97+
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
98+
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
99+
- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once.
100+
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
101+
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity.
85102
- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline.
86103
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
87104
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.

infra/stats.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const inferenceEventTable = new aws.s3tables.Table(
5656
{ name: "error_cause2", type: "string", required: false },
5757
{ name: "api_key", type: "string", required: false },
5858
{ name: "workspace", type: "string", required: false },
59+
{ name: "user_id", type: "string", required: false },
5960
{ name: "is_subscription", type: "boolean", required: false },
6061
{ name: "subscription", type: "string", required: false },
6162
{ name: "response_length", type: "long", required: false },
@@ -84,7 +85,7 @@ const inferenceEventTable = new aws.s3tables.Table(
8485
},
8586
},
8687
},
87-
{ deleteBeforeReplace: $app.stage !== "production" },
88+
{ deleteBeforeReplace: $app.stage !== "production", ignoreChanges: ["metadata"] },
8889
)
8990

9091
export const inferenceEvent = new sst.Linkable("InferenceEvent", {
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { expect, test, type Page } from "@playwright/test"
2+
import { base64Encode } from "@opencode-ai/core/util/encode"
3+
import { mockOpenCodeServer } from "../utils/mock-server"
4+
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
5+
6+
const directory = "C:/OpenCode/ReviewLineCommentRegression"
7+
const sessionID = "ses_review_line_comment_regression"
8+
const title = "Review line comment regression"
9+
10+
test.beforeEach(async ({ page }) => {
11+
await openReview(page)
12+
})
13+
14+
test("opens the comment editor when code is clicked", async ({ page }) => {
15+
const review = page.locator('[data-component="session-review"]')
16+
const line = review.getByText("export const value = 'after'", { exact: true })
17+
await expectAppVisible(line)
18+
await line.click()
19+
20+
await expect(review.getByRole("textbox")).toBeVisible()
21+
})
22+
23+
test("opens the comment editor when a line number is clicked", async ({ page }) => {
24+
const review = page.locator('[data-component="session-review"]')
25+
const lineNumber = review.locator('[data-column-number="1"]').last()
26+
await expectAppVisible(lineNumber)
27+
await lineNumber.click()
28+
29+
await expect(review.getByRole("textbox")).toBeVisible()
30+
})
31+
32+
test("opens the comment editor for a line number range", async ({ page }) => {
33+
const review = page.locator('[data-component="session-review"]')
34+
const start = review.locator('[data-column-number="1"]').last()
35+
const end = review.locator('[data-column-number="3"]').last()
36+
await expectAppVisible(start)
37+
await expectAppVisible(end)
38+
39+
const from = await start.boundingBox()
40+
const to = await end.boundingBox()
41+
if (!from || !to) throw new Error("Missing line number bounds")
42+
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
43+
await page.mouse.down()
44+
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2)
45+
await page.mouse.up()
46+
47+
await expect(review.getByRole("textbox")).toBeVisible()
48+
})
49+
50+
test("shows a comment button when a line number is hovered", async ({ page }) => {
51+
const review = page.locator('[data-component="session-review"]')
52+
const lineNumber = review.locator('[data-column-number="1"]').last()
53+
await expectAppVisible(lineNumber)
54+
55+
const comment = review.getByRole("button", { name: "Comment", exact: true })
56+
await expect(async () => {
57+
await page.mouse.move(0, 0)
58+
await lineNumber.hover()
59+
await expect(comment).toBeVisible({ timeout: 500 })
60+
}).toPass()
61+
await comment.click()
62+
await expect(review.getByRole("textbox")).toBeVisible()
63+
})
64+
65+
test("stages a submitted line comment in the prompt context", async ({ page }) => {
66+
const requests: string[] = []
67+
page.on("request", (request) => {
68+
if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`)
69+
})
70+
71+
const review = page.locator('[data-component="session-review"]')
72+
await review.getByText("export const value = 'after'", { exact: true }).click()
73+
await review.getByRole("textbox").fill("Use the existing value instead")
74+
await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').click()
75+
76+
await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible()
77+
await page.getByRole("tab", { name: "Session" }).click()
78+
const context = page.getByText("Use the existing value instead", { exact: true }).last()
79+
await expect(context).toBeVisible()
80+
await expect(context.locator("..")).toContainText("review.ts:2")
81+
expect(requests).toEqual([])
82+
})
83+
84+
async function openReview(page: Page) {
85+
await page.setViewportSize({ width: 700, height: 900 })
86+
await mockOpenCodeServer(page, {
87+
directory,
88+
project: {
89+
id: "proj_review_line_comment_regression",
90+
worktree: directory,
91+
vcs: "git",
92+
name: "review-line-comment-regression",
93+
time: { created: 1700000000000, updated: 1700000000000 },
94+
sandboxes: [],
95+
},
96+
provider: { all: [], connected: [], default: {} },
97+
sessions: [
98+
{
99+
id: sessionID,
100+
slug: "review-line-comment-regression",
101+
projectID: "proj_review_line_comment_regression",
102+
directory,
103+
title,
104+
version: "dev",
105+
time: { created: 1700000000000, updated: 1700000000000 },
106+
},
107+
],
108+
vcsDiff: [
109+
{
110+
file: "src/review.ts",
111+
additions: 1,
112+
deletions: 1,
113+
status: "modified",
114+
patch:
115+
"diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n",
116+
},
117+
],
118+
pageMessages: () => ({
119+
items: [
120+
{
121+
info: {
122+
id: "msg_review_line_comment_regression",
123+
sessionID,
124+
role: "user",
125+
time: { created: 1700000000000 },
126+
summary: { diffs: [] },
127+
agent: "build",
128+
model: { providerID: "opencode", modelID: "test" },
129+
},
130+
parts: [
131+
{
132+
id: "prt_review_line_comment_regression",
133+
sessionID,
134+
messageID: "msg_review_line_comment_regression",
135+
type: "text",
136+
text: "Review this change.",
137+
},
138+
],
139+
},
140+
],
141+
}),
142+
})
143+
144+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
145+
await expectSessionTitle(page, title)
146+
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff")
147+
await page.getByRole("tab", { name: "Changes" }).click()
148+
expect(await (await diffResponse).json()).toHaveLength(1)
149+
150+
const review = page.locator('[data-component="session-review"]')
151+
await expectAppVisible(review)
152+
await review
153+
.getByRole("heading", { name: /review\.ts/ })
154+
.getByRole("button")
155+
.first()
156+
.click()
157+
}

packages/app/e2e/smoke/session-timeline.fixture.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const words = [
2121
"vector",
2222
]
2323

24+
const serverKey = "http://127.0.0.1:4096"
2425
const sourceID = "ses_smoke_source"
2526
const targetID = "ses_smoke_target"
2627
const directory = "C:/OpenCode/SmokeProject"
@@ -139,7 +140,7 @@ function toolPart(
139140
status: "completed",
140141
input,
141142
output: lorem(index * 23 + partIndex, outputLength),
142-
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
143+
title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed",
143144
metadata,
144145
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
145146
},
@@ -200,9 +201,7 @@ function turn(index: number): Message[] {
200201
...(index % 8 === 0
201202
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
202203
: []),
203-
...(index % 7 === 0
204-
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
205-
: []),
204+
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
206205
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
207206
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
208207
...(index % 13 === 0
@@ -242,6 +241,7 @@ function orderedParts(message: Message) {
242241

243242
export const fixture = {
244243
directory,
244+
serverKey,
245245
project: {
246246
id: projectID,
247247
worktree: directory,
@@ -295,6 +295,7 @@ export const fixture = {
295295
.filter(renderable)
296296
.map((part) => part.id),
297297
),
298+
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id,
298299
},
299300
}
300301

packages/app/e2e/smoke/session-timeline.spec.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,18 @@ test.describe("smoke: session timeline", () => {
327327
const expectedMessageIDs = fixture.expected.targetMessageIDs
328328
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
329329
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
330+
331+
const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`)
332+
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
333+
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
334+
await expect(shellSubtitle).toHaveCount(0)
335+
await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck")
336+
await shellTrigger.click()
337+
await expect(shellTrigger).toHaveAttribute("aria-expanded", "false")
338+
await expect(shellSubtitle).toHaveText("bun typecheck")
339+
await shellTrigger.click()
340+
await expect(shellTrigger).toHaveAttribute("aria-expanded", "true")
341+
await expect(shellSubtitle).toHaveCount(0)
330342
})
331343
})
332344

@@ -706,7 +718,8 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin
706718
}
707719

708720
async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
709-
const href = `/${base64Encode(fixture.directory)}/session/${sessionID}`
721+
console.log(process.env)
722+
const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`
710723
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
711724
await expect(tab).toBeVisible()
712725
await tab.click()

packages/app/e2e/utils/mock-server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface MockServerConfig {
1818
project: unknown
1919
sessions: ({ id: string } & Record<string, unknown>)[]
2020
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
21+
vcsDiff?: unknown[]
2122
messageDelay?: number
2223
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
2324
events?: () => unknown[]
@@ -52,6 +53,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
5253
const path = url.pathname
5354
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
5455
if (path === "/global/health") return json(route, { healthy: true })
56+
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
5557
if (emptyObject.has(path)) return json(route, {})
5658
if (emptyList.has(path)) return json(route, [])
5759
if (path in staticRoutes) return json(route, staticRoutes[path])

packages/app/index.html

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
11
<!doctype html>
2-
<html lang="en" style="background-color: var(--background-base)">
2+
<html lang="en" style="background-color: var(--v2-background-bg-deep, #fafafa)">
33
<head>
44
<meta charset="utf-8" />
5-
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
5+
<meta
6+
name="viewport"
7+
content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover"
8+
/>
69
<title>OpenCode</title>
710
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
811
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
912
<link rel="shortcut icon" href="/favicon-v3.ico" />
1013
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
1114
<link rel="manifest" href="/site.webmanifest" />
12-
<meta name="theme-color" content="#F8F7F7" />
15+
<meta name="theme-color" content="#fafafa" />
16+
<meta name="mobile-web-app-capable" content="yes" />
17+
<meta name="apple-mobile-web-app-capable" content="yes" />
18+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
1319
<meta property="og:image" content="/social-share.png" />
1420
<meta property="twitter:image" content="/social-share.png" />
1521
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
1622
</head>
17-
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
23+
<body class="antialiased overscroll-none text-12-regular overflow-hidden bg-v2-background-bg-deep">
1824
<noscript>You need to enable JavaScript to run this app.</noscript>
19-
<div id="root" class="flex flex-col h-dvh p-px"></div>
25+
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
2026
<script src="/src/entry.tsx" type="module"></script>
2127
</body>
2228
</html>

0 commit comments

Comments
 (0)