mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-17 01:40:19 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71af6a4a0e | ||
|
|
3943b84ef0 | ||
|
|
1e63a3bb73 | ||
|
|
7291f3e07a | ||
|
|
13afea6a1a | ||
|
|
8ffbcc2c81 | ||
|
|
35bb8e44ef | ||
|
|
0ccfe69ff3 | ||
|
|
07c0ff5914 | ||
|
|
fe1717b309 | ||
|
|
943dc005f7 | ||
|
|
cddf1dd3e9 | ||
|
|
b474b84259 | ||
|
|
c0d41d2b26 | ||
|
|
c458ad72d3 | ||
|
|
f44c40b4d3 | ||
|
|
5fa0a42386 | ||
|
|
d3e1de326b | ||
|
|
81d57087a4 | ||
|
|
34d8ded0c5 |
No files matched your search
@@ -2,6 +2,76 @@
|
||||
|
||||
Release notes for OpenCode V2, newest first.
|
||||
|
||||
## v2.0.29 — 2026-10-14
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Remote Device Pairing**: In **Settings → Pairing**, select **Pair device** to enable OpenTunnel remote access and get a QR code or copyable link. ([#55306](https://github.com/anomalyco/opencode/pull/55306))
|
||||
- **Provider Account Management**: Use the connect dialog or **Settings → Providers** to switch, rename, remove, or add provider accounts. ([#54101](https://github.com/anomalyco/opencode/pull/54101))
|
||||
- **Automatic Response Continuation**: Responses that reach their output-token limit now continue automatically, preserving partial output instead of stopping abruptly. ([#53876](https://github.com/anomalyco/opencode/pull/53876))
|
||||
- **Trackpad Image Navigation**: Pinch to zoom and use two-finger scrolling to pan image and SVG previews. Select **Fit** to reset the view. ([#54590](https://github.com/anomalyco/opencode/pull/54590))
|
||||
|
||||
### Core
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Automatically continue responses after an output-token limit, with bounded retries and clearer recovery for truncated tool calls. ([#53876](https://github.com/anomalyco/opencode/pull/53876))
|
||||
- Stabilize access policies under the top-level `policies` configuration key while retaining compatibility with `experimental.policies`. ([#55297](https://github.com/anomalyco/opencode/pull/55297))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Give centrally managed configuration precedence over local and user configuration. ([#55267](https://github.com/anomalyco/opencode/pull/55267))
|
||||
- Allow migrated V1 npm provider configuration to target built-in provider routes. ([#55254](https://github.com/anomalyco/opencode/pull/55254))
|
||||
|
||||
### TUI
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Report working sessions and sessions awaiting permission or input through terminal program-status notifications. ([#55172](https://github.com/anomalyco/opencode/pull/55172))
|
||||
|
||||
### Desktop
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Pair devices from **Settings → Pairing** through OpenTunnel remote access, with a QR code and copyable link. ([#55306](https://github.com/anomalyco/opencode/pull/55306))
|
||||
- Manage provider accounts from the connect dialog and **Settings → Providers**, including switching, renaming, removal, and adding another account. ([#54101](https://github.com/anomalyco/opencode/pull/54101))
|
||||
- Show session context tokens and accumulated family cost in the session view. ([#53785](https://github.com/anomalyco/opencode/pull/53785))
|
||||
- Pinch to zoom and use two-finger scrolling to pan image and SVG previews; select **Fit** to reset. ([#54590](https://github.com/anomalyco/opencode/pull/54590))
|
||||
- Cycle side-panel tabs with the configured previous-tab and next-tab shortcuts while the side panel is focused. ([#54899](https://github.com/anomalyco/opencode/pull/54899))
|
||||
- Refresh the question composer and clarify timeline notices that apply only to a model variant. ([#54822](https://github.com/anomalyco/opencode/pull/54822), [#54880](https://github.com/anomalyco/opencode/pull/54880))
|
||||
- Refine diff colors and move unread indicators into session tabs. ([#52551](https://github.com/anomalyco/opencode/pull/52551), [#53570](https://github.com/anomalyco/opencode/pull/53570))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Show steered messages as pending immediately and render inline attachments before their server echo arrives. ([#55251](https://github.com/anomalyco/opencode/pull/55251), [#54650](https://github.com/anomalyco/opencode/pull/54650))
|
||||
- Preserve raw media data up to 256 MiB so artifact previews remain available. ([#55018](https://github.com/anomalyco/opencode/pull/55018))
|
||||
- Retain session model selections when the model catalog refreshes. ([#54481](https://github.com/anomalyco/opencode/pull/54481))
|
||||
- Close browser tabs from the address field and suppress incorrect missing-file notices for restored or already-open tabs. ([#54501](https://github.com/anomalyco/opencode/pull/54501), [#55022](https://github.com/anomalyco/opencode/pull/55022))
|
||||
- Prevent a project-store eviction loop during launch and remove hover gaps between vertical tabs. ([#55078](https://github.com/anomalyco/opencode/pull/55078), [#54866](https://github.com/anomalyco/opencode/pull/54866))
|
||||
|
||||
### SDK
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Export `lastAssistantWithUsage` from the Solid client to consistently locate the assistant step that determines current context usage. ([#53785](https://github.com/anomalyco/opencode/pull/53785))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Include all paginated child sessions when loading a session family for usage totals. ([#53785](https://github.com/anomalyco/opencode/pull/53785))
|
||||
- Admit inline data-URL files into optimistic pending messages so attachments render immediately. ([#54650](https://github.com/anomalyco/opencode/pull/54650))
|
||||
|
||||
### Extensions
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Add `ctx.permission.assert` for Effect and Promise plugins, allowing plugins to use configured permission rules and the native approval prompt. ([#54930](https://github.com/anomalyco/opencode/pull/54930))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Use Figma's public endpoint and default OAuth configuration, and support Git installations and development releases. ([#55252](https://github.com/anomalyco/opencode/pull/55252), [#55304](https://github.com/anomalyco/opencode/pull/55304))
|
||||
- Resolve the Figma plugin peer dependency from its workspace. ([#55269](https://github.com/anomalyco/opencode/pull/55269))
|
||||
- Associate each rejected Promise hook with its own tool call. Thanks [@1052326311](https://github.com/1052326311). ([#55263](https://github.com/anomalyco/opencode/pull/55263))
|
||||
|
||||
## v2.0.28 — 2026-10-13
|
||||
|
||||
### Highlights
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -55,7 +55,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode.cjs",
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
@@ -180,7 +180,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -205,7 +205,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -218,7 +218,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -246,7 +246,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@opencode/console-resource": "workspace:*",
|
||||
},
|
||||
@@ -273,7 +273,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
@@ -339,7 +339,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "5.0.0",
|
||||
@@ -385,7 +385,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -422,7 +422,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -438,7 +438,7 @@
|
||||
},
|
||||
"packages/gui-extensions": {
|
||||
"name": "@opencode/gui-extensions",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@betteroffice/docx": "0.4.3",
|
||||
"@betteroffice/fonts": "0.4.0",
|
||||
@@ -487,7 +487,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
},
|
||||
@@ -506,7 +506,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -519,7 +519,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -533,7 +533,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -548,7 +548,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -585,7 +585,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -600,7 +600,7 @@
|
||||
},
|
||||
"packages/plugins/figma": {
|
||||
"name": "@opencode/plugin-figma",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"gray-matter": "4.0.3",
|
||||
},
|
||||
@@ -630,7 +630,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -645,7 +645,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -669,7 +669,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -690,7 +690,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -713,7 +713,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -748,7 +748,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -768,7 +768,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -795,7 +795,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@planetscale/database": "1.19.0",
|
||||
"drizzle-orm": "catalog:",
|
||||
@@ -813,7 +813,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode/stats-core": "workspace:*",
|
||||
@@ -859,7 +859,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -873,7 +873,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -907,7 +907,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@kitlangton/solid-motion": "0.2.2",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -943,7 +943,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -981,7 +981,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1035,7 +1035,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
|
||||
@@ -28,6 +28,10 @@ const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAI
|
||||
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
|
||||
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
|
||||
const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
|
||||
const CONSOLE_CRM_TOKEN = new sst.Secret("CONSOLE_CRM_TOKEN")
|
||||
const CONSOLE_CRM_LOOKUP_URL = new sst.Linkable("CONSOLE_CRM_LOOKUP_URL", {
|
||||
properties: { value: `https://${domain}/console/api/internal/console-account` },
|
||||
})
|
||||
|
||||
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
||||
handler: "packages/console/function/src/log-processor.ts",
|
||||
@@ -48,6 +52,8 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
SALESFORCE_CLIENT_ID,
|
||||
SALESFORCE_CLIENT_SECRET,
|
||||
SALESFORCE_INSTANCE_URL,
|
||||
CONSOLE_CRM_TOKEN,
|
||||
CONSOLE_CRM_LOOKUP_URL,
|
||||
],
|
||||
environment: {
|
||||
//VITE_DOCS_URL: web.url.apply((url) => url!),
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -162,6 +162,32 @@ test("keyboard navigation follows the visible tab order and skips unresolved tab
|
||||
await expectPath(page, sessionHref(c.id))
|
||||
})
|
||||
|
||||
test("option arrows move to the previous or next tab, and with shift to the next unread one", async ({ page }) => {
|
||||
await mockWorkspace(page, {
|
||||
name: "Tabs",
|
||||
sessions: [a, b, c],
|
||||
sessionPermissions: {
|
||||
[c.id]: [
|
||||
{ id: "permission-tab-c", sessionID: c.id, action: "shell", resources: ["git status"], metadata: {}, save: [] },
|
||||
],
|
||||
},
|
||||
})
|
||||
await page.goto(sessionHref(a.id))
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(
|
||||
page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(c.id)}"]) [data-slot="tab-unread-dot"]`),
|
||||
).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Alt+ArrowDown")
|
||||
await expectPath(page, sessionHref(b.id))
|
||||
|
||||
await page.keyboard.press("Alt+ArrowUp")
|
||||
await expectPath(page, sessionHref(a.id))
|
||||
|
||||
await page.keyboard.press("Shift+Alt+ArrowDown")
|
||||
await expectPath(page, sessionHref(c.id))
|
||||
})
|
||||
|
||||
for (const row of [
|
||||
{ tabLayout: "horizontal", width: 360, hasTouch: false, direction: "ltr" },
|
||||
{ tabLayout: "vertical", width: 390, hasTouch: true, direction: "rtl" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -42,6 +42,10 @@ export const dict = {
|
||||
"command.palette": "Command palette",
|
||||
|
||||
"command.session.new": "New session",
|
||||
"command.session.previous": "Previous session",
|
||||
"command.session.next": "Next session",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.file.open": "Open file",
|
||||
"command.tab.close": "Close tab",
|
||||
"command.tab.reopenClosed": "Reopen closed tab",
|
||||
@@ -790,6 +794,8 @@ export const dict = {
|
||||
"settings.timeline.description.text-only": "Hide all activity.",
|
||||
"settings.timeline.description.custom": "Uses advanced settings.",
|
||||
"settings.timeline.custom": "Custom",
|
||||
"settings.timeline.row.turnDiffs.title": "Changed files",
|
||||
"settings.timeline.row.turnDiffs.description": "List the files each turn changed when it finishes",
|
||||
"settings.timeline.advanced": "Advanced",
|
||||
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
|
||||
"settings.timeline.group": "Group",
|
||||
|
||||
@@ -180,6 +180,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
timelineDetail,
|
||||
pendingInputIDs,
|
||||
queuedCompactionIDs,
|
||||
turnDiffs: settings.general.showTurnDiffs,
|
||||
})
|
||||
|
||||
const [pending, setPending] = createStore({ rename: false })
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { FileDiffInfo } from "@opencode/client/promise"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createTurnDiffCache } from "./turn-diffs"
|
||||
|
||||
const turn = { sessionID: "ses_a", from: "msg_open" }
|
||||
|
||||
// Every pending promise callback runs before the next macrotask.
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
const diff = { file: "src/a.ts", patch: "", additions: 1, deletions: 0, status: "modified" } satisfies FileDiffInfo
|
||||
|
||||
describe("turn diff cache", () => {
|
||||
test("loads each turn once and retries after a failure", async () => {
|
||||
const cache = createRoot(() => createTurnDiffCache())
|
||||
const failed = Promise.withResolvers<readonly FileDiffInfo[]>()
|
||||
const calls: string[] = []
|
||||
|
||||
cache.load(turn, () => (calls.push("failed"), failed.promise))
|
||||
cache.load(turn, () => (calls.push("duplicate"), Promise.resolve([diff])))
|
||||
failed.reject(new Error("offline"))
|
||||
await settle()
|
||||
expect(cache.get(turn)).toBeUndefined()
|
||||
|
||||
cache.load(turn, () => (calls.push("retry"), Promise.resolve([diff])))
|
||||
await settle()
|
||||
cache.load(turn, () => (calls.push("cached"), Promise.resolve([])))
|
||||
|
||||
expect(calls).toEqual(["failed", "retry"])
|
||||
expect(cache.get(turn)).toEqual([diff])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { FileDiffInfo } from "@opencode/client/promise"
|
||||
import type { TurnDiffInput } from "@opencode/session-ui/context"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
/**
|
||||
* One tab's in-memory cache of the files each finished turn changed. A finished turn's snapshots never
|
||||
* change, so each turn loads once per tab and survives the timeline remounting; closing the tab drops it.
|
||||
*/
|
||||
export function createTurnDiffCache() {
|
||||
const [diffs, setDiffs] = createStore<Record<string, readonly FileDiffInfo[] | undefined>>({})
|
||||
const loading = new Set<string>()
|
||||
const key = (input: TurnDiffInput) => `${input.sessionID}/${input.from}`
|
||||
|
||||
return {
|
||||
get: (input: TurnDiffInput) => diffs[key(input)],
|
||||
load(input: TurnDiffInput, fetch: () => Promise<readonly FileDiffInfo[]>) {
|
||||
const id = key(input)
|
||||
|
||||
if (diffs[id] || loading.has(id)) return
|
||||
loading.add(id)
|
||||
// A failed load leaves the turn unloaded, so the next request retries.
|
||||
void fetch()
|
||||
.then(
|
||||
(value) => setDiffs(id, value),
|
||||
() => undefined,
|
||||
)
|
||||
.finally(() => loading.delete(id))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,17 @@ export const SettingsGeneral: Component = () => {
|
||||
onChange={settings.general.setTimelineDetail}
|
||||
/>
|
||||
</div>
|
||||
<SettingsRow
|
||||
title={language.t("settings.timeline.row.turnDiffs.title")}
|
||||
description={language.t("settings.timeline.row.turnDiffs.description")}
|
||||
>
|
||||
<div data-action="settings-show-turn-diffs">
|
||||
<Switch
|
||||
checked={settings.general.showTurnDiffs()}
|
||||
onChange={(checked) => settings.general.setShowTurnDiffs(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ describe("settings schema", () => {
|
||||
showFileTree: false,
|
||||
timelineDetail: timelinePresets[2].value,
|
||||
showCustomAgents: false,
|
||||
showTurnDiffs: false,
|
||||
mobileTitlebarPosition: "top",
|
||||
terminalPlacement: "side",
|
||||
followUpBehavior: "steer",
|
||||
|
||||
@@ -116,6 +116,7 @@ const generalSchema = Persistence.struct({
|
||||
tools: placementOnlySchema,
|
||||
}),
|
||||
showCustomAgents: Schema.Boolean,
|
||||
showTurnDiffs: Schema.Boolean,
|
||||
mobileTitlebarPosition: Schema.Literals(["top", "bottom"]),
|
||||
// Owned by the review extension, which copies it out once; kept so settings rewrites cannot drop it first.
|
||||
mobileDiffWrap: Persistence.optional(Schema.Boolean),
|
||||
@@ -278,6 +279,7 @@ export const defaultSettings: Settings = {
|
||||
showFileTree: false,
|
||||
timelineDetail: { ...timelinePresets[2].value },
|
||||
showCustomAgents: false,
|
||||
showTurnDiffs: false,
|
||||
mobileTitlebarPosition: "top",
|
||||
terminalPlacement: "side",
|
||||
followUpBehavior: "steer",
|
||||
@@ -351,6 +353,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowCustomAgents(value: boolean) {
|
||||
setStore("general", "showCustomAgents", value)
|
||||
},
|
||||
showTurnDiffs: withFallback(() => store.general?.showTurnDiffs, defaultSettings.general.showTurnDiffs),
|
||||
setShowTurnDiffs(value: boolean) {
|
||||
setStore("general", "showTurnDiffs", value)
|
||||
},
|
||||
mobileTitlebarPosition: withFallback(
|
||||
() => store.general?.mobileTitlebarPosition,
|
||||
defaultSettings.general.mobileTitlebarPosition,
|
||||
|
||||
@@ -8,10 +8,11 @@ import type { ServerConnection } from "@/runtime/server/registry"
|
||||
import { sessionHref } from "@/shell/routes/session"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useTabs, type Tab } from "@/shell/tabs/tabs"
|
||||
import { readLocalImage } from "@/runtime/server/image"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { createTurnDiffCache } from "@/session/timeline/turn-diffs"
|
||||
|
||||
export function SessionUIProvider(
|
||||
props: ParentProps<{
|
||||
@@ -65,6 +66,23 @@ export function SessionUIProvider(
|
||||
|
||||
const providers = useProviders(directory)
|
||||
|
||||
// Turn diffs live in the tab's in-memory state, so they outlast timeline remounts and go when the tab closes.
|
||||
const turnDiffs = () => {
|
||||
const id = params.id
|
||||
|
||||
if (!id) return
|
||||
|
||||
const tab =
|
||||
tabs.store.find(
|
||||
(item) =>
|
||||
item.type === "session" &&
|
||||
item.server === props.server &&
|
||||
(item.sessionId === id || item.routeSessionId === id),
|
||||
) ?? ({ type: "session", server: props.server, sessionId: id } satisfies Tab)
|
||||
|
||||
return tabs.state(tab, "turnDiffs", createTurnDiffCache)
|
||||
}
|
||||
|
||||
const sessionUIData = createMemo(() => ({
|
||||
provider: providers.ready()
|
||||
? { all: providers.all(), default: providers.default(), connected: providers.connected().map((item) => item.id) }
|
||||
@@ -88,6 +106,10 @@ export function SessionUIProvider(
|
||||
sessionID={params.id}
|
||||
shellRunning={(id) => !!data.shell.get(id)}
|
||||
shellOutput={(input) => serverSDK.api.shell.output(input)}
|
||||
turnDiff={{
|
||||
get: (input) => turnDiffs()?.get(input),
|
||||
load: (input) => turnDiffs()?.load(input, () => serverSDK.api.session.diff(input)),
|
||||
}}
|
||||
onNavigateToSession={navigateToSession}
|
||||
onSessionHref={href}
|
||||
>
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
|
||||
import { displayName } from "@opencode/ui/project-avatar"
|
||||
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
|
||||
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
|
||||
import type { SessionInfo } from "@opencode/client/promise"
|
||||
import { sessionTabTitle } from "./tab-title"
|
||||
@@ -28,6 +27,8 @@ export function TabNavItem(props: {
|
||||
server: ServerConnection.Key
|
||||
session: SessionInfo | undefined
|
||||
preparing: boolean
|
||||
unread: boolean
|
||||
loading: boolean
|
||||
fallbackTitle?: string
|
||||
onRename: (title: string) => Promise<void>
|
||||
onClose: () => void
|
||||
@@ -57,13 +58,7 @@ export function TabNavItem(props: {
|
||||
const servers = useServers()
|
||||
const serverCtx = useServerCtx(() => servers.list.find((item) => ServerConnection.key(item) === props.server))
|
||||
|
||||
const avatar = useSessionTabAvatarState(
|
||||
() => props.server,
|
||||
() => props.session?.id ?? "",
|
||||
() => true,
|
||||
)
|
||||
|
||||
const unread = () => !!props.session && !props.active && avatar.unread() && !avatar.loading()
|
||||
const unread = () => !!props.session && !props.active && props.unread
|
||||
|
||||
const project = createMemo(() => {
|
||||
const session = props.session
|
||||
@@ -312,7 +307,7 @@ export function TabNavItem(props: {
|
||||
project={project()}
|
||||
directory={session.location.directory}
|
||||
unread={false}
|
||||
loading={avatar.loading()}
|
||||
loading={props.loading}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { mergeVisibleTabOrder } from "./tab-order"
|
||||
import { adjacentTabKey, mergeVisibleTabOrder } from "./tab-order"
|
||||
|
||||
test("finds the nearest accepted tab in either direction, wrapping around", () => {
|
||||
const order = ["a", "b", "c", "d", "e"]
|
||||
const unread = new Set(["a", "d"])
|
||||
|
||||
expect(adjacentTabKey(order, "b", 1, (key) => unread.has(key))).toBe("d")
|
||||
expect(adjacentTabKey(order, "b", -1, (key) => unread.has(key))).toBe("a")
|
||||
expect(adjacentTabKey(order, "e", 1, (key) => unread.has(key))).toBe("a")
|
||||
expect(adjacentTabKey(order, "a", -1, (key) => unread.has(key))).toBe("d")
|
||||
expect(adjacentTabKey(order, "a", 1, (key) => key === "a")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("merges reordered visible tabs around hidden tabs", () => {
|
||||
expect(mergeVisibleTabOrder(["a", "hidden", "b", "c"], ["a", "b", "c"], ["c", "a", "b"])).toEqual([
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
export function adjacentTabKey(order: string[], current: string | undefined, offset: -1 | 1) {
|
||||
if (!current || order.length === 0) return
|
||||
/** The nearest other tab in the offset's direction, wrapping around, that `include` accepts. */
|
||||
export function adjacentTabKey(
|
||||
order: string[],
|
||||
current: string | undefined,
|
||||
offset: -1 | 1,
|
||||
include: (key: string) => boolean = () => true,
|
||||
) {
|
||||
if (!current) return
|
||||
const index = order.indexOf(current)
|
||||
|
||||
if (index === -1) return
|
||||
|
||||
return order[(index + offset + order.length) % order.length]
|
||||
return Array.from(
|
||||
{ length: order.length - 1 },
|
||||
(_, step) => order[(index + offset * (step + 1) + order.length) % order.length],
|
||||
).find(include)
|
||||
}
|
||||
|
||||
export function mergeVisibleTabOrder(all: string[], current: string[], next: string[]) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useGlobal, useServerCtx, type ServerCtx } from "@/runtime/server/runtim
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
|
||||
import { createTabComposerState } from "@/composer/persistence"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
@@ -27,6 +28,8 @@ function SessionTabSlot(props: {
|
||||
orientation: "horizontal" | "vertical"
|
||||
session: SessionInfo | undefined
|
||||
preparing: boolean
|
||||
unread: boolean
|
||||
loading: boolean
|
||||
fallbackTitle?: string
|
||||
onRename: (title: string) => Promise<void>
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
@@ -64,6 +67,8 @@ function SessionTabSlot(props: {
|
||||
server={props.tab.server}
|
||||
session={props.session}
|
||||
preparing={props.preparing}
|
||||
unread={props.unread}
|
||||
loading={props.loading}
|
||||
fallbackTitle={props.fallbackTitle}
|
||||
onRename={props.onRename}
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
@@ -84,6 +89,7 @@ function SessionTabEntry(props: {
|
||||
orientation: "horizontal" | "vertical"
|
||||
serverCtx: ServerCtx | undefined
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
onUnreadChange: (unread: boolean) => void
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
@@ -94,6 +100,15 @@ function SessionTabEntry(props: {
|
||||
const cachedSession = createMemo(() => props.serverCtx?.data.session.get(props.tab.sessionId))
|
||||
const persisted = createMemo(() => tabs.info[props.id])
|
||||
|
||||
const status = useSessionTabAvatarState(
|
||||
() => props.tab.server,
|
||||
() => props.tab.sessionId,
|
||||
() => true,
|
||||
)
|
||||
|
||||
// A busy session shows its progress indicator instead of the unread dot.
|
||||
const unread = createMemo(() => status.unread() && !status.loading())
|
||||
|
||||
const [loadedSession] = createResource(
|
||||
() => {
|
||||
if (pending()) return null
|
||||
@@ -135,6 +150,7 @@ function SessionTabEntry(props: {
|
||||
}
|
||||
|
||||
createEffect(() => props.onVisibleChange(visible()))
|
||||
createEffect(() => props.onUnreadChange(unread()))
|
||||
|
||||
createEffect(() => {
|
||||
const ctx = props.serverCtx
|
||||
@@ -186,6 +202,8 @@ function SessionTabEntry(props: {
|
||||
orientation={props.orientation}
|
||||
session={session()}
|
||||
preparing={!!pending()}
|
||||
unread={unread()}
|
||||
loading={status.loading()}
|
||||
fallbackTitle={
|
||||
pending()
|
||||
? language.t("session.tab.session")
|
||||
@@ -263,6 +281,7 @@ export function TitlebarTabStrip(props: {
|
||||
const vertical = () => props.orientation === "vertical"
|
||||
let listRef!: HTMLDivElement
|
||||
const [visibility, setVisibility] = createStore<Record<string, boolean>>({})
|
||||
const [unread, setUnread] = createStore<Record<string, boolean>>({})
|
||||
const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)]))
|
||||
const visibleTabIds = () => visibleTabs().map(tabKey)
|
||||
|
||||
@@ -283,11 +302,39 @@ export function TitlebarTabStrip(props: {
|
||||
hidden: true,
|
||||
onSelect: () => selectAdjacentTab(1),
|
||||
},
|
||||
{
|
||||
id: "session.previous",
|
||||
category: language.t("command.category.session"),
|
||||
title: language.t("command.session.previous"),
|
||||
keybind: "alt+arrowup",
|
||||
onSelect: () => selectAdjacentTab(-1),
|
||||
},
|
||||
{
|
||||
id: "session.next",
|
||||
category: language.t("command.category.session"),
|
||||
title: language.t("command.session.next"),
|
||||
keybind: "alt+arrowdown",
|
||||
onSelect: () => selectAdjacentTab(1),
|
||||
},
|
||||
{
|
||||
id: "session.previous.unseen",
|
||||
category: language.t("command.category.session"),
|
||||
title: language.t("command.session.previous.unseen"),
|
||||
keybind: "shift+alt+arrowup",
|
||||
onSelect: () => selectAdjacentTab(-1, (key) => !!unread[key]),
|
||||
},
|
||||
{
|
||||
id: "session.next.unseen",
|
||||
category: language.t("command.category.session"),
|
||||
title: language.t("command.session.next.unseen"),
|
||||
keybind: "shift+alt+arrowdown",
|
||||
onSelect: () => selectAdjacentTab(1, (key) => !!unread[key]),
|
||||
},
|
||||
])
|
||||
|
||||
function selectAdjacentTab(offset: -1 | 1) {
|
||||
function selectAdjacentTab(offset: -1 | 1, include?: (key: string) => boolean) {
|
||||
const current = props.currentTab
|
||||
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
|
||||
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset, include)
|
||||
const next = props.tabs.find((tab) => tabKey(tab) === key)
|
||||
|
||||
if (next) props.onNavigate(next)
|
||||
@@ -390,6 +437,7 @@ export function TitlebarTabStrip(props: {
|
||||
orientation={vertical() ? "vertical" : "horizontal"}
|
||||
serverCtx={serverCtx()}
|
||||
onVisibleChange={(visible) => setVisibility(id, visible)}
|
||||
onUnreadChange={(value) => setUnread(id, value)}
|
||||
onNavigate={(element) => {
|
||||
ref = element
|
||||
props.onNavigate(tab, element)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -43,7 +43,16 @@ export function filename(channel = OPENCODE_CHANNEL) {
|
||||
return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
|
||||
}
|
||||
|
||||
export function defaultPort(channel = OPENCODE_CHANNEL) {
|
||||
export function defaultPort(channel = OPENCODE_CHANNEL, platform = process.platform) {
|
||||
const port = channelPort(channel)
|
||||
// Windows hands out 0xC000-0xFFFF for outgoing connections, and Hyper-V and WinNAT reserve blocks of that range at
|
||||
// boot, so a port there can fail as in use while nothing listens on it. Moving into the 0xB000 block keeps the low
|
||||
// 12 bits: 0xC0DE becomes 0xB0DE, which is 0xC0DE - 4096, a nod to the v1 port.
|
||||
if (platform === "win32" && port >= 0xc000) return 0xb000 | (port & 0xfff)
|
||||
return port
|
||||
}
|
||||
|
||||
function channelPort(channel: string) {
|
||||
if (channel === "latest" || channel === "dev" || channel === "beta" || channel === "next") return 0xc0de
|
||||
if (channel === "local") return 0xc0df
|
||||
return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
|
||||
|
||||
@@ -36,7 +36,18 @@ export const register = Effect.fnUntraced(function* (options: {
|
||||
found.url === info.url &&
|
||||
found.pid === info.pid &&
|
||||
found.password === info.password
|
||||
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, options.file)))
|
||||
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(
|
||||
Effect.andThen(
|
||||
fs.rename(temp, options.file).pipe(
|
||||
// Windows refuses to replace a file while another process has it open, as discovering clients briefly do.
|
||||
Effect.retry({
|
||||
while: (error) => process.platform === "win32" && error.reason._tag !== "NotFound",
|
||||
schedule: Schedule.spaced("20 millis"),
|
||||
times: 50,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* current.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("managed service registration check failed; shutting down", {
|
||||
|
||||
@@ -18,13 +18,27 @@ import { testEffect } from "../../core/test/lib/effect"
|
||||
const it = testEffect(NodeFileSystem.layer)
|
||||
|
||||
test("managed service ports are stable per installation channel", () => {
|
||||
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
|
||||
expect(ServiceConfig.defaultPort("dev")).toBe(0xc0de)
|
||||
expect(ServiceConfig.defaultPort("beta")).toBe(0xc0de)
|
||||
expect(ServiceConfig.defaultPort("next")).toBe(0xc0de)
|
||||
expect(ServiceConfig.defaultPort("local")).toBe(0xc0df)
|
||||
expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a"))
|
||||
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
|
||||
expect(ServiceConfig.defaultPort("latest", "linux")).toBe(0xc0de)
|
||||
expect(ServiceConfig.defaultPort("dev", "linux")).toBe(0xc0de)
|
||||
expect(ServiceConfig.defaultPort("beta", "linux")).toBe(0xc0de)
|
||||
expect(ServiceConfig.defaultPort("next", "linux")).toBe(0xc0de)
|
||||
expect(ServiceConfig.defaultPort("local", "linux")).toBe(0xc0df)
|
||||
expect(ServiceConfig.defaultPort("latest", "darwin")).toBe(0xc0de)
|
||||
expect(ServiceConfig.defaultPort("preview-a", "linux")).toBe(ServiceConfig.defaultPort("preview-a", "linux"))
|
||||
expect(ServiceConfig.defaultPort("preview-a", "linux")).not.toBe(ServiceConfig.defaultPort("preview-b", "linux"))
|
||||
})
|
||||
|
||||
test("managed service ports on Windows stay below the range Windows reserves", () => {
|
||||
expect(ServiceConfig.defaultPort("latest", "win32")).toBe(0xc0de - 4096)
|
||||
expect(ServiceConfig.defaultPort("dev", "win32")).toBe(0xc0de - 4096)
|
||||
expect(ServiceConfig.defaultPort("local", "win32")).toBe(0xc0df - 4096)
|
||||
const ports = Array.from({ length: 200 }, (_, index) => ({
|
||||
windows: ServiceConfig.defaultPort(`preview-${index}`, "win32"),
|
||||
linux: ServiceConfig.defaultPort(`preview-${index}`, "linux"),
|
||||
}))
|
||||
expect(ports.some((port) => port.linux >= 0xc000)).toBe(true)
|
||||
expect(ports.every((port) => port.windows >= 10_000 && port.windows < 0xc000)).toBe(true)
|
||||
expect(ports.filter((port) => port.linux < 0xc000 && port.windows !== port.linux)).toEqual([])
|
||||
})
|
||||
|
||||
test("service disabled accepts only booleans without changing configuration on invalid input", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"dev": "vite dev --host 0.0.0.0",
|
||||
"dev:enterprise": "bun --watch script/dev-enterprise.ts",
|
||||
"dev:remote": "bun sst shell --stage=dev bun dev",
|
||||
"build": "bun ./script/generate-sitemap.ts && vite build",
|
||||
"start": "vite start"
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { handleEnterpriseRequest } from "../src/lib/enterprise"
|
||||
import { z } from "zod"
|
||||
import { SalesforceCliError, salesforceCliResponse } from "./salesforce-cli-response"
|
||||
import { handleConsoleSignup } from "../src/lib/console-signup"
|
||||
import { consoleAccountSchema } from "../src/lib/console-account"
|
||||
|
||||
const org = process.env.SF_TARGET_ORG || "opencode"
|
||||
const crmToken = process.env.CONSOLE_CRM_TOKEN
|
||||
if (!crmToken) throw new Error("Set CONSOLE_CRM_TOKEN to the shared local Console CRM token")
|
||||
const accountUrl = process.env.CONSOLE_CRM_LOOKUP_URL || "http://localhost:3100/console/api/internal/console-account"
|
||||
|
||||
async function authenticatedRequest(
|
||||
path: string,
|
||||
payload?: Record<string, unknown>,
|
||||
method: "GET" | "POST" | "PATCH" = payload ? "POST" : "GET",
|
||||
) {
|
||||
const child = Bun.spawn(
|
||||
[
|
||||
"sf",
|
||||
"api",
|
||||
"request",
|
||||
"rest",
|
||||
path,
|
||||
"--target-org",
|
||||
org,
|
||||
"--json",
|
||||
"--method",
|
||||
method,
|
||||
...(payload ? ["--body", "-", "--header", "Content-Type:application/json"] : []),
|
||||
],
|
||||
{
|
||||
stdin: payload ? new Blob([JSON.stringify(payload)]) : "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
)
|
||||
const [output] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
child.exited,
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
return salesforceCliResponse(output)
|
||||
}
|
||||
|
||||
await authenticatedRequest("/services/data/v59.0/")
|
||||
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: Number(process.env.ENTERPRISE_DEV_PORT || 3101),
|
||||
async fetch(request) {
|
||||
const path = new URL(request.url).pathname
|
||||
if (path === "/health" && request.method === "GET") {
|
||||
return Response.json({ handler: "local-sst-enterprise", salesforceOrg: org })
|
||||
}
|
||||
if (path === "/api/console-signup" && request.method === "POST") {
|
||||
return handleConsoleSignup(request, crmToken, (path, method, payload) =>
|
||||
authenticatedRequest(path, payload, method),
|
||||
)
|
||||
}
|
||||
if (path !== "/api/enterprise") return new Response("Not found", { status: 404 })
|
||||
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 })
|
||||
const failures: string[] = []
|
||||
const response = await handleEnterpriseRequest(request, {
|
||||
salesforceSubmit: async (payload) => {
|
||||
const result = await authenticatedRequest("/services/data/v59.0/sobjects/Lead", payload).catch(
|
||||
(error: unknown) => {
|
||||
const code = error instanceof SalesforceCliError ? error.code : "CLI_ERROR"
|
||||
failures.push(code)
|
||||
console.error(`Local Salesforce submission rejected: ${code}`)
|
||||
return undefined
|
||||
},
|
||||
)
|
||||
return z.object({ success: z.literal(true) }).safeParse(result).success
|
||||
},
|
||||
notifications: false,
|
||||
accountLookup: async (email) => {
|
||||
const response = await fetch(accountUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${crmToken}` },
|
||||
body: JSON.stringify({ email }),
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
if (!response.ok) throw new Error(`Local Console account lookup failed (${response.status})`)
|
||||
return z.object({ account: consoleAccountSchema.nullable() }).parse(await response.json()).account
|
||||
},
|
||||
})
|
||||
if (failures.length > 0) {
|
||||
return Response.json(
|
||||
{ error: "Salesforce rejected the request.", errorCode: failures[0] },
|
||||
{ status: 502, headers: { "X-Enterprise-Handler": "local-sst-enterprise" } },
|
||||
)
|
||||
}
|
||||
response.headers.set("X-Enterprise-Handler", "local-sst-enterprise")
|
||||
return response
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Local Enterprise handler: ${server.url.origin}/api/enterprise`)
|
||||
console.log(`Salesforce CLI org: ${org}; local email and EmailOctopus delivery disabled.`)
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { SalesforceCliError, salesforceCliResponse } from "./salesforce-cli-response"
|
||||
|
||||
test("unwraps a successful Salesforce Lead creation from the CLI HTTP envelope", () => {
|
||||
expect(
|
||||
salesforceCliResponse(
|
||||
JSON.stringify({
|
||||
status: 0,
|
||||
result: { statusCode: 201, headers: {}, body: { id: "test-lead", success: true, errors: [] } },
|
||||
}),
|
||||
),
|
||||
).toEqual({ id: "test-lead", success: true, errors: [] })
|
||||
})
|
||||
|
||||
test("rejects a Salesforce HTTP failure even if the CLI exits successfully", () => {
|
||||
expect(() =>
|
||||
salesforceCliResponse(
|
||||
JSON.stringify({
|
||||
status: 0,
|
||||
result: { statusCode: 400, body: [{ errorCode: "INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST" }] },
|
||||
}),
|
||||
),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test("rejects CLI errors and responses missing the HTTP envelope", () => {
|
||||
expect(() => salesforceCliResponse(JSON.stringify({ status: 1, message: "CLI error" }))).toThrow()
|
||||
expect(() => salesforceCliResponse(JSON.stringify({ status: 0, result: { success: true } }))).toThrow()
|
||||
})
|
||||
|
||||
test("preserves the Salesforce rejection code when the CLI exits nonzero", () => {
|
||||
expect(() =>
|
||||
salesforceCliResponse(
|
||||
JSON.stringify({
|
||||
status: 1,
|
||||
result: { statusCode: 400, body: [{ errorCode: "DUPLICATES_DETECTED", message: "Duplicate found" }] },
|
||||
}),
|
||||
),
|
||||
).toThrow(new SalesforceCliError("DUPLICATES_DETECTED"))
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export class SalesforceCliError extends Error {
|
||||
constructor(readonly code: string) {
|
||||
super(`Salesforce request failed: ${code}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function salesforceCliResponse(output: string) {
|
||||
const response = z
|
||||
.object({
|
||||
status: z.number(),
|
||||
result: z.object({ statusCode: z.number().int(), body: z.unknown() }).optional(),
|
||||
})
|
||||
.parse(JSON.parse(output))
|
||||
if (!response.result) throw new SalesforceCliError("CLI_ERROR")
|
||||
if (response.status !== 0 || response.result.statusCode < 200 || response.result.statusCode > 299) {
|
||||
const errors = z
|
||||
.array(z.object({ errorCode: z.string().regex(/^[A-Z][A-Z0-9_]+$/) }))
|
||||
.safeParse(response.result.body)
|
||||
throw new SalesforceCliError(errors.success ? (errors.data[0]?.errorCode ?? "REQUEST_FAILED") : "REQUEST_FAILED")
|
||||
}
|
||||
return response.result.body
|
||||
}
|
||||
@@ -299,7 +299,6 @@ export const dict = {
|
||||
"تُنشأ سجلات مراقبة إساءة الاستخدام لكل استخدام لميزات API، ويُحتفظ بها لمدة تصل إلى 30 يومًا.",
|
||||
"go.faq.a5.museRetention":
|
||||
"أسعار توكنات مخفّضة للغاية مقابل منح الإذن باستخدام مطالباتك وإكمالات النموذج لتدريب نماذج Meta المستقبلية.",
|
||||
"go.faq.a5.catJamRetention": "خلال فترته المجانية، قد تُستخدم البيانات التي يتم جمعها لتحسين النموذج.",
|
||||
"go.faq.a5.geminiRetention": "قد تُستخدم مطالباتك وإكمالات النموذج لتدريب نماذج Google المستقبلية.",
|
||||
"go.faq.a5.learnMore": "اعرف المزيد",
|
||||
"go.faq.a5.deepseekRetention": "تُجدَّد اتفاقية ZDR شهريًا. الاتفاقية الحالية سارية حتى 31 أكتوبر 2026.",
|
||||
|
||||
@@ -306,7 +306,6 @@ export const dict = {
|
||||
"Logs de monitoramento de abuso são gerados para todo uso de recursos da API e retidos por até 30 dias.",
|
||||
"go.faq.a5.museRetention":
|
||||
"Preços de tokens com grandes descontos em troca da permissão para usar seus prompts e respostas geradas para treinar futuros modelos da Meta.",
|
||||
"go.faq.a5.catJamRetention": "Durante o período gratuito, os dados coletados podem ser usados para melhorar o modelo.",
|
||||
"go.faq.a5.geminiRetention":
|
||||
"Seus prompts e respostas geradas podem ser usados para treinar futuros modelos do Google.",
|
||||
"go.faq.a5.learnMore": "Saiba mais",
|
||||
|
||||
@@ -304,7 +304,6 @@ export const dict = {
|
||||
"Logfiler til overvågning af misbrug genereres ved al brug af API-funktioner og opbevares i op til 30 dage.",
|
||||
"go.faq.a5.museRetention":
|
||||
"Kraftigt nedsatte tokenpriser til gengæld for tilladelse til at bruge dine prompts og modelsvar til at træne fremtidige Meta-modeller.",
|
||||
"go.faq.a5.catJamRetention": "I gratisperioden kan indsamlede data blive brugt til at forbedre modellen.",
|
||||
"go.faq.a5.geminiRetention": "Dine prompts og modelsvar kan blive brugt til at træne fremtidige Google-modeller.",
|
||||
"go.faq.a5.learnMore": "Læs mere",
|
||||
"go.faq.a5.deepseekRetention":
|
||||
|
||||
@@ -306,7 +306,6 @@ export const dict = {
|
||||
"Für die Nutzung aller API-Funktionen werden Protokolle zur Missbrauchsüberwachung erstellt und bis zu 30 Tage lang aufbewahrt.",
|
||||
"go.faq.a5.museRetention":
|
||||
"Stark vergünstigte Tokenpreise im Gegenzug für die Erlaubnis, deine Prompts und Vervollständigungen zum Trainieren zukünftiger Meta-Modelle zu verwenden.",
|
||||
"go.faq.a5.catJamRetention": "Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.",
|
||||
"go.faq.a5.geminiRetention":
|
||||
"Deine Prompts und Vervollständigungen können zum Trainieren zukünftiger Google-Modelle verwendet werden.",
|
||||
"go.faq.a5.learnMore": "Mehr erfahren",
|
||||
|
||||
@@ -304,7 +304,6 @@ export const dict = {
|
||||
"Heavily discounted token pricing in exchange for permission to use your prompts and completions to train future Meta models.",
|
||||
"go.faq.a5.deepseekRetention":
|
||||
"ZDR agreement is renewed monthly. The current agreement is valid through October 31, 2026.",
|
||||
"go.faq.a5.catJamRetention": "During its free period, collected data may be used to improve the model.",
|
||||
"go.faq.a5.geminiRetention": "Your prompts and completions may be used to train future Google models.",
|
||||
"go.faq.a5.learnMore": "Learn more",
|
||||
|
||||
|
||||
@@ -307,7 +307,6 @@ export const dict = {
|
||||
"Se generan registros de supervisión de abusos para todo el uso de funciones de la API y se conservan durante un máximo de 30 días.",
|
||||
"go.faq.a5.museRetention":
|
||||
"Precios de tokens muy reducidos a cambio de permitir que tus prompts y las respuestas generadas se utilicen para entrenar futuros modelos de Meta.",
|
||||
"go.faq.a5.catJamRetention": "Durante su periodo gratuito, los datos recopilados pueden usarse para mejorar el modelo.",
|
||||
"go.faq.a5.geminiRetention":
|
||||
"Tus prompts y las respuestas generadas pueden utilizarse para entrenar futuros modelos de Google.",
|
||||
"go.faq.a5.learnMore": "Más información",
|
||||
|
||||
@@ -309,7 +309,6 @@ export const dict = {
|
||||
"Des journaux de surveillance des abus sont générés pour toute utilisation des fonctionnalités API et conservés pendant un maximum de 30 jours.",
|
||||
"go.faq.a5.museRetention":
|
||||
"Des tarifs de tokens fortement réduits en échange de l’autorisation d’utiliser vos prompts et vos complétions pour entraîner de futurs modèles Meta.",
|
||||
"go.faq.a5.catJamRetention": "Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.",
|
||||
"go.faq.a5.geminiRetention":
|
||||
"Vos prompts et vos complétions peuvent être utilisés pour entraîner de futurs modèles Google.",
|
||||
"go.faq.a5.learnMore": "En savoir plus",
|
||||
|
||||
@@ -302,7 +302,6 @@ export const dict = {
|
||||
"ZDR disabilita importanti funzionalità API che dipendono dai dati archiviati, tra cui la Responses API con stato, Files and Collections e Batch API.",
|
||||
"go.faq.a5.gptRetention":
|
||||
"I log di monitoraggio degli abusi vengono generati per l'utilizzo di tutte le funzionalità API e conservati per un massimo di 30 giorni.",
|
||||
"go.faq.a5.catJamRetention": "Durante il periodo gratuito, i dati raccolti possono essere utilizzati per migliorare il modello.",
|
||||
"go.faq.a5.geminiRetention":
|
||||
"I tuoi prompt e completamenti possono essere utilizzati per addestrare futuri modelli Google.",
|
||||
"go.faq.a5.learnMore": "Scopri di più",
|
||||
|
||||
@@ -302,7 +302,6 @@ export const dict = {
|
||||
"go.faq.a5.gptRetention": "不正使用監視ログはすべてのAPI機能の使用時に生成され、最大30日間保持されます。",
|
||||
"go.faq.a5.museRetention":
|
||||
"将来のMetaモデルのトレーニングにプロンプトと生成結果を使用する許可と引き換えに、トークン料金が大幅に割引されます。",
|
||||
"go.faq.a5.catJamRetention": "無料提供期間中、収集されたデータはモデルの改善に使用される場合があります。",
|
||||
"go.faq.a5.geminiRetention": "プロンプトと生成結果は、将来のGoogleモデルのトレーニングに使用される場合があります。",
|
||||
"go.faq.a5.learnMore": "詳しく見る",
|
||||
"go.faq.a5.deepseekRetention": "ZDR契約は毎月更新されます。現在の契約は2026年10月31日まで有効です。",
|
||||
|
||||
@@ -297,7 +297,6 @@ export const dict = {
|
||||
"go.faq.a5.gptRetention": "모든 API 기능 사용에 대해 악용 모니터링 로그가 생성되며 최대 30일 동안 보존됩니다.",
|
||||
"go.faq.a5.museRetention":
|
||||
"향후 Meta 모델 학습에 사용자의 프롬프트와 생성 결과를 사용할 수 있도록 허용하는 대신 토큰 가격이 대폭 할인됩니다.",
|
||||
"go.faq.a5.catJamRetention": "무료 제공 기간 동안 수집된 데이터는 모델 개선에 사용될 수 있습니다.",
|
||||
"go.faq.a5.geminiRetention": "사용자의 프롬프트와 생성 결과는 향후 Google 모델 학습에 사용될 수 있습니다.",
|
||||
"go.faq.a5.learnMore": "자세히 알아보기",
|
||||
"go.faq.a5.deepseekRetention": "ZDR 계약은 매월 갱신됩니다. 현재 계약은 2026년 10월 31일까지 유효합니다.",
|
||||
|
||||
@@ -302,7 +302,6 @@ export const dict = {
|
||||
"ZDR deaktiverer viktige API-funksjoner som er avhengige av lagrede data, inkludert den tilstandsbaserte Responses API, Files and Collections og Batch API.",
|
||||
"go.faq.a5.gptRetention":
|
||||
"Logger for overvåking av misbruk genereres for all bruk av API-funksjoner og oppbevares i opptil 30 dager.",
|
||||
"go.faq.a5.catJamRetention": "I gratisperioden kan innsamlede data brukes til å forbedre modellen.",
|
||||
"go.faq.a5.geminiRetention": "Ledetekstene og fullføringene dine kan brukes til å trene fremtidige Google-modeller.",
|
||||
"go.faq.a5.learnMore": "Les mer",
|
||||
"go.faq.a5.deepseekRetention":
|
||||
|
||||
@@ -303,7 +303,6 @@ export const dict = {
|
||||
"ZDR wyłącza ważne funkcje API zależne od przechowywanych danych, w tym stanowy Responses API, Files and Collections oraz Batch API.",
|
||||
"go.faq.a5.gptRetention":
|
||||
"Dzienniki monitorowania nadużyć są generowane dla każdego użycia funkcji API i przechowywane przez maksymalnie 30 dni.",
|
||||
"go.faq.a5.catJamRetention": "W okresie bezpłatnego dostępu zebrane dane mogą być wykorzystywane do ulepszania modelu.",
|
||||
"go.faq.a5.geminiRetention":
|
||||
"Twoje prompty i odpowiedzi mogą być wykorzystywane do trenowania przyszłych modeli Google.",
|
||||
"go.faq.a5.learnMore": "Dowiedz się więcej",
|
||||
|
||||
@@ -306,7 +306,6 @@ export const dict = {
|
||||
"ZDR отключает важные функции API, зависящие от сохраненных данных, включая Responses API с сохранением состояния, Files and Collections и Batch API.",
|
||||
"go.faq.a5.gptRetention":
|
||||
"Журналы мониторинга злоупотреблений создаются при любом использовании функций API и хранятся до 30 дней.",
|
||||
"go.faq.a5.catJamRetention": "В течение бесплатного периода собранные данные могут использоваться для улучшения модели.",
|
||||
"go.faq.a5.geminiRetention": "Ваши промпты и ответы могут использоваться для обучения будущих моделей Google.",
|
||||
"go.faq.a5.learnMore": "Подробнее",
|
||||
"go.faq.a5.deepseekRetention":
|
||||
|
||||
@@ -302,7 +302,6 @@ export const dict = {
|
||||
"ระบบจะสร้างบันทึกการตรวจสอบการใช้งานในทางที่ผิดสำหรับการใช้งานฟีเจอร์ API ทั้งหมด และเก็บรักษาไว้นานสูงสุด 30 วัน",
|
||||
"go.faq.a5.museRetention":
|
||||
"ราคาของ token ลดลงอย่างมาก โดยแลกกับการอนุญาตให้นำพรอมต์และผลลัพธ์ที่สร้างขึ้นของคุณไปใช้ฝึกโมเดล Meta ในอนาคต",
|
||||
"go.faq.a5.catJamRetention": "ในช่วงที่ให้ใช้ฟรี ข้อมูลที่รวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล",
|
||||
"go.faq.a5.geminiRetention": "พรอมต์และผลลัพธ์ที่สร้างขึ้นของคุณอาจถูกนำไปใช้ฝึกโมเดล Google ในอนาคต",
|
||||
"go.faq.a5.learnMore": "ดูข้อมูลเพิ่มเติม",
|
||||
"go.faq.a5.deepseekRetention": "ข้อตกลง ZDR จะต่ออายุทุกเดือน ข้อตกลงปัจจุบันมีผลใช้ถึงวันที่ 31 ตุลาคม 2026",
|
||||
|
||||
@@ -304,7 +304,6 @@ export const dict = {
|
||||
"ZDR, durum bilgisi tutan Responses API, Files and Collections ve Batch API dahil olmak üzere saklanan verilere bağlı önemli API özelliklerini devre dışı bırakır.",
|
||||
"go.faq.a5.gptRetention":
|
||||
"Tüm API özelliklerinin kullanımı için kötüye kullanım izleme günlükleri oluşturulur ve 30 güne kadar saklanır.",
|
||||
"go.faq.a5.catJamRetention": "Ücretsiz dönem boyunca toplanan veriler modeli geliştirmek için kullanılabilir.",
|
||||
"go.faq.a5.geminiRetention":
|
||||
"İstemleriniz ve tamamlamalarınız gelecekteki Google modellerini eğitmek için kullanılabilir.",
|
||||
"go.faq.a5.learnMore": "Daha fazla bilgi",
|
||||
|
||||
@@ -302,7 +302,6 @@ export const dict = {
|
||||
"ZDR вимикає важливі функції API, які залежать від збережених даних, зокрема Responses API зі збереженням стану, Files and Collections та Batch API.",
|
||||
"go.faq.a5.gptRetention":
|
||||
"Журнали моніторингу зловживань створюються для всіх випадків використання функцій API та зберігаються до 30 днів.",
|
||||
"go.faq.a5.catJamRetention": "Протягом безкоштовного періоду зібрані дані можуть використовуватися для покращення моделі.",
|
||||
"go.faq.a5.geminiRetention":
|
||||
"Ваші промпти та відповіді можуть використовуватися для навчання майбутніх моделей Google.",
|
||||
"go.faq.a5.learnMore": "Докладніше",
|
||||
|
||||
@@ -288,7 +288,6 @@ export const dict = {
|
||||
"ZDR 会禁用依赖所存储数据的重要 API 功能,包括有状态的 Responses API、Files and Collections 和 Batch API。",
|
||||
"go.faq.a5.gptRetention": "所有 API 功能的使用都会生成滥用监控日志,并最多保留 30 天。",
|
||||
"go.faq.a5.museRetention": "以允许使用你的提示词和补全结果训练未来的 Meta 模型为交换,token 价格可获得大幅折扣。",
|
||||
"go.faq.a5.catJamRetention": "在免费期间,收集的数据可能会用于改进模型。",
|
||||
"go.faq.a5.geminiRetention": "你的提示词和模型输出可能会被用于训练未来的 Google 模型。",
|
||||
"go.faq.a5.learnMore": "了解更多",
|
||||
"go.faq.a5.deepseekRetention": "ZDR 协议每月续签。当前协议有效期至 2026 年 10 月 31 日。",
|
||||
|
||||
@@ -287,7 +287,6 @@ export const dict = {
|
||||
"ZDR 會停用依賴儲存資料的重要 API 功能,包括具狀態的 Responses API、Files and Collections 與 Batch API。",
|
||||
"go.faq.a5.gptRetention": "所有 API 功能的使用都會產生濫用監控日誌,並保留最多 30 天。",
|
||||
"go.faq.a5.museRetention": "以允許使用您的提示詞和生成結果來訓練未來的 Meta 模型為交換,token 價格可享大幅折扣。",
|
||||
"go.faq.a5.catJamRetention": "在免費期間,收集的資料可能會用於改進模型。",
|
||||
"go.faq.a5.geminiRetention": "你的提示詞和模型輸出可能會被用於訓練未來的 Google 模型。",
|
||||
"go.faq.a5.learnMore": "了解更多",
|
||||
"go.faq.a5.deepseekRetention": "ZDR 協議每月續簽。目前的協議有效至 2026 年 10 月 31 日。",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Resource } from "@opencode/console-resource"
|
||||
import { z } from "zod"
|
||||
|
||||
export const consoleAccountSchema = z.object({
|
||||
id: z.string().min(1).max(255),
|
||||
email: z.email(),
|
||||
name: z.string().nullable(),
|
||||
createdAt: z.iso.datetime(),
|
||||
})
|
||||
export type ConsoleAccount = z.infer<typeof consoleAccountSchema>
|
||||
|
||||
export async function lookupConsoleAccount(email: string): Promise<ConsoleAccount | null> {
|
||||
const response = await fetch(`${Resource.CONSOLE_CRM_LOOKUP_URL.value}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${Resource.CONSOLE_CRM_TOKEN.value}` },
|
||||
body: JSON.stringify({ email }),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
if (!response.ok) throw new Error(`Console account lookup failed (${response.status})`)
|
||||
const result = z.object({ account: consoleAccountSchema.nullable() }).parse(await response.json())
|
||||
return result.account
|
||||
}
|
||||
|
||||
export function consoleAccountFields(account: ConsoleAccount | null) {
|
||||
if (!account) return { Has_Console_Account__c: false }
|
||||
return {
|
||||
Has_Console_Account__c: true,
|
||||
Console_Account_ID__c: account.id,
|
||||
Console_Signup_Date__c: account.createdAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { syncConsoleAccount, type SalesforceRequest } from "./salesforce"
|
||||
import { handleConsoleSignup } from "./console-signup"
|
||||
import { z } from "zod"
|
||||
|
||||
const account = {
|
||||
id: "user_console_test",
|
||||
email: "person@example.test",
|
||||
name: "Test Person",
|
||||
createdAt: "2026-10-01T10:00:00.000Z",
|
||||
}
|
||||
const servers = new Set<ReturnType<typeof Bun.serve>>()
|
||||
afterEach(async () => {
|
||||
await Promise.all([...servers].map((server) => server.stop(true)))
|
||||
servers.clear()
|
||||
})
|
||||
|
||||
function fixture(
|
||||
initial: { object: "Lead" | "Contact"; Id: string; LeadSource: string; Company?: string; AccountId?: string }[] = [],
|
||||
email = account.email,
|
||||
) {
|
||||
const people: Record<string, unknown>[] = initial.map((person) => ({ ...person }))
|
||||
const writes: { method: string; path: string; payload: Record<string, unknown> }[] = []
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname.endsWith("/query")) {
|
||||
const query = url.searchParams.get("q") ?? ""
|
||||
expect(query).toContain("Console_Account_ID__c")
|
||||
expect(query).toContain(`Email = '${email.toLowerCase()}'`)
|
||||
const object = query.includes("FROM Contact") ? "Contact" : "Lead"
|
||||
if (object === "Lead") expect(query).toContain("IsConverted = false")
|
||||
return Response.json({
|
||||
done: true,
|
||||
records: people.filter((person) => person.object === object).map((person) => ({ Id: person.Id })),
|
||||
})
|
||||
}
|
||||
const payload = z.record(z.string(), z.unknown()).parse(await request.json())
|
||||
writes.push({ method: request.method, path: url.pathname, payload })
|
||||
if (request.method === "PATCH") {
|
||||
const person = people.find((person) => person.Id === url.pathname.split("/").at(-1))
|
||||
if (!person) return Response.json({ error: "Not found" }, { status: 404 })
|
||||
Object.assign(person, payload)
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
const id = "00Q000000000001AAA"
|
||||
people.push({ object: "Lead", Id: id, ...payload })
|
||||
return Response.json({ success: true, id }, { status: 201 })
|
||||
},
|
||||
})
|
||||
servers.add(server)
|
||||
const request: SalesforceRequest = async (path, method = "GET", payload) => {
|
||||
const response = await fetch(new URL(path, server.url), {
|
||||
method,
|
||||
...(payload ? { body: JSON.stringify(payload), headers: { "Content-Type": "application/json" } } : {}),
|
||||
})
|
||||
if (!response.ok) throw new Error("CRM request failed")
|
||||
return response.status === 204 ? null : response.json()
|
||||
}
|
||||
return { people, writes, request }
|
||||
}
|
||||
|
||||
test("a new signup creates one Console lead and an exact retry updates it", async () => {
|
||||
const crm = fixture()
|
||||
await syncConsoleAccount(account, crm.request)
|
||||
await syncConsoleAccount(account, crm.request)
|
||||
expect(crm.people).toHaveLength(1)
|
||||
expect(crm.writes.filter((write) => write.method === "POST")).toHaveLength(1)
|
||||
expect(crm.people[0]).toMatchObject({
|
||||
Company: "example.test",
|
||||
LeadSource: "console signup",
|
||||
Has_Console_Account__c: true,
|
||||
Console_Account_ID__c: account.id,
|
||||
Console_Signup_Date__c: account.createdAt,
|
||||
})
|
||||
expect(crm.writes[1].payload).not.toHaveProperty("LeadSource")
|
||||
expect(crm.writes[1].payload).not.toHaveProperty("Company")
|
||||
})
|
||||
|
||||
test.each([
|
||||
["hello@acme.com", "acme.com"],
|
||||
["jane@company.co.uk", "company.co.uk"],
|
||||
["HELLO@ACME.COM", "acme.com"],
|
||||
["person@gmail.com", "Individual"],
|
||||
["person+signup@GOOGLEMAIL.COM", "Individual"],
|
||||
["person@outlook.com", "Individual"],
|
||||
["person@hotmail.co.uk", "Individual"],
|
||||
["person@yahoo.com", "Individual"],
|
||||
["person@icloud.com", "Individual"],
|
||||
["person@proton.me", "Individual"],
|
||||
["person@protonmail.com", "Individual"],
|
||||
["person@pm.me", "Individual"],
|
||||
["person@aol.com", "Individual"],
|
||||
["person@gmail.com.example.test", "gmail.com.example.test"],
|
||||
])("sets Company for a new signup with %s to %s", async (email, company) => {
|
||||
const crm = fixture([], email)
|
||||
await syncConsoleAccount({ ...account, email }, crm.request)
|
||||
expect(crm.people).toHaveLength(1)
|
||||
expect(crm.people[0]).toMatchObject({ Company: company, Email: email.toLowerCase(), LeadSource: "console signup" })
|
||||
})
|
||||
|
||||
test("matching Leads and Contacts keep their source and receive the Console fields", async () => {
|
||||
const crm = fixture([
|
||||
{ object: "Contact", Id: "003000000000001AAA", LeadSource: "Partner", AccountId: "001000000000001AAA" },
|
||||
{ object: "Lead", Id: "00Q000000000002AAA", LeadSource: "enterprise website form", Company: "Visitor's Company" },
|
||||
])
|
||||
await syncConsoleAccount(account, crm.request)
|
||||
expect(crm.writes.every((write) => write.method === "PATCH")).toBe(true)
|
||||
expect(crm.people.map((person) => person.LeadSource)).toEqual(["Partner", "enterprise website form"])
|
||||
expect(crm.people[0].AccountId).toBe("001000000000001AAA")
|
||||
expect(crm.people[1].Company).toBe("Visitor's Company")
|
||||
for (const write of crm.writes) {
|
||||
expect(write.payload).not.toHaveProperty("Company")
|
||||
expect(write.payload).not.toHaveProperty("AccountId")
|
||||
}
|
||||
for (const person of crm.people)
|
||||
expect(person).toMatchObject({
|
||||
Has_Console_Account__c: true,
|
||||
Console_Account_ID__c: account.id,
|
||||
Console_Signup_Date__c: account.createdAt,
|
||||
})
|
||||
})
|
||||
|
||||
test("a converted person's Contact is updated without creating another Lead", async () => {
|
||||
const crm = fixture([{ object: "Contact", Id: "003000000000001AAA", LeadSource: "Website" }])
|
||||
await syncConsoleAccount(account, crm.request)
|
||||
expect(crm.people).toHaveLength(1)
|
||||
expect(crm.writes[0].path).toContain("/Contact/")
|
||||
expect(crm.people[0].LeadSource).toBe("Website")
|
||||
})
|
||||
|
||||
test("the signup endpoint requires its server credential before touching Salesforce", async () => {
|
||||
const crm = fixture()
|
||||
const response = await handleConsoleSignup(
|
||||
new Request("http://localhost/api/console-signup", { method: "POST", body: JSON.stringify(account) }),
|
||||
"test-shared-token",
|
||||
crm.request,
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
expect(crm.writes).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("the authenticated signup endpoint reports confirmed delivery", async () => {
|
||||
const crm = fixture()
|
||||
const response = await handleConsoleSignup(
|
||||
new Request("http://localhost/api/console-signup", {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer test-shared-token" },
|
||||
body: JSON.stringify(account),
|
||||
}),
|
||||
"test-shared-token",
|
||||
crm.request,
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ success: true })
|
||||
expect(crm.people).toHaveLength(1)
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { timingSafeEqual } from "node:crypto"
|
||||
import { consoleAccountSchema } from "~/lib/console-account"
|
||||
import { salesforceRequest, syncConsoleAccount, type SalesforceRequest } from "~/lib/salesforce"
|
||||
|
||||
export async function handleConsoleSignup(request: Request, token: string, salesforce?: SalesforceRequest) {
|
||||
const expected = Buffer.from(`Bearer ${token}`)
|
||||
const actual = Buffer.from(request.headers.get("authorization") ?? "")
|
||||
if (!token || expected.length !== actual.length || !timingSafeEqual(expected, actual))
|
||||
return new Response(null, { status: 401 })
|
||||
const data: unknown = await request.json().catch(() => null)
|
||||
const account = consoleAccountSchema.safeParse(data)
|
||||
if (!account.success) return Response.json({ error: "Invalid Console account" }, { status: 400 })
|
||||
return Promise.resolve(salesforce)
|
||||
.then(async (client) => syncConsoleAccount(account.data, client ?? (await salesforceRequest())))
|
||||
.then(
|
||||
(result) => Response.json(result),
|
||||
() => Response.json({ error: "Salesforce sync unavailable" }, { status: 503 }),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { sendEmail } from "~/lib/aws"
|
||||
import { Resource } from "@opencode/console-resource"
|
||||
import { i18n } from "~/i18n"
|
||||
import { localeFromRequest } from "~/lib/language"
|
||||
import { createLead } from "~/lib/salesforce"
|
||||
import { inferenceSpendOptions } from "~/lib/inference-spend"
|
||||
import { lookupConsoleAccount, type ConsoleAccount } from "~/lib/console-account"
|
||||
|
||||
interface EnterpriseFormData {
|
||||
name: string
|
||||
role: string
|
||||
company?: string
|
||||
email: string
|
||||
phone?: string
|
||||
inferenceSpend?: string
|
||||
alias?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
const EMAIL_OCTOPUS_LIST_ID = "1b381e5e-39bd-11f1-ba4a-cdd4791f0c43"
|
||||
|
||||
function splitFullName(fullName: string) {
|
||||
const parts = fullName
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((p) => p.length > 0)
|
||||
if (parts.length === 0) return { firstName: "", lastName: "" }
|
||||
if (parts.length === 1) return { firstName: parts[0], lastName: "" }
|
||||
return { firstName: parts[0], lastName: parts.slice(1).join(" ") }
|
||||
}
|
||||
|
||||
function subscribe(email: string, fullName: string) {
|
||||
const name = splitFullName(fullName)
|
||||
const fields: Record<string, string> = {}
|
||||
if (name.firstName) fields.FirstName = name.firstName
|
||||
if (name.lastName) fields.LastName = name.lastName
|
||||
|
||||
const payload: { email_address: string; fields?: Record<string, string> } = { email_address: email }
|
||||
if (Object.keys(fields).length) payload.fields = fields
|
||||
|
||||
return fetch(`https://api.emailoctopus.com/lists/${EMAIL_OCTOPUS_LIST_ID}/contacts`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Resource.EMAILOCTOPUS_API_KEY.value}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}).then(
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
console.error("EmailOctopus subscribe failed:", res.status, res.statusText, await res.text())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
(err) => {
|
||||
console.error("Failed to subscribe enterprise email:", err)
|
||||
return false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function handleEnterpriseRequest(
|
||||
request: Request,
|
||||
options: {
|
||||
salesforceSubmit?: (payload: Record<string, unknown>) => Promise<boolean>
|
||||
notifications?: boolean
|
||||
accountLookup?: (email: string) => Promise<ConsoleAccount | null>
|
||||
} = {},
|
||||
) {
|
||||
const dict = i18n(localeFromRequest(request))
|
||||
try {
|
||||
const body = (await request.json()) as EnterpriseFormData
|
||||
const trap = typeof body.alias === "string" ? body.alias.trim() : ""
|
||||
|
||||
if (trap) {
|
||||
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
|
||||
}
|
||||
|
||||
if (!body.name || !body.role || !body.email || !body.message) {
|
||||
return Response.json({ error: dict["enterprise.form.error.allFieldsRequired"] }, { status: 400 })
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(body.email)) {
|
||||
return Response.json({ error: dict["enterprise.form.error.invalidEmailFormat"] }, { status: 400 })
|
||||
}
|
||||
|
||||
const inferenceSpend = inferenceSpendOptions.find((option) => option.value === body.inferenceSpend)
|
||||
if (body.inferenceSpend && !inferenceSpend) {
|
||||
return Response.json({ error: dict["enterprise.form.error.invalidInferenceSpend"] }, { status: 400 })
|
||||
}
|
||||
|
||||
const account = await (options.accountLookup ?? lookupConsoleAccount)(body.email).catch((err) => {
|
||||
console.error("Failed to look up Console account:", err)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const emailContent = `
|
||||
${body.message}<br><br>
|
||||
${inferenceSpend ? `Current monthly inference spend: ${inferenceSpend.salesforceValue}<br><br>` : ""}
|
||||
--<br>
|
||||
${body.name}<br>
|
||||
${body.role}<br>
|
||||
${body.company ? `${body.company}<br>` : ""}${body.email}<br>
|
||||
${body.phone ? `${body.phone}<br>` : ""}`.trim()
|
||||
|
||||
const [lead, mail, octopus] = await Promise.all([
|
||||
createLead(
|
||||
{
|
||||
name: body.name,
|
||||
role: body.role,
|
||||
company: body.company,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
inferenceSpend: inferenceSpend?.salesforceValue,
|
||||
message: body.message,
|
||||
consoleAccount: account,
|
||||
},
|
||||
options.salesforceSubmit,
|
||||
).catch((err) => {
|
||||
console.error("Failed to create Salesforce lead:", err)
|
||||
return false
|
||||
}),
|
||||
options.notifications === false
|
||||
? Promise.resolve(false)
|
||||
: sendEmail({
|
||||
to: Resource.ENTERPRISE_SALES_INBOX_EMAIL.value,
|
||||
subject: `Enterprise Inquiry from ${body.name}`,
|
||||
body: emailContent,
|
||||
replyTo: body.email,
|
||||
}).then(
|
||||
() => true,
|
||||
(err) => {
|
||||
console.error("Failed to send enterprise email:", err)
|
||||
return false
|
||||
},
|
||||
),
|
||||
options.notifications === false ? Promise.resolve(false) : subscribe(body.email, body.name),
|
||||
])
|
||||
|
||||
if (!lead && !mail && !octopus) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn("Enterprise inquiry accepted in dev mode without integrations", { email: body.email })
|
||||
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
|
||||
}
|
||||
console.error("Enterprise inquiry delivery failed", { email: body.email })
|
||||
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
|
||||
}
|
||||
|
||||
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Error processing enterprise form:", error)
|
||||
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterAll, expect, test } from "bun:test"
|
||||
|
||||
const leads: Record<string, unknown>[] = []
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
async fetch(request, instance) {
|
||||
const path = new URL(request.url).pathname
|
||||
if (path === "/services/oauth2/token") {
|
||||
return Response.json({ access_token: "local-test-token", instance_url: instance.url.origin })
|
||||
}
|
||||
if (path === "/services/data/v59.0/sobjects/Lead" && request.method === "POST") {
|
||||
leads.push(await request.json())
|
||||
return Response.json({ id: "local-test-lead", success: true }, { status: 201 })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
const resources = {
|
||||
SST_RESOURCE_SALESFORCE_INSTANCE_URL: server.url.origin,
|
||||
SST_RESOURCE_SALESFORCE_CLIENT_ID: "local-test-client",
|
||||
SST_RESOURCE_SALESFORCE_CLIENT_SECRET: "local-test-secret",
|
||||
}
|
||||
const previous = Object.fromEntries(Object.keys(resources).map((key) => [key, process.env[key]]))
|
||||
Object.entries(resources).forEach(([key, value]) => {
|
||||
process.env[key] = JSON.stringify({ value })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await server.stop(true)
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
return
|
||||
}
|
||||
process.env[key] = value
|
||||
})
|
||||
})
|
||||
|
||||
test("a fake entry keeps the visitor message, spend range, and enterprise lead source in their own fields", async () => {
|
||||
const { createLead } = await import("./salesforce")
|
||||
const accepted = await createLead({
|
||||
name: "Fake Console Visitor",
|
||||
role: "Engineering lead",
|
||||
company: "Example Test Company",
|
||||
email: "fake-visitor@example.test",
|
||||
inferenceSpend: "$10K–$50K",
|
||||
message: " We need SSO for our developers.\nPlease share the enterprise options. ",
|
||||
})
|
||||
|
||||
expect(accepted).toBe(true)
|
||||
expect(leads).toHaveLength(1)
|
||||
expect(leads[0]).toEqual({
|
||||
LastName: "Fake Console Visitor",
|
||||
Company: "Example Test Company",
|
||||
Email: "fake-visitor@example.test",
|
||||
Phone: null,
|
||||
Title: "Engineering lead",
|
||||
Description: " We need SSO for our developers.\nPlease share the enterprise options. ",
|
||||
Current_Monthly_Inference_Spend__c: "$10K–$50K",
|
||||
LeadSource: "enterprise website form",
|
||||
})
|
||||
})
|
||||
|
||||
test("the local Enterprise request handler applies the updated mapping to a form submission", async () => {
|
||||
const { handleEnterpriseRequest } = await import("./enterprise")
|
||||
const response = await handleEnterpriseRequest(
|
||||
new Request("http://localhost/api/enterprise", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "Fake Local Form Visitor",
|
||||
role: "Engineering lead",
|
||||
company: "Example Test Company",
|
||||
email: "fake-local-visitor@example.test",
|
||||
inferenceSpend: "50k-100k",
|
||||
message: " How are you bro!\nThis is a local form test. ",
|
||||
alias: "",
|
||||
}),
|
||||
}),
|
||||
{
|
||||
salesforceSubmit: async (payload) => {
|
||||
const response = await fetch(`${server.url.origin}/services/data/v59.0/sobjects/Lead`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
return response.ok
|
||||
},
|
||||
notifications: false,
|
||||
accountLookup: async () => ({
|
||||
id: "user_test",
|
||||
email: "fake-local-visitor@example.test",
|
||||
name: null,
|
||||
createdAt: "2026-10-01T12:00:00.000Z",
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ success: true })
|
||||
expect(leads.at(-1)).toMatchObject({
|
||||
LastName: "Fake Local Form Visitor",
|
||||
Description: " How are you bro!\nThis is a local form test. ",
|
||||
Current_Monthly_Inference_Spend__c: "$50K–$100K",
|
||||
LeadSource: "enterprise website form",
|
||||
Has_Console_Account__c: true,
|
||||
Console_Account_ID__c: "user_test",
|
||||
Console_Signup_Date__c: "2026-10-01T12:00:00.000Z",
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,54 @@
|
||||
import { Resource } from "@opencode/console-resource"
|
||||
import { z } from "zod"
|
||||
import { consoleAccountFields, type ConsoleAccount } from "./console-account"
|
||||
|
||||
// Match provider domains exactly; business domains hosted by these providers remain business addresses.
|
||||
const personalEmailDomains = new Set([
|
||||
"gmail.com",
|
||||
"googlemail.com",
|
||||
"outlook.com",
|
||||
"outlook.co.uk",
|
||||
"hotmail.com",
|
||||
"hotmail.co.uk",
|
||||
"hotmail.fr",
|
||||
"hotmail.de",
|
||||
"hotmail.it",
|
||||
"live.com",
|
||||
"live.co.uk",
|
||||
"live.ca",
|
||||
"live.com.au",
|
||||
"msn.com",
|
||||
"yahoo.com",
|
||||
"yahoo.co.uk",
|
||||
"yahoo.co.in",
|
||||
"yahoo.com.au",
|
||||
"yahoo.ca",
|
||||
"yahoo.fr",
|
||||
"yahoo.de",
|
||||
"yahoo.it",
|
||||
"yahoo.co.jp",
|
||||
"ymail.com",
|
||||
"rocketmail.com",
|
||||
"icloud.com",
|
||||
"me.com",
|
||||
"mac.com",
|
||||
"proton.me",
|
||||
"protonmail.com",
|
||||
"pm.me",
|
||||
"aol.com",
|
||||
"aim.com",
|
||||
"gmx.com",
|
||||
"gmx.de",
|
||||
"gmx.net",
|
||||
"mail.com",
|
||||
"fastmail.com",
|
||||
"fastmail.fm",
|
||||
"hey.com",
|
||||
"tuta.com",
|
||||
"tuta.io",
|
||||
"tutanota.com",
|
||||
"tutanota.de",
|
||||
])
|
||||
|
||||
async function login() {
|
||||
const url = Resource.SALESFORCE_INSTANCE_URL.value.replace(/\/$/, "")
|
||||
@@ -46,9 +96,25 @@ export interface SalesforceLeadInput {
|
||||
phone?: string
|
||||
inferenceSpend?: string
|
||||
message: string
|
||||
consoleAccount?: ConsoleAccount | null
|
||||
}
|
||||
|
||||
export async function createLead(input: SalesforceLeadInput): Promise<boolean> {
|
||||
export async function createLead(
|
||||
input: SalesforceLeadInput,
|
||||
submit?: (payload: Record<string, unknown>) => Promise<boolean>,
|
||||
): Promise<boolean> {
|
||||
const payload = {
|
||||
LastName: input.name,
|
||||
Company: input.company?.trim() || "Website",
|
||||
Email: input.email,
|
||||
Phone: input.phone ?? null,
|
||||
Title: input.role,
|
||||
Description: input.message,
|
||||
Current_Monthly_Inference_Spend__c: input.inferenceSpend,
|
||||
LeadSource: "enterprise website form",
|
||||
...(input.consoleAccount === undefined ? {} : consoleAccountFields(input.consoleAccount)),
|
||||
}
|
||||
if (submit) return submit(payload)
|
||||
const auth = await login()
|
||||
if (!auth) return false
|
||||
|
||||
@@ -58,18 +124,7 @@ export async function createLead(input: SalesforceLeadInput): Promise<boolean> {
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
LastName: input.name,
|
||||
Company: input.company?.trim() || "Website",
|
||||
Email: input.email,
|
||||
Phone: input.phone ?? null,
|
||||
Title: input.role,
|
||||
Description: input.inferenceSpend
|
||||
? `Current monthly inference spend: ${input.inferenceSpend}\n\n${input.message}`
|
||||
: input.message,
|
||||
Current_Monthly_Inference_Spend__c: input.inferenceSpend,
|
||||
LeadSource: "Website",
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
}).catch((err) => {
|
||||
console.error("Failed to create Salesforce lead:", err)
|
||||
})
|
||||
@@ -83,3 +138,65 @@ export async function createLead(input: SalesforceLeadInput): Promise<boolean> {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export type SalesforceRequest = (
|
||||
path: string,
|
||||
method?: "GET" | "POST" | "PATCH",
|
||||
payload?: Record<string, unknown>,
|
||||
) => Promise<unknown>
|
||||
|
||||
export async function salesforceRequest(): Promise<SalesforceRequest> {
|
||||
const auth = await login()
|
||||
if (!auth) throw new Error("Salesforce authentication unavailable")
|
||||
return async (path, method = "GET", payload) => {
|
||||
const response = await fetch(`${auth.url}${path}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${auth.token}`, "Content-Type": "application/json" },
|
||||
...(payload ? { body: JSON.stringify(payload) } : {}),
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
if (!response.ok) throw new Error(`Salesforce request failed (${response.status})`)
|
||||
if (response.status === 204) return null
|
||||
return response.json()
|
||||
}
|
||||
}
|
||||
|
||||
/** Update existing people without changing their acquisition source or sales notes. */
|
||||
export async function syncConsoleAccount(account: ConsoleAccount, request: SalesforceRequest) {
|
||||
const quote = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")
|
||||
const matches = async (object: "Contact" | "Lead") => {
|
||||
const query = `SELECT Id FROM ${object} WHERE ${object === "Lead" ? "IsConverted = false AND " : ""}(Console_Account_ID__c = '${quote(account.id)}' OR Email = '${quote(account.email.toLowerCase())}')`
|
||||
const records: { Id: string }[] = []
|
||||
let path: string | undefined = `/services/data/v59.0/query?q=${encodeURIComponent(query)}`
|
||||
while (path) {
|
||||
const result = z
|
||||
.object({
|
||||
records: z.array(z.object({ Id: z.string().regex(/^[a-zA-Z0-9]{15,18}$/) })),
|
||||
done: z.boolean(),
|
||||
nextRecordsUrl: z.string().optional(),
|
||||
})
|
||||
.parse(await request(path))
|
||||
records.push(...result.records)
|
||||
if (result.done) break
|
||||
if (!result.nextRecordsUrl?.startsWith("/services/data/")) throw new Error("Salesforce query pagination missing")
|
||||
path = result.nextRecordsUrl
|
||||
}
|
||||
return records.map((record) => ({ object, id: record.Id }))
|
||||
}
|
||||
const people = [...(await matches("Contact")), ...(await matches("Lead"))]
|
||||
const fields = consoleAccountFields(account)
|
||||
for (const person of people)
|
||||
await request(`/services/data/v59.0/sobjects/${person.object}/${person.id}`, "PATCH", fields)
|
||||
if (people.length > 0) return { success: true, created: false, updated: people.length }
|
||||
const domain = account.email.slice(account.email.lastIndexOf("@") + 1).toLowerCase()
|
||||
const result = z.object({ success: z.literal(true), id: z.string() }).parse(
|
||||
await request("/services/data/v59.0/sobjects/Lead", "POST", {
|
||||
LastName: (account.name?.trim() || account.email).slice(0, 80),
|
||||
Company: personalEmailDomains.has(domain) ? "Individual" : domain,
|
||||
Email: account.email.toLowerCase(),
|
||||
LeadSource: "console signup",
|
||||
...fields,
|
||||
}),
|
||||
)
|
||||
return { success: true, created: true, id: result.id }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Resource } from "@opencode/console-resource"
|
||||
import { handleConsoleSignup } from "~/lib/console-signup"
|
||||
|
||||
// SolidStart keeps only the method export from API routes, so the handler lives in lib.
|
||||
export async function POST(event: APIEvent) {
|
||||
return handleConsoleSignup(event.request, Resource.CONSOLE_CRM_TOKEN.value)
|
||||
}
|
||||
@@ -1,138 +1,7 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { sendEmail } from "~/lib/aws"
|
||||
import { Resource } from "@opencode/console-resource"
|
||||
import { i18n } from "~/i18n"
|
||||
import { localeFromRequest } from "~/lib/language"
|
||||
import { createLead } from "~/lib/salesforce"
|
||||
import { inferenceSpendOptions } from "~/lib/inference-spend"
|
||||
|
||||
interface EnterpriseFormData {
|
||||
name: string
|
||||
role: string
|
||||
company?: string
|
||||
email: string
|
||||
phone?: string
|
||||
inferenceSpend?: string
|
||||
alias?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
const EMAIL_OCTOPUS_LIST_ID = "1b381e5e-39bd-11f1-ba4a-cdd4791f0c43"
|
||||
|
||||
function splitFullName(fullName: string) {
|
||||
const parts = fullName
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((p) => p.length > 0)
|
||||
if (parts.length === 0) return { firstName: "", lastName: "" }
|
||||
if (parts.length === 1) return { firstName: parts[0], lastName: "" }
|
||||
return { firstName: parts[0], lastName: parts.slice(1).join(" ") }
|
||||
}
|
||||
|
||||
function subscribe(email: string, fullName: string) {
|
||||
const name = splitFullName(fullName)
|
||||
const fields: Record<string, string> = {}
|
||||
if (name.firstName) fields.FirstName = name.firstName
|
||||
if (name.lastName) fields.LastName = name.lastName
|
||||
|
||||
const payload: { email_address: string; fields?: Record<string, string> } = { email_address: email }
|
||||
if (Object.keys(fields).length) payload.fields = fields
|
||||
|
||||
return fetch(`https://api.emailoctopus.com/lists/${EMAIL_OCTOPUS_LIST_ID}/contacts`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Resource.EMAILOCTOPUS_API_KEY.value}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}).then(
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
console.error("EmailOctopus subscribe failed:", res.status, res.statusText, await res.text())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
(err) => {
|
||||
console.error("Failed to subscribe enterprise email:", err)
|
||||
return false
|
||||
},
|
||||
)
|
||||
}
|
||||
import { handleEnterpriseRequest } from "~/lib/enterprise"
|
||||
|
||||
// SolidStart keeps only the method export from API routes, so the handler lives in lib.
|
||||
export async function POST(event: APIEvent) {
|
||||
const dict = i18n(localeFromRequest(event.request))
|
||||
try {
|
||||
const body = (await event.request.json()) as EnterpriseFormData
|
||||
const trap = typeof body.alias === "string" ? body.alias.trim() : ""
|
||||
|
||||
if (trap) {
|
||||
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
|
||||
}
|
||||
|
||||
if (!body.name || !body.role || !body.email || !body.message) {
|
||||
return Response.json({ error: dict["enterprise.form.error.allFieldsRequired"] }, { status: 400 })
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(body.email)) {
|
||||
return Response.json({ error: dict["enterprise.form.error.invalidEmailFormat"] }, { status: 400 })
|
||||
}
|
||||
|
||||
const inferenceSpend = inferenceSpendOptions.find((option) => option.value === body.inferenceSpend)
|
||||
if (body.inferenceSpend && !inferenceSpend) {
|
||||
return Response.json({ error: dict["enterprise.form.error.invalidInferenceSpend"] }, { status: 400 })
|
||||
}
|
||||
|
||||
const emailContent = `
|
||||
${body.message}<br><br>
|
||||
${inferenceSpend ? `Current monthly inference spend: ${inferenceSpend.salesforceValue}<br><br>` : ""}
|
||||
--<br>
|
||||
${body.name}<br>
|
||||
${body.role}<br>
|
||||
${body.company ? `${body.company}<br>` : ""}${body.email}<br>
|
||||
${body.phone ? `${body.phone}<br>` : ""}`.trim()
|
||||
|
||||
const [lead, mail, octopus] = await Promise.all([
|
||||
createLead({
|
||||
name: body.name,
|
||||
role: body.role,
|
||||
company: body.company,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
inferenceSpend: inferenceSpend?.salesforceValue,
|
||||
message: body.message,
|
||||
}).catch((err) => {
|
||||
console.error("Failed to create Salesforce lead:", err)
|
||||
return false
|
||||
}),
|
||||
sendEmail({
|
||||
to: Resource.ENTERPRISE_SALES_INBOX_EMAIL.value,
|
||||
subject: `Enterprise Inquiry from ${body.name}`,
|
||||
body: emailContent,
|
||||
replyTo: body.email,
|
||||
}).then(
|
||||
() => true,
|
||||
(err) => {
|
||||
console.error("Failed to send enterprise email:", err)
|
||||
return false
|
||||
},
|
||||
),
|
||||
subscribe(body.email, body.name),
|
||||
])
|
||||
|
||||
if (!lead && !mail && !octopus) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn("Enterprise inquiry accepted in dev mode without integrations", { email: body.email })
|
||||
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
|
||||
}
|
||||
console.error("Enterprise inquiry delivery failed", { email: body.email })
|
||||
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
|
||||
}
|
||||
|
||||
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Error processing enterprise form:", error)
|
||||
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
|
||||
}
|
||||
return handleEnterpriseRequest(event.request)
|
||||
}
|
||||
@@ -35,7 +35,7 @@ const models = [
|
||||
{ name: "Space Bunny", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
|
||||
{ name: "LongCat 2.5 Preview Free", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
|
||||
{ name: "Step 5 Preview Free", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
|
||||
{ name: "Cat Jam Free", training: "go.faq.a5.used", retention: "go.faq.a5.notZdr" },
|
||||
{ name: "Cat Jam Free", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
|
||||
{ name: "Grok 4.7", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
|
||||
{ name: "Grok 4.6", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
|
||||
{ name: "GPT 6 Luna", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
|
||||
@@ -343,9 +343,6 @@ export default function Home() {
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Cat Jam Free:</strong> {i18n.t("go.faq.a5.catJamRetention")}
|
||||
</p>
|
||||
<p>
|
||||
<strong>DeepSeek:</strong> {i18n.t("go.faq.a5.deepseekRetention")}
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -4,13 +4,14 @@ import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { sameDirectory } from "@opencode/util/path"
|
||||
import { dirname, join } from "path"
|
||||
import { Effect, PubSub, Semaphore, Stream } from "effect"
|
||||
import path, { dirname, join } from "path"
|
||||
import { Effect, FiberMap, PubSub, Semaphore, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/http"
|
||||
import { Config } from "../../config.js"
|
||||
import { Watcher } from "../../filesystem/watcher.js"
|
||||
import { InstructionDiscovery } from "../../instruction-discovery.js"
|
||||
import { Instructions } from "../../instructions/index.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { AbsolutePath } from "../../schema.js"
|
||||
|
||||
type Loaded =
|
||||
| { readonly type: "available"; readonly files: InstructionDiscovery.File[] }
|
||||
@@ -18,17 +19,19 @@ type Loaded =
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.instruction",
|
||||
effect: Effect.fn(function* () {
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const discovery = yield* InstructionDiscovery.Service
|
||||
// Nothing this plugin watches or loads can contribute when both scopes
|
||||
// are disabled; skip the resolves, the watcher fiber, and the transform.
|
||||
if (!discovery.project && !discovery.global) return
|
||||
// Activate even when both discovery scopes are disabled: configured `instructions`
|
||||
// entries are explicit requests, not discovery.
|
||||
yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||
const location = yield* Location.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const changes = yield* PubSub.sliding<string>(1)
|
||||
const watches = yield* FiberMap.make<string>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const start = yield* fs.resolve(location.directory)
|
||||
const root = yield* fs.resolve(location.project.directory)
|
||||
@@ -37,6 +40,10 @@ export const Plugin = define({
|
||||
const stop = FSUtil.contains(home, start) ? home : root
|
||||
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
|
||||
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
|
||||
const configured: { sources: string[]; remote: Map<string, InstructionDiscovery.File> } = {
|
||||
sources: [],
|
||||
remote: new Map(),
|
||||
}
|
||||
|
||||
const publish = (update: Watcher.Update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid)
|
||||
// The ancestor walk can reach the global file when the location sits
|
||||
@@ -49,15 +56,15 @@ export const Plugin = define({
|
||||
.filter((file) => discovery.global || file !== globalFile)
|
||||
: []),
|
||||
]
|
||||
for (const path of new Set(candidates)) {
|
||||
const updates = yield* watcher.subscribe({ path, type: "file" })
|
||||
for (const file of new Set(candidates)) {
|
||||
const updates = yield* watcher.subscribe({ path: file, type: "file" })
|
||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
|
||||
}
|
||||
|
||||
const read = Effect.fn("ConfigInstructionPlugin.read")(function* (path: string) {
|
||||
const content = yield* fs.readFileStringSafe(path)
|
||||
if (content !== undefined) return new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
|
||||
yield* Effect.logDebug("instruction file skipped", { path, reason: "unavailable" })
|
||||
const read = Effect.fn("ConfigInstructionPlugin.read")(function* (filepath: string) {
|
||||
const content = yield* fs.readFileStringSafe(filepath)
|
||||
if (content !== undefined) return new InstructionDiscovery.File({ path: filepath, content })
|
||||
yield* Effect.logDebug("instruction file skipped", { path: filepath, reason: "unavailable" })
|
||||
})
|
||||
|
||||
const globalSource = Effect.fn("ConfigInstructionPlugin.globalSource")(function* () {
|
||||
@@ -78,6 +85,83 @@ export const Plugin = define({
|
||||
return files.filter((file): file is InstructionDiscovery.File => file !== undefined)
|
||||
})
|
||||
|
||||
// Relative patterns search upward from the location to the project root. Without
|
||||
// project discovery they resolve against the global config directory instead.
|
||||
const match = Effect.fn("ConfigInstructionPlugin.match")(function* (source: string) {
|
||||
const expanded = source.startsWith("~/") ? path.join(global.home, source.slice(2)) : source
|
||||
const options = { absolute: true, include: "file", dot: true } as const
|
||||
if (path.isAbsolute(expanded))
|
||||
return yield* fs
|
||||
.scan(path.basename(expanded), { ...options, cwd: path.dirname(expanded) })
|
||||
.pipe(Effect.orElseSucceed(() => [] as string[]))
|
||||
if (project) return yield* fs.globUp(expanded, start, root)
|
||||
if (!discovery.global) return []
|
||||
return yield* fs
|
||||
.scan(expanded, { ...options, cwd: global.config })
|
||||
.pipe(Effect.orElseSucceed(() => [] as string[]))
|
||||
})
|
||||
|
||||
const configuredSource = Effect.fn("ConfigInstructionPlugin.configuredSource")(function* () {
|
||||
const files: InstructionDiscovery.File[] = []
|
||||
const watched = new Set<string>()
|
||||
for (const source of configured.sources) {
|
||||
if (isUrl(source)) {
|
||||
const file = configured.remote.get(source)
|
||||
if (file) files.push(file)
|
||||
continue
|
||||
}
|
||||
for (const item of (yield* match(source)).toSorted()) {
|
||||
const resolved = yield* fs.resolve(item)
|
||||
if (watched.has(resolved)) continue
|
||||
watched.add(resolved)
|
||||
const file = yield* read(resolved)
|
||||
if (file) files.push(file)
|
||||
}
|
||||
}
|
||||
for (const [key] of Array.from(watches)) if (!watched.has(key)) yield* FiberMap.remove(watches, key)
|
||||
for (const file of watched) {
|
||||
const updates = yield* watcher.subscribe({ path: file, type: "file" })
|
||||
yield* FiberMap.run(watches, file, updates.pipe(Stream.runForEach(publish)), {
|
||||
onlyIfMissing: true,
|
||||
startImmediately: true,
|
||||
})
|
||||
}
|
||||
return files
|
||||
})
|
||||
|
||||
// Remote instructions are fetched only when config loads, not on every file rescan.
|
||||
// A failed fetch keeps the last good copy or skips the URL, so an unreachable URL
|
||||
// never makes the whole instruction set unavailable.
|
||||
const reconfigure = Effect.fn("ConfigInstructionPlugin.reconfigure")(
|
||||
function* () {
|
||||
const entries = yield* config.entries()
|
||||
const sources = Array.from(
|
||||
new Set(entries.flatMap((entry) => (entry.type === "document" ? (entry.info.instructions ?? []) : []))),
|
||||
)
|
||||
const fetched = yield* Effect.forEach(
|
||||
sources.filter(isUrl),
|
||||
(url) =>
|
||||
HttpClientRequest.get(url).pipe(
|
||||
http.execute,
|
||||
Effect.flatMap((response) => response.text),
|
||||
Effect.timeout("5 seconds"),
|
||||
Effect.map((content) => new InstructionDiscovery.File({ path: url, content })),
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to fetch instruction url", { url, cause }).pipe(
|
||||
Effect.as(configured.remote.get(url)),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ concurrency: 4 },
|
||||
)
|
||||
configured.sources = sources
|
||||
configured.remote = new Map(
|
||||
fetched.filter((file) => file !== undefined).map((file) => [file.path, file] as const),
|
||||
)
|
||||
},
|
||||
(effect) => lock.withPermit(effect),
|
||||
)
|
||||
|
||||
const isolate = <A, E, R>(source: string, effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
@@ -92,10 +176,11 @@ export const Plugin = define({
|
||||
const sources = yield* Effect.all({
|
||||
global: isolate("global", globalSource()),
|
||||
project: isolate("project", projectSource()),
|
||||
configured: isolate("configured", configuredSource()),
|
||||
})
|
||||
loaded.current =
|
||||
Array.isArray(sources.global) && Array.isArray(sources.project)
|
||||
? { type: "available", files: [...sources.global, ...sources.project] }
|
||||
Array.isArray(sources.global) && Array.isArray(sources.project) && Array.isArray(sources.configured)
|
||||
? { type: "available", files: [...sources.global, ...sources.project, ...sources.configured] }
|
||||
: { type: "unavailable" }
|
||||
if (!file) return
|
||||
yield* Effect.logDebug("instructions rescanned", {
|
||||
@@ -115,6 +200,13 @@ export const Plugin = define({
|
||||
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
// Subscribe before the first config read so an update in between still triggers a reload.
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() => reconfigure().pipe(Effect.andThen(refresh()), Effect.andThen(discovery.reload()))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* reconfigure()
|
||||
yield* refresh()
|
||||
yield* discovery.transform((editor) => {
|
||||
if (loaded.current.type === "unavailable") {
|
||||
@@ -141,3 +233,7 @@ function ancestorDirectories(start: string, stop: string): string[] {
|
||||
if (sameDirectory(start, stop)) return [start]
|
||||
return [start, ...ancestorDirectories(dirname(start), stop)]
|
||||
}
|
||||
|
||||
function isUrl(source: string) {
|
||||
return URL.canParse(source) && /^https?:$/.test(new URL(source).protocol)
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export const Plugin = define({
|
||||
})
|
||||
}
|
||||
integrations.update(integrationID, (integration) => {
|
||||
integration.name = provider.name ?? integration.name
|
||||
integration.name = provider.name || integration.name
|
||||
})
|
||||
if (provider.env !== undefined) {
|
||||
integrations.method.update({
|
||||
@@ -66,7 +66,7 @@ export const Plugin = define({
|
||||
})
|
||||
provider.activation = "enabled"
|
||||
if (item.canonical !== undefined) provider.canonical = item.canonical
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.name) provider.name = item.name
|
||||
if (item.package !== undefined) provider.package = item.package
|
||||
if (item.compatibility !== undefined)
|
||||
provider.compatibility = { ...provider.compatibility, ...item.compatibility }
|
||||
@@ -111,7 +111,7 @@ export const Plugin = define({
|
||||
if (item.settings?.baseURL !== undefined && model.settings) delete model.settings.baseURL
|
||||
}
|
||||
if (config.family !== undefined) model.family = config.family
|
||||
if (config.name !== undefined) model.name = config.name
|
||||
if (config.name) model.name = config.name
|
||||
if (config.modelID !== undefined) model.modelID = config.modelID
|
||||
if (config.compatibility !== undefined)
|
||||
model.compatibility = { ...model.compatibility, ...config.compatibility }
|
||||
|
||||
@@ -19,7 +19,6 @@ type ParseSource =
|
||||
|
||||
type SubstituteInput = ParseSource & {
|
||||
text: string
|
||||
missing?: "error" | "empty"
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
@@ -56,27 +55,24 @@ const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, tex
|
||||
const filePath = token.slice("{file:".length, -1)
|
||||
const expandedPath = filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath
|
||||
const resolvedPath = path.isAbsolute(expandedPath) ? expandedPath : path.resolve(configDir, expandedPath)
|
||||
const fileContent = yield* fs.readFileString(resolvedPath).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (input.missing === "empty") return Effect.succeed("")
|
||||
const fileContent = yield* fs
|
||||
.readFileStringSafe(resolvedPath)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new InvalidError({ path: configSource, message: `bad file reference: "${token}"` }, { cause: error }),
|
||||
),
|
||||
)
|
||||
// Like a missing {env:VAR}, a missing or unreadable file substitutes empty so one stale secret
|
||||
// reference cannot take down every Location that loads this config.
|
||||
if (fileContent === undefined)
|
||||
yield* Effect.logWarning("config file reference is missing or unreadable", {
|
||||
source: configSource,
|
||||
reference: token,
|
||||
path: resolvedPath,
|
||||
})
|
||||
|
||||
const message = `bad file reference: "${token}"`
|
||||
return Effect.fail(
|
||||
new InvalidError(
|
||||
{
|
||||
path: configSource,
|
||||
message:
|
||||
error._tag === "PlatformError" && error.reason._tag === "NotFound"
|
||||
? `${message} ${resolvedPath} does not exist`
|
||||
: message,
|
||||
},
|
||||
{ cause: error },
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
out += JSON.stringify(fileContent.trim()).slice(1, -1)
|
||||
out += JSON.stringify((fileContent ?? "").trim()).slice(1, -1)
|
||||
cursor = index + token.length
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { createPatch } from "diff"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Instructions } from "./instructions/index.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export class File extends Schema.Class<File>("InstructionDiscovery.File")({
|
||||
path: AbsolutePath,
|
||||
// An absolute file path, or the URL of a configured remote instruction.
|
||||
path: Schema.String,
|
||||
content: Schema.String,
|
||||
}) {}
|
||||
|
||||
@@ -21,7 +21,7 @@ export const Event = {
|
||||
}
|
||||
|
||||
export type Data = {
|
||||
files: Map<AbsolutePath, Types.DeepMutable<File>>
|
||||
files: Map<string, Types.DeepMutable<File>>
|
||||
available: boolean
|
||||
}
|
||||
|
||||
@@ -64,12 +64,12 @@ export const layer = (options?: Options) =>
|
||||
list: () => Array.from(editor.files.values()),
|
||||
add: (file) => editor.files.set(file.path, new File(file) as Types.DeepMutable<File>),
|
||||
update: (path, update) => {
|
||||
const current = editor.files.get(AbsolutePath.make(path))
|
||||
const current = editor.files.get(path)
|
||||
if (!current) return
|
||||
update(current)
|
||||
current.path = AbsolutePath.make(path)
|
||||
current.path = path
|
||||
},
|
||||
remove: (path) => editor.files.delete(AbsolutePath.make(path)),
|
||||
remove: (path) => editor.files.delete(path),
|
||||
unavailable: () => {
|
||||
editor.available = false
|
||||
},
|
||||
|
||||
@@ -1306,7 +1306,7 @@ describe("Config", () => {
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "opencode.jsonc"),
|
||||
`{
|
||||
// Ignored reference: {file:missing.txt}
|
||||
// Ignored reference: {file:.}
|
||||
"username": "user-{env:OPENCODE_TEST_MISSING}",
|
||||
"mcp": {
|
||||
"servers": {
|
||||
@@ -1315,7 +1315,8 @@ describe("Config", () => {
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}",
|
||||
"X-Token": "{file:token.txt}"
|
||||
"X-Token": "{file:token.txt}",
|
||||
"X-Missing": "Bearer {file:~/.opencode-test-missing/key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1335,6 +1336,7 @@ describe("Config", () => {
|
||||
expect(remote.headers).toEqual({
|
||||
Authorization: "Bearer secret",
|
||||
"X-Token": 'file\n"token"',
|
||||
"X-Missing": "Bearer ",
|
||||
})
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { writeFileSync } from "node:fs"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { describe, expect, setDefaultTimeout } from "bun:test"
|
||||
import { Document, Event, Info } from "@opencode/schema/config"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Command } from "@opencode/core/command"
|
||||
@@ -38,6 +38,8 @@ import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes
|
||||
import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
|
||||
setDefaultTimeout(30_000)
|
||||
|
||||
const it = testEffect(
|
||||
Layer.merge(
|
||||
PluginTestLayer,
|
||||
@@ -366,8 +368,9 @@ function title(value: string) {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1)
|
||||
}
|
||||
|
||||
// Watcher delivery and reloads can take seconds on loaded CI runners.
|
||||
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
for (let attempt = 0; attempt < 500; attempt++) {
|
||||
if (yield* condition) return
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { Deferred, Effect, Fiber, Layer, PubSub, Stream } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/http"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Config } from "@opencode/core/config"
|
||||
import { ConfigInstructionPlugin } from "@opencode/core/config/plugin/instruction"
|
||||
import { Watcher } from "@opencode/core/filesystem/watcher"
|
||||
import { InstructionDiscovery } from "@opencode/core/instruction-discovery"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { Document, Info } from "@opencode/schema/config"
|
||||
import { Event } from "@opencode/schema/event"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
@@ -26,13 +30,25 @@ const instructionLayer = (input: {
|
||||
home?: string
|
||||
locationServiceLayer: Layer.Layer<Location.Service>
|
||||
filesystemLayer?: Layer.Layer<FSUtil.Service>
|
||||
instructions?: string[]
|
||||
http?: HttpClient.HttpClient
|
||||
project?: boolean
|
||||
}) => {
|
||||
const watcher = Watcher.testLayer
|
||||
const config = Config.testLayer(input.instructions ? [configured(input.instructions)] : [])
|
||||
return Layer.mergeAll(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
|
||||
LayerNode.group([
|
||||
InstructionDiscovery.node,
|
||||
Bus.node,
|
||||
Config.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
Location.node,
|
||||
Watcher.node,
|
||||
]),
|
||||
[
|
||||
Config.node.replace(config),
|
||||
InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: input.project })),
|
||||
Global.node.replace(
|
||||
input.config || input.home
|
||||
@@ -48,20 +64,34 @@ const instructionLayer = (input: {
|
||||
],
|
||||
),
|
||||
watcher,
|
||||
config,
|
||||
Layer.succeed(HttpClient.HttpClient, input.http ?? respond(() => new Response(null, { status: 404 }))),
|
||||
)
|
||||
}
|
||||
|
||||
const start = Effect.fnUntraced(function* () {
|
||||
yield* ConfigInstructionPlugin.Plugin.effect(host())
|
||||
const start = Effect.fnUntraced(function* (ctx = host()) {
|
||||
yield* ConfigInstructionPlugin.Plugin.effect(ctx)
|
||||
return yield* InstructionDiscovery.Service
|
||||
})
|
||||
|
||||
const file = (path: string, content: string) =>
|
||||
new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
|
||||
const configured = (instructions: string[]) => new Document({ type: "document", info: new Info({ instructions }) })
|
||||
|
||||
const configUpdated = () => ({ id: Event.ID.create(), created: Date.now(), type: "config.updated" as const, data: {} })
|
||||
|
||||
const respond = (handler: (url: string) => Response) =>
|
||||
HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request.url))))
|
||||
|
||||
const file = (path: string, content: string) => new InstructionDiscovery.File({ path, content })
|
||||
|
||||
function emitAndWait(update: Watcher.Update) {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
yield* triggerAndWait(watcher.emit(update))
|
||||
})
|
||||
}
|
||||
|
||||
function triggerAndWait<E, R>(trigger: Effect.Effect<void, E, R>) {
|
||||
return Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const updated = yield* Deferred.make<void>()
|
||||
const fiber = yield* bus.subscribe(InstructionDiscovery.Event.Updated).pipe(
|
||||
@@ -69,7 +99,7 @@ function emitAndWait(update: Watcher.Update) {
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
yield* watcher.emit(update)
|
||||
yield* trigger
|
||||
yield* Deferred.await(updated).pipe(Effect.timeout("2 seconds"))
|
||||
yield* Fiber.interrupt(fiber)
|
||||
})
|
||||
@@ -250,6 +280,133 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads configured files, globs, home paths, and URLs alongside AGENTS.md", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const project = path.join(tmp.path, "repo")
|
||||
const directory = path.join(project, "packages", "core")
|
||||
const agents = path.join(project, "AGENTS.md")
|
||||
const first = path.join(project, "rules", "a.md")
|
||||
const second = path.join(project, "rules", "b.md")
|
||||
const absolute = path.join(tmp.path, "absolute.md")
|
||||
const homeFile = path.join(tmp.path, "home.md")
|
||||
const remote = "https://example.test/instructions.md"
|
||||
const requests: string[] = []
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.dirname(first), { recursive: true })
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await Promise.all([
|
||||
fs.writeFile(agents, "agents"),
|
||||
fs.writeFile(first, "first"),
|
||||
fs.writeFile(second, "second"),
|
||||
fs.writeFile(absolute, "absolute"),
|
||||
fs.writeFile(homeFile, "home"),
|
||||
])
|
||||
})
|
||||
|
||||
const discovery = yield* start()
|
||||
const initialized = yield* readInitial(yield* discovery.load())
|
||||
// The unreachable URL and missing file are skipped without making AGENTS.md unavailable.
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
`Instructions from: ${agents}\nagents`,
|
||||
`Instructions from: ${first}\nfirst`,
|
||||
`Instructions from: ${second}\nsecond`,
|
||||
`Instructions from: ${absolute}\nabsolute`,
|
||||
`Instructions from: ${homeFile}\nhome`,
|
||||
`Instructions from: ${remote}\nremote`,
|
||||
].join("\n\n"),
|
||||
)
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(first, "changed"))
|
||||
yield* emitAndWait({ type: "update", path: first })
|
||||
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
|
||||
`The instructions changed:\nInstructions from: ${first}\nchanged`,
|
||||
)
|
||||
// File rescans reuse fetched URLs instead of requesting them again.
|
||||
expect(requests).toHaveLength(2)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: path.join(tmp.path, "global"),
|
||||
home: tmp.path,
|
||||
instructions: [
|
||||
"rules/*.md",
|
||||
absolute,
|
||||
"~/home.md",
|
||||
remote,
|
||||
"missing.md",
|
||||
"https://example.test/missing.md",
|
||||
],
|
||||
http: respond((url) => {
|
||||
requests.push(url)
|
||||
return url === remote ? new Response("remote") : new Response(null, { status: 404 })
|
||||
}),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(project) },
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reloads configured sources on config updates and keeps the last fetched URL", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const first = path.join(tmp.path, "first.md")
|
||||
const second = path.join(tmp.path, "second.md")
|
||||
const remote = "https://example.test/instructions.md"
|
||||
const responses = [new Response("remote"), new Response(null, { status: 500 })]
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(first, "first")
|
||||
await fs.writeFile(second, "second")
|
||||
})
|
||||
const events = yield* PubSub.unbounded<ReturnType<typeof configUpdated>>()
|
||||
const config = yield* Config.Test
|
||||
const discovery = yield* start(host({ event: { subscribe: () => Stream.fromPubSub(events) } }))
|
||||
const initialized = yield* readInitial(yield* discovery.load())
|
||||
expect(initialized.text).toBe(
|
||||
[`Instructions from: ${first}\nfirst`, `Instructions from: ${remote}\nremote`].join("\n\n"),
|
||||
)
|
||||
|
||||
yield* config.setEntries([configured([second, remote])])
|
||||
yield* triggerAndWait(PubSub.publish(events, configUpdated()).pipe(Effect.asVoid))
|
||||
expect((yield* readInitial(yield* discovery.load())).text).toBe(
|
||||
[`Instructions from: ${second}\nsecond`, `Instructions from: ${remote}\nremote`].join("\n\n"),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: path.join(tmp.path, "global"),
|
||||
instructions: [first, remote],
|
||||
http: respond(() => responses.shift() ?? new Response(null, { status: 500 })),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps an empty AGENTS.md as available context", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -103,7 +103,7 @@ describe("LocationServiceMap", () => {
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.chmod(directory, 0o755)))
|
||||
}
|
||||
if (failure === "config reference") {
|
||||
yield* Effect.promise(() => fs.mkdir(directory))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "username.txt"), { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ username: "{file:username.txt}" })),
|
||||
)
|
||||
@@ -124,6 +124,7 @@ describe("LocationServiceMap", () => {
|
||||
}
|
||||
if (failure === "permissions") yield* Effect.promise(() => fs.chmod(directory, 0o755))
|
||||
if (failure === "config reference") {
|
||||
yield* Effect.promise(() => fs.rm(path.join(directory, "username.txt"), { recursive: true }))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "username.txt"), "test-user"))
|
||||
}
|
||||
expect((yield* Effect.promise(() => fs.stat(directory))).isDirectory()).toBe(true)
|
||||
@@ -147,7 +148,7 @@ describe("LocationServiceMap", () => {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
if (failure === "file") yield* Effect.promise(() => fs.writeFile(directory, "file"))
|
||||
if (failure === "config reference") {
|
||||
yield* Effect.promise(() => fs.mkdir(directory))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "username.txt"), { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ username: "{file:username.txt}" })),
|
||||
)
|
||||
@@ -165,6 +166,7 @@ describe("LocationServiceMap", () => {
|
||||
if (failure === "file") yield* Effect.promise(() => fs.rm(directory))
|
||||
if (failure !== "config reference") yield* Effect.promise(() => fs.mkdir(directory))
|
||||
if (failure === "config reference") {
|
||||
yield* Effect.promise(() => fs.rm(path.join(directory, "username.txt"), { recursive: true }))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "username.txt"), "test-user"))
|
||||
}
|
||||
const repaired = yield* locations.contextEffect(ref)
|
||||
|
||||
@@ -1767,6 +1767,8 @@ testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unus
|
||||
expect((yield* service.servers()).map((server) => server.name)).toEqual([Mcp.ServerName.make("resources")])
|
||||
expect(yield* service.tools()).toEqual([])
|
||||
}),
|
||||
// Spawns and connects the stdio server three times, which is slow on Windows CI.
|
||||
30_000,
|
||||
)
|
||||
|
||||
test("restores runtime MCP config when a transform is disposed", async () => {
|
||||
|
||||
@@ -218,56 +218,63 @@ describe("Npm.add", () => {
|
||||
|
||||
expect(await fs.stat(path.join(entries.added.directory, "package.json"))).toBeTruthy()
|
||||
expect(entries.cached).toEqual(entries.added)
|
||||
})
|
||||
}, 30_000)
|
||||
|
||||
test.each(["unnamed", "named"])("installs and locates %s Git packages with dependencies", async (kind) => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await createGitFixture(tmp.path)
|
||||
const spec =
|
||||
kind === "unnamed"
|
||||
? `git+file://${fixture.repository}#${fixture.commit}`
|
||||
: `fixture-named-plugin@git+file://${fixture.repository}#fixture-branch`
|
||||
// Real Git installs and updates exceed Bun's default timeout on Windows. Plain tests are used because
|
||||
// Windows CI has reported test.each cases timing out at the 5s default despite an explicit timeout.
|
||||
for (const kind of ["unnamed", "named"]) {
|
||||
test(`installs and locates ${kind} Git packages with dependencies`, async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await createGitFixture(tmp.path)
|
||||
const spec =
|
||||
kind === "unnamed"
|
||||
? `git+file://${fixture.repository}#${fixture.commit}`
|
||||
: `fixture-named-plugin@git+file://${fixture.repository}#fixture-branch`
|
||||
|
||||
const entries = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return {
|
||||
added: yield* npm.add(spec),
|
||||
cached: yield* npm.add(spec),
|
||||
resolved: yield* npm.resolve(spec),
|
||||
}
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
||||
const entries = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return {
|
||||
added: yield* npm.add(spec),
|
||||
cached: yield* npm.add(spec),
|
||||
resolved: yield* npm.resolve(spec),
|
||||
}
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
||||
|
||||
expect(entries.added.directory).toEndWith(path.join("node_modules", entries.added.name))
|
||||
expect(entries.added.version).toBe(fixture.commit)
|
||||
expect(entries.cached).toEqual(entries.added)
|
||||
expect(entries.resolved).toEqual(entries.added)
|
||||
expect(
|
||||
await fs.stat(path.join(path.dirname(entries.added.directory), "fixture-dependency", "package.json")),
|
||||
).toBeTruthy()
|
||||
expect(entries.added.directory).toContain(path.join("npm", await Npm.cacheKey(spec)))
|
||||
expect(entries.added.directory).toContain("node_modules")
|
||||
})
|
||||
expect(entries.added.directory).toEndWith(path.join("node_modules", entries.added.name))
|
||||
expect(entries.added.version).toBe(fixture.commit)
|
||||
expect(entries.cached).toEqual(entries.added)
|
||||
expect(entries.resolved).toEqual(entries.added)
|
||||
expect(
|
||||
await fs.stat(path.join(path.dirname(entries.added.directory), "fixture-dependency", "package.json")),
|
||||
).toBeTruthy()
|
||||
expect(entries.added.directory).toContain(path.join("npm", await Npm.cacheKey(spec)))
|
||||
expect(entries.added.directory).toContain("node_modules")
|
||||
}, 30_000)
|
||||
}
|
||||
|
||||
test.each(["commit", "branch"])("installs a Git package from an npm ::path: subdirectory at a %s", async (ref) => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await createGitFixture(tmp.path)
|
||||
const spec = `git+file://${fixture.repository}#${ref === "commit" ? fixture.commit : "fixture-branch"}::path:packages/subdirectory-plugin`
|
||||
const result = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return { added: yield* npm.add(spec), outdated: yield* npm.check(spec) }
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
||||
for (const ref of ["commit", "branch"]) {
|
||||
test(`installs a Git package from an npm ::path: subdirectory at a ${ref}`, async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await createGitFixture(tmp.path)
|
||||
const spec = `git+file://${fixture.repository}#${ref === "commit" ? fixture.commit : "fixture-branch"}::path:packages/subdirectory-plugin`
|
||||
const result = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return { added: yield* npm.add(spec), outdated: yield* npm.check(spec) }
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
||||
|
||||
expect(result.added.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
|
||||
expect(result.added.name).toBe("fixture-subdirectory-plugin")
|
||||
expect(result.added.version).toBe(fixture.commit)
|
||||
expect(await Bun.file(path.join(result.added.directory, "index.js")).text()).toContain("subdirectory: true")
|
||||
expect(
|
||||
await fs.stat(path.join(path.dirname(result.added.directory), "fixture-subdirectory-dependency", "package.json")),
|
||||
).toBeTruthy()
|
||||
expect(result.outdated).toBeFalse()
|
||||
})
|
||||
expect(result.added.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
|
||||
expect(result.added.name).toBe("fixture-subdirectory-plugin")
|
||||
expect(result.added.version).toBe(fixture.commit)
|
||||
expect(await Bun.file(path.join(result.added.directory, "index.js")).text()).toContain("subdirectory: true")
|
||||
expect(
|
||||
await fs.stat(
|
||||
path.join(path.dirname(result.added.directory), "fixture-subdirectory-dependency", "package.json"),
|
||||
),
|
||||
).toBeTruthy()
|
||||
expect(result.outdated).toBeFalse()
|
||||
}, 30_000)
|
||||
}
|
||||
|
||||
// Several real Git installs and updates exceed Bun's default timeout on Windows.
|
||||
test("checks and updates mutable Git packages without changing pinned installs", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await createGitFixture(tmp.path)
|
||||
@@ -311,29 +318,33 @@ describe("Npm.add", () => {
|
||||
}, 30_000)
|
||||
|
||||
// Symlink creation needs elevated privileges on Windows.
|
||||
test.skipIf(win)("records Git revisions when the cache directory is reached through a symlink", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await createGitFixture(tmp.path)
|
||||
await fs.mkdir(path.join(tmp.path, "cache"))
|
||||
await fs.symlink(path.join(tmp.path, "cache"), path.join(tmp.path, "link"))
|
||||
const mutable = `git+${pathToFileURL(fixture.repository).href}#fixture-branch`
|
||||
test.skipIf(win)(
|
||||
"records Git revisions when the cache directory is reached through a symlink",
|
||||
async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await createGitFixture(tmp.path)
|
||||
await fs.mkdir(path.join(tmp.path, "cache"))
|
||||
await fs.symlink(path.join(tmp.path, "cache"), path.join(tmp.path, "link"))
|
||||
const mutable = `git+${pathToFileURL(fixture.repository).href}#fixture-branch`
|
||||
|
||||
const result = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
const added = yield* npm.add(mutable)
|
||||
const current = yield* npm.check(mutable)
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
|
||||
await Bun.$`git -C ${fixture.repository} add .`
|
||||
await Bun.$`git -C ${fixture.repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm second`
|
||||
})
|
||||
return { added, current, outdated: yield* npm.check(mutable) }
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "link"))), Effect.runPromise)
|
||||
const result = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
const added = yield* npm.add(mutable)
|
||||
const current = yield* npm.check(mutable)
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
|
||||
await Bun.$`git -C ${fixture.repository} add .`
|
||||
await Bun.$`git -C ${fixture.repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm second`
|
||||
})
|
||||
return { added, current, outdated: yield* npm.check(mutable) }
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "link"))), Effect.runPromise)
|
||||
|
||||
expect(result.added.version).toBe(fixture.commit)
|
||||
expect(result.current).toBeFalse()
|
||||
expect(result.outdated).toBeTrue()
|
||||
}, 30_000)
|
||||
expect(result.added.version).toBe(fixture.commit)
|
||||
expect(result.current).toBeFalse()
|
||||
expect(result.outdated).toBeTrue()
|
||||
},
|
||||
30_000,
|
||||
)
|
||||
})
|
||||
|
||||
describe("Npm.resolve", () => {
|
||||
|
||||
@@ -215,7 +215,7 @@ describe("Session.move", () => {
|
||||
yield* Effect.promise(() => Promise.all([mkdir(source), mkdir(destination)]))
|
||||
if (broken)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(path.join(source, "opencode.json"), JSON.stringify({ instructions: ["{file:./missing.txt}"] })),
|
||||
Bun.write(path.join(source, "opencode.json"), JSON.stringify({ instructions: ["{file:.}"] })),
|
||||
)
|
||||
const session = yield* Session.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
@@ -271,7 +271,7 @@ describe("Session.move", () => {
|
||||
const source = Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "source")) })
|
||||
const destination = AbsolutePath.make(path.join(tmp.path, "destination"))
|
||||
yield* Effect.promise(() => Promise.all([mkdir(source.directory), mkdir(destination)]))
|
||||
const config = JSON.stringify({ instructions: ["{file:./missing.txt}"] })
|
||||
const config = JSON.stringify({ instructions: ["{file:.}"] })
|
||||
if (!broken) yield* Effect.promise(() => Bun.write(path.join(source.directory, "opencode.json"), config))
|
||||
const selectedID = Session.ID.create()
|
||||
const replacements: LayerNode.Replacements = [
|
||||
|
||||
@@ -324,8 +324,8 @@ describe("util.effect-flock", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0))
|
||||
expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
|
||||
expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0))
|
||||
|
||||
const lines = (await fs.readFile(done, "utf8"))
|
||||
.split("\n")
|
||||
|
||||
@@ -59,8 +59,8 @@ describe("util.flock", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0))
|
||||
expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
|
||||
expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0))
|
||||
|
||||
const lines = (await fs.readFile(done, "utf8"))
|
||||
.split("\n")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/desktop",
|
||||
"private": true,
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/gui-extensions",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"name": "@opencode/http-recorder",
|
||||
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"description": "OpenCode's desktop browser plugin",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin-figma",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"description": "Figma MCP integration and official Figma skills for OpenCode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"name": "@opencode/sdk",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -36,6 +36,17 @@ type Data = {
|
||||
}
|
||||
}
|
||||
|
||||
/** Names a finished turn by its Session and the user message that opened it. */
|
||||
export type TurnDiffInput = { sessionID: string; from: string }
|
||||
|
||||
/** The host's cache of the files each finished turn changed. */
|
||||
export type TurnDiffSource = {
|
||||
/** The turn's diffs, or undefined until they load. */
|
||||
get: (input: TurnDiffInput) => readonly FileDiffInfo[] | undefined
|
||||
/** Starts loading the turn's diffs unless they are cached or already loading. */
|
||||
load: (input: TurnDiffInput) => void
|
||||
}
|
||||
|
||||
export type NavigateToSessionFn = (sessionID: string) => void
|
||||
|
||||
export type SessionHrefFn = (sessionID: string) => string
|
||||
@@ -48,6 +59,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
sessionID?: string
|
||||
shellRunning?: (id: string) => boolean
|
||||
shellOutput?: (input: ShellOutputInput) => Promise<ShellOutputOutput>
|
||||
turnDiff?: TurnDiffSource
|
||||
onNavigateToSession?: NavigateToSessionFn
|
||||
onSessionHref?: SessionHrefFn
|
||||
}) => {
|
||||
@@ -65,6 +77,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
sessionHref: props.onSessionHref,
|
||||
shellRunning: props.shellRunning,
|
||||
shellOutput: props.shellOutput,
|
||||
turnDiff: props.turnDiff,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -62,6 +62,7 @@ export function CurrentSessionProviders(props: { document: SessionDocument; chil
|
||||
},
|
||||
session_diff: { [CURRENT_SESSION_ID]: props.document.diffs },
|
||||
}}
|
||||
turnDiff={{ get: () => props.document.diffs, load: () => {} }}
|
||||
>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</DataProvider>
|
||||
@@ -76,6 +77,7 @@ export function CurrentSessionTimelineStory(props: {
|
||||
width?: string
|
||||
shellToolDefaultOpen?: boolean
|
||||
editToolDefaultOpen?: boolean
|
||||
turnDiffs?: boolean
|
||||
}) {
|
||||
// Inline story documents rebuild their messages on every prop read, and the timeline projection keys text by identity.
|
||||
const document = createMemo(() => props.document)
|
||||
@@ -118,6 +120,7 @@ export function CurrentSessionTimelineStory(props: {
|
||||
actions={actions}
|
||||
shellToolDefaultOpen={props.shellToolDefaultOpen}
|
||||
editToolDefaultOpen={props.editToolDefaultOpen}
|
||||
turnDiffs={props.turnDiffs}
|
||||
/>
|
||||
</CurrentSessionProviders>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
writeFileDocument,
|
||||
} from "../storybook/current-session-fixtures"
|
||||
import { storyDocument, storyPatchFile, storyTool } from "../storybook/current-session-scenarios"
|
||||
import type { SessionDocument } from "../document"
|
||||
import { SessionTimeline } from "./session-timeline"
|
||||
import { ToolDisplay } from "../tools/tool-renderer"
|
||||
|
||||
@@ -198,6 +199,76 @@ const WrittenSource = {
|
||||
),
|
||||
}
|
||||
|
||||
// A finished turn: the step records which files changed, and the idle marker ends the turn.
|
||||
function finishedTurnDocument(files: { file: string; before: string; after: string }[]): SessionDocument {
|
||||
const document = storyDocument([
|
||||
storyTool("tool_turn_shell", "shell", "completed", { command: "bun run format" }, { output: "Formatted" }),
|
||||
])
|
||||
|
||||
const step = document.messages.at(-1)
|
||||
|
||||
if (step?.type !== "assistant") throw new Error("Expected the story document to end with a step")
|
||||
|
||||
return {
|
||||
...document,
|
||||
messages: [
|
||||
...document.messages.slice(0, -1),
|
||||
{ ...step, snapshot: { files: files.map((item) => item.file) } },
|
||||
{ id: "msg_turn_idle", type: "idle", outcome: "succeeded", time: { created: step.time.created + 400 } },
|
||||
],
|
||||
diffs: files.map((item) => {
|
||||
const lines = (text: string) => (text ? text.split("\n").length - 1 : 0)
|
||||
const changes = createTwoFilesPatch(item.file, item.file, item.before, item.after)
|
||||
|
||||
return {
|
||||
file: item.file,
|
||||
patch: changes,
|
||||
additions: item.before ? (item.after ? 1 : 0) : lines(item.after),
|
||||
deletions: item.after ? (item.before ? 1 : 0) : lines(item.before),
|
||||
status: !item.before ? "added" : !item.after ? "deleted" : "modified",
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export const TurnChangeSummary = {
|
||||
render: () => (
|
||||
<CurrentSessionTimelineStory
|
||||
title="Turn change summary"
|
||||
description="Once the turn ends, every file it changed is summarized below it, including changes made by shell commands."
|
||||
document={finishedTurnDocument([
|
||||
{
|
||||
file: "packages/app/src/session/timeline/message-timeline.tsx",
|
||||
before: "export const before = true\n",
|
||||
after: "export const after = true\n",
|
||||
},
|
||||
{ file: "packages/app/src/session/turn-summary.ts", before: "", after: "export const summary = []\n" },
|
||||
{ file: "packages/app/src/session/legacy-summary.ts", before: "export const legacy = []\n", after: "" },
|
||||
])}
|
||||
width="860px"
|
||||
turnDiffs
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const TurnChangeSummaryOverflow = {
|
||||
render: () => (
|
||||
<CurrentSessionTimelineStory
|
||||
title="Turn change summary with many files"
|
||||
description="Past five files, the rest of the turn's changes wait behind Show all."
|
||||
document={finishedTurnDocument(
|
||||
Array.from({ length: 14 }, (_, index) => ({
|
||||
file: `src/modules/module-${index + 1}.ts`,
|
||||
before: `export const value = ${index}\n`,
|
||||
after: `export const value = ${index + 1}\n`,
|
||||
})),
|
||||
)}
|
||||
width="860px"
|
||||
turnDiffs
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
const fileScenarios = {
|
||||
repeated: RepeatedEdits,
|
||||
streaming: EditWithStreamedSibling,
|
||||
@@ -256,6 +327,7 @@ export const AppendingToolCalls = {
|
||||
export const ChangingFiles = {
|
||||
args: { scenario: "streaming" },
|
||||
argTypes: { scenario: { control: "select", options: Object.keys(fileScenarios) } },
|
||||
// SAFETY: the scenario control only offers fileScenarios keys.
|
||||
render: (args: { scenario: string }) => fileScenarios[args.scenario as keyof typeof fileScenarios].render(),
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ type Entry =
|
||||
| { type: "assistant"; message: SessionMessageAssistant }
|
||||
| { type: "notice"; message: Notice }
|
||||
| { type: "idle"; message: FailedIdle }
|
||||
| { type: "diff"; message: SessionMessageIdle; from: string; files: string[] }
|
||||
|
||||
type Content = SessionMessageAssistant["content"][number]
|
||||
|
||||
@@ -79,6 +80,7 @@ export type TimelineProjectionInput = {
|
||||
timelineDetail?: TimelineDetail
|
||||
pendingInputIDs?: ReadonlySet<string>
|
||||
queuedCompactionIDs?: readonly string[]
|
||||
turnDiffs?: boolean
|
||||
previousRows?: TimelineRow.TimelineRow[]
|
||||
}
|
||||
|
||||
@@ -95,6 +97,7 @@ export function createTimelineProjection(input: TimelineProjectionInput) {
|
||||
undefined,
|
||||
input.timelineDetail,
|
||||
input.queuedCompactionIDs,
|
||||
input.turnDiffs,
|
||||
)
|
||||
|
||||
const rows = reuseTimelineRows(input.previousRows, projection.rows)
|
||||
@@ -133,6 +136,8 @@ export function createReactiveTimelineProjection(input: {
|
||||
timelineDetail?: Accessor<TimelineDetail>
|
||||
pendingInputIDs?: Accessor<ReadonlySet<string>>
|
||||
queuedCompactionIDs?: Accessor<readonly string[]>
|
||||
/** Summarize each finished turn's changed files after it. */
|
||||
turnDiffs?: Accessor<boolean>
|
||||
}) {
|
||||
const sessionMessageByID = createMemo(
|
||||
() => new Map(input.sessionMessages().map((message) => [message.id, message] as const)),
|
||||
@@ -170,6 +175,7 @@ export function createReactiveTimelineProjection(input: {
|
||||
textVisible().get(content)!(),
|
||||
input.timelineDetail?.(),
|
||||
input.queuedCompactionIDs?.(),
|
||||
input.turnDiffs?.(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -232,6 +238,7 @@ export namespace Timeline {
|
||||
isRenderable = renderable,
|
||||
detail?: TimelineDetail,
|
||||
queuedCompactionIDs: readonly string[] = [],
|
||||
turnDiffs = false,
|
||||
) {
|
||||
type Turn = {
|
||||
id: string
|
||||
@@ -245,6 +252,8 @@ export namespace Timeline {
|
||||
const turnByUserID = new Map<string, Turn>()
|
||||
const leading: Notice[] = []
|
||||
let current: Turn | undefined
|
||||
// Everything since the last idle marker is one turn, including prompts steered in meanwhile.
|
||||
let execution: { opener: string; turn: Turn; files: Set<string> } | undefined
|
||||
|
||||
messages.forEach((message) => {
|
||||
if (isNotice(message)) {
|
||||
@@ -270,38 +279,32 @@ export namespace Timeline {
|
||||
turnByUserID.set(message.id, turn)
|
||||
current = turn
|
||||
|
||||
// Undelivered input has not joined the turn yet.
|
||||
if (!pendingInputIDs?.has(message.id))
|
||||
execution = { opener: execution?.opener ?? message.id, turn, files: execution?.files ?? new Set() }
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (isFailedIdle(message)) {
|
||||
const last = current?.entries.at(-1)
|
||||
if (message.type === "idle") {
|
||||
appendIdle(message)
|
||||
|
||||
if (
|
||||
last?.type === "notice" &&
|
||||
last.message.type === "compaction" &&
|
||||
last.message.status === "failed" &&
|
||||
!isInterrupted(last.message.error)
|
||||
)
|
||||
return
|
||||
|
||||
const lastStep = current?.entries.findLast((entry) => entry.type === "assistant" || entry.type === "idle")
|
||||
|
||||
if (lastStep?.type === "assistant" && lastStep.message.error && !isInterrupted(lastStep.message.error)) return
|
||||
|
||||
if (current && !current.shell) {
|
||||
current.entries.push({ type: "idle", message })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const turn: Turn = { id: message.id, time: message.time, entries: [{ type: "idle", message }] }
|
||||
turns.push(turn)
|
||||
current = turn
|
||||
// The summary follows the turn's last prompt, after any failure the marker reports.
|
||||
// Sorted like the diff route's git order, so the list keeps its order when the diffs arrive.
|
||||
if (turnDiffs && execution?.files.size)
|
||||
execution.turn.entries.push({
|
||||
type: "diff",
|
||||
message,
|
||||
from: execution.opener,
|
||||
files: [...execution.files].sort(),
|
||||
})
|
||||
execution = undefined
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type !== "assistant") return
|
||||
message.snapshot?.files?.forEach((file) => execution?.files.add(file))
|
||||
const existing = current?.user ? current : undefined
|
||||
|
||||
if (existing?.user) {
|
||||
@@ -322,6 +325,33 @@ export namespace Timeline {
|
||||
current = turn
|
||||
})
|
||||
|
||||
function appendIdle(message: SessionMessageIdle) {
|
||||
if (!isFailedIdle(message)) return
|
||||
const last = current?.entries.at(-1)
|
||||
|
||||
if (
|
||||
last?.type === "notice" &&
|
||||
last.message.type === "compaction" &&
|
||||
last.message.status === "failed" &&
|
||||
!isInterrupted(last.message.error)
|
||||
)
|
||||
return
|
||||
|
||||
const lastStep = current?.entries.findLast((entry) => entry.type === "assistant" || entry.type === "idle")
|
||||
|
||||
if (lastStep?.type === "assistant" && lastStep.message.error && !isInterrupted(lastStep.message.error)) return
|
||||
|
||||
if (current && !current.shell) {
|
||||
current.entries.push({ type: "idle", message })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const turn: Turn = { id: message.id, time: message.time, entries: [{ type: "idle", message }] }
|
||||
turns.push(turn)
|
||||
current = turn
|
||||
}
|
||||
|
||||
const activeMessageID =
|
||||
turns.findLast((turn) => !pendingInputIDs?.has(turn.id) || turn.entries.some((entry) => entry.type === "idle"))
|
||||
?.id ?? turns.at(-1)?.id
|
||||
@@ -339,6 +369,7 @@ export namespace Timeline {
|
||||
entry.type === "notice"
|
||||
? visibleNotice(entry.message)
|
||||
: entry.type === "idle" ||
|
||||
entry.type === "diff" ||
|
||||
!!entry.message.error ||
|
||||
!!entry.message.retry ||
|
||||
entry.message.content.some((content) => isRenderable(content, showReasoning, detail)),
|
||||
@@ -556,6 +587,19 @@ export namespace Timeline {
|
||||
}),
|
||||
)
|
||||
|
||||
return
|
||||
case "diff":
|
||||
appendAssistantSegment(assistantSegment)
|
||||
assistantSegment = []
|
||||
rows.push(
|
||||
new TimelineRow.TurnDiff({
|
||||
userMessageID: turnID,
|
||||
messageID: entry.message.id,
|
||||
from: entry.from,
|
||||
files: entry.files,
|
||||
}),
|
||||
)
|
||||
|
||||
return
|
||||
case "notice":
|
||||
if (detail?.notices.placement === "hidden" && !timelineNoticeRequired(entry.message)) return
|
||||
|
||||
@@ -1452,4 +1452,67 @@ describe("current session timeline rows", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("summarizes a turn's changed files after the idle marker that ends it", () => {
|
||||
const step = (id: string, files?: string[]) =>
|
||||
({
|
||||
id,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text", text: id }],
|
||||
snapshot: files && { start: "tree_a", end: "tree_b", files },
|
||||
time: { created: 1, completed: 2 },
|
||||
}) satisfies SessionMessageAssistant
|
||||
|
||||
const idle = (id: string) => ({ id, type: "idle", outcome: "succeeded", time: { created: 1 } }) as const
|
||||
const user = (id: string) => ({ id, type: "user", text: id, time: { created: 1 } }) as const
|
||||
|
||||
const source = [
|
||||
// A steered prompt joins the running turn; the summary diffs from its opening prompt and lists
|
||||
// every path its steps recorded once, sorted.
|
||||
user("msg_open"),
|
||||
step("msg_edit", ["src/a.ts"]),
|
||||
user("msg_steer"),
|
||||
step("msg_answer", ["src/c.ts", "src/a.ts"]),
|
||||
idle("msg_idle_1"),
|
||||
// A turn that changed nothing gets no summary.
|
||||
user("msg_ask"),
|
||||
step("msg_reply", []),
|
||||
idle("msg_idle_2"),
|
||||
// Neither does a turn still running.
|
||||
user("msg_next"),
|
||||
step("msg_running", ["src/b.ts"]),
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
const rows = createTimelineProjection({
|
||||
sessionMessages: source,
|
||||
status: { type: "busy" },
|
||||
reasoningMode: "compact",
|
||||
turnDiffs: true,
|
||||
}).rows
|
||||
|
||||
expect(rows.filter((row) => Predicate.isTagged(row, "TurnDiff"))).toEqual([
|
||||
new TimelineRow.TurnDiff({
|
||||
userMessageID: "msg_steer",
|
||||
messageID: "msg_idle_1",
|
||||
from: "msg_open",
|
||||
files: ["src/a.ts", "src/c.ts"],
|
||||
}),
|
||||
])
|
||||
expect(rows.map(TimelineRow.key).slice(0, 6)).toEqual([
|
||||
"user-message:msg_open",
|
||||
"assistant-part:part:part:msg_edit:msg_edit:text:0",
|
||||
"turn-gap:msg_steer",
|
||||
"user-message:msg_steer",
|
||||
"assistant-part:part:part:msg_answer:msg_answer:text:0",
|
||||
"turn-diff:msg_idle_1",
|
||||
])
|
||||
// Hosts opt in, so a timeline without the setting lists no summaries.
|
||||
expect(
|
||||
Timeline.constructSessionMessageRows(source, true, { type: "busy" }).rows.some((row) =>
|
||||
Predicate.isTagged(row, "TurnDiff"),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,8 @@ import type {
|
||||
} from "@opencode/client/promise"
|
||||
import { useI18n } from "@opencode/ui/context/i18n"
|
||||
import { Option, Predicate, Schema } from "effect"
|
||||
import { For, Show, createMemo, type Accessor, type JSX } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, type Accessor, type JSX } from "solid-js"
|
||||
import { DiffChanges } from "@opencode/ui/diff-changes"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { SessionUserActions, SessionUserAttachmentReference, SessionUserComment } from "../actions"
|
||||
import { useData } from "../context"
|
||||
@@ -25,7 +26,8 @@ import {
|
||||
SessionCompactionMessage,
|
||||
SessionCompactionQueued,
|
||||
} from "../message/message-content"
|
||||
import type { ContextGroupPart } from "../tools/tool-renderer"
|
||||
import { ToolRegistry, type ContextGroupPart } from "../tools/tool-renderer"
|
||||
import { GenericTool } from "../components/basic-tool"
|
||||
import { SessionRetry } from "../components/session-retry"
|
||||
import { SessionError } from "../components/session-error"
|
||||
import { timelineCategory, type TimelineDetail } from "./detail"
|
||||
@@ -41,6 +43,9 @@ import {
|
||||
|
||||
const emptyAssistantMessages: SessionMessageAssistant[] = []
|
||||
|
||||
// Past this many files, the rest of a turn's changes wait behind "Show all".
|
||||
const turnDiffFileLimit = 5
|
||||
|
||||
const decodeMetadataFile = Schema.decodeUnknownOption(Schema.Struct({ file: Schema.String }))
|
||||
|
||||
type Projection = ReturnType<typeof createReactiveTimelineProjection>
|
||||
@@ -704,6 +709,56 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
)
|
||||
}
|
||||
|
||||
function TurnDiff(props: { row: TimelineRow.TurnDiff; onSizeChange?: () => void }) {
|
||||
const render = ToolRegistry.render("patch") ?? GenericTool
|
||||
// Until the diff loads, list the paths the turn's steps recorded, without change counts.
|
||||
const turn = () => ({ sessionID: input.sessionID(), from: props.row.from })
|
||||
const diffs = () => data.turnDiff?.get(turn())
|
||||
const paths = () => diffs()?.map((diff) => diff.file) ?? props.row.files
|
||||
const showAllKey = () => `turn-diff:${props.row.messageID}`
|
||||
const showAll = () => input.disclosure.value(showAllKey()) === true
|
||||
const limit = <T,>(items: readonly T[]) => (showAll() ? items : items.slice(0, turnDiffFileLimit))
|
||||
const overflow = () => paths().length > turnDiffFileLimit
|
||||
const fileKey = (path: string) => `turn-diff:${props.row.messageID}:file:${path}`
|
||||
|
||||
createEffect(() => data.turnDiff?.load(turn()))
|
||||
|
||||
return (
|
||||
<Show when={data.turnDiff && paths().length > 0}>
|
||||
<div data-slot="session-turn-diffs" class="flex flex-col gap-2 pt-4">
|
||||
<div class="flex h-6 min-w-0 items-center gap-2">
|
||||
<span class="text-14-medium text-text-strong tabular-nums">
|
||||
{i18n.plural("ui.sessionTurn.diffs.changed", paths().length)}
|
||||
</span>
|
||||
<DiffChanges changes={[...(diffs() ?? [])]} />
|
||||
<Show when={overflow()}>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto text-14-regular text-text-interactive-base"
|
||||
onClick={() => input.disclosure.set(showAllKey(), !showAll())}
|
||||
>
|
||||
{showAll() ? i18n.t("ui.sessionTurn.diffs.showLess") : i18n.t("ui.sessionTurn.diffs.showAll")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<Dynamic
|
||||
component={render}
|
||||
tool="patch"
|
||||
input={{}}
|
||||
metadata={{ files: limit(diffs() ?? []) }}
|
||||
pendingFiles={diffs() ? undefined : limit(props.row.files)}
|
||||
status="completed"
|
||||
fileOpen={(path: string) => input.disclosure.value(fileKey(path))}
|
||||
onFileOpenChange={(path: string, open: boolean) => input.disclosure.set(fileKey(path), open)}
|
||||
deferContent
|
||||
virtualizeDiff={false}
|
||||
onContentRendered={props.onSizeChange}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const render = (row: Accessor<TimelineRow.TimelineRow>, onSizeChange?: () => void) => {
|
||||
if (Predicate.isTagged(row(), "TurnGap")) return <div data-timeline-row="TurnGap" aria-hidden="true" class="h-6" />
|
||||
|
||||
@@ -898,6 +953,24 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
)
|
||||
}
|
||||
|
||||
if (Predicate.isTagged(row(), "TurnDiff")) {
|
||||
const current = () => {
|
||||
const value = row()
|
||||
|
||||
if (!Predicate.isTagged(value, "TurnDiff")) throw new Error("Expected a turn-diff timeline row")
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame row={current()}>
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${padding()}`}>
|
||||
<TurnDiff row={current()} onSizeChange={onSizeChange} />
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
if (Predicate.isTagged(row(), "Retry")) {
|
||||
const current = () => {
|
||||
const value = row()
|
||||
|
||||
@@ -16,6 +16,7 @@ export type SessionTimelineProps = {
|
||||
shellToolDefaultOpen?: boolean
|
||||
editToolDefaultOpen?: boolean
|
||||
timelineDetail?: TimelineDetail
|
||||
turnDiffs?: boolean
|
||||
class?: string
|
||||
}
|
||||
|
||||
@@ -27,6 +28,7 @@ export function SessionTimeline(props: SessionTimelineProps) {
|
||||
shellToolDefaultOpen: () => props.shellToolDefaultOpen ?? false,
|
||||
editToolDefaultOpen: () => props.editToolDefaultOpen ?? false,
|
||||
timelineDetail: props.timelineDetail && (() => props.timelineDetail!),
|
||||
turnDiffs: () => props.turnDiffs ?? false,
|
||||
})
|
||||
|
||||
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>({})
|
||||
|
||||
@@ -72,6 +72,19 @@ export namespace TimelineRow {
|
||||
inboxID: string
|
||||
}> {}
|
||||
|
||||
/**
|
||||
* The files a finished turn changed, shown once the Session goes idle. `messageID` is the
|
||||
* idle marker that ended the turn; `from` is the prompt that opened it, which names the turn
|
||||
* to the session diff route even when later prompts were steered in. `files` are the paths its
|
||||
* steps recorded, listed before the diff loads; the diff can omit a file a later step reverted.
|
||||
*/
|
||||
export class TurnDiff extends Data.TaggedClass("TurnDiff")<{
|
||||
userMessageID: string
|
||||
messageID: string
|
||||
from: string
|
||||
files: readonly string[]
|
||||
}> {}
|
||||
|
||||
/** Where the host shows the active turn's live status when undelivered input waits below it. */
|
||||
export class Activity extends Data.TaggedClass("Activity")<{
|
||||
userMessageID: string
|
||||
@@ -88,6 +101,7 @@ export namespace TimelineRow {
|
||||
| Error
|
||||
| Retry
|
||||
| CompactionQueued
|
||||
| TurnDiff
|
||||
| Activity
|
||||
|
||||
export const key: (row: TimelineRow) => string = Match.typeTags<TimelineRow>()({
|
||||
@@ -104,6 +118,7 @@ export namespace TimelineRow {
|
||||
Error: (row) => `error:${row.userMessageID}`,
|
||||
Retry: (row) => `retry:${row.userMessageID}`,
|
||||
CompactionQueued: (row) => `compaction-queued:${row.inboxID}`,
|
||||
TurnDiff: (row) => `turn-diff:${row.messageID}`,
|
||||
// One live status exists at a time; it keeps its identity when the active turn changes.
|
||||
Activity: () => "activity",
|
||||
})
|
||||
@@ -129,5 +144,6 @@ export type TimelineRowMap = {
|
||||
Retry: { userMessageID: string }
|
||||
Error: { userMessageID: string; text: string }
|
||||
CompactionQueued: { userMessageID: string; inboxID: string }
|
||||
TurnDiff: { userMessageID: string; messageID: string; from: string; files: readonly string[] }
|
||||
Activity: { userMessageID: string }
|
||||
}
|
||||
@@ -1132,6 +1132,8 @@ export interface ToolProps {
|
||||
deferContent?: boolean
|
||||
virtualizeDiff?: boolean
|
||||
onContentRendered?: () => void
|
||||
/** Paths the patch view lists, without change counts or diffs, instead of `metadata.files`. */
|
||||
pendingFiles?: readonly string[]
|
||||
forceOpen?: boolean
|
||||
locked?: boolean
|
||||
}
|
||||
@@ -2212,7 +2214,18 @@ ToolRegistry.register({
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const fileComponent = useFileComponent()
|
||||
const files = createMemo(() => patchFileGroups(props.metadata.files))
|
||||
|
||||
const files = createMemo(
|
||||
() =>
|
||||
props.pendingFiles?.map((path) => ({
|
||||
path,
|
||||
type: "update" as const,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
views: [],
|
||||
})) ?? patchFileGroups(props.metadata.files),
|
||||
)
|
||||
|
||||
const [expanded, setExpanded] = createSignal<string[]>([])
|
||||
|
||||
const title = createMemo(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.28",
|
||||
"version": "2.0.29",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user