Compare commits

...
Author SHA1 Message Date
Kit Langton c9d35f29c3 fix(client): hydrate nested subagents in family sync 2026-08-20 17:08:11 -04:00
Kit Langton 6d4a1be23e feat(session): add viewed state 2026-08-20 16:58:08 -04:00
Dax cc15c2a488 feat(posts): add Astro posts site (#43720) 2026-08-20 16:37:28 -04:00
Kit Langton ebc2504ef3 refactor(core): route title and compaction through shared model requests (#43555) 2026-08-20 16:46:57 +00:00
Aiden Cline c33c9bf2b9 fix(core): preserve background shell status (#43663) 2026-08-20 11:01:15 -05:00
Kit Langton 2970b7a6a8 fix(core): settle foreign typed tool failures instead of dropping them (#43576) 2026-08-20 11:56:35 -04:00
Kit Langton 82d2c6133e fix(tui): stop registering one resize listener per transcript row (#43562) 2026-08-20 11:55:37 -04:00
Dax Raad 22f2604ffa fix(core): preserve unicode in compaction truncation 2026-08-20 11:52:42 -04:00
Shoubhit Dash a71884dfdf test(ai): record responses websocket flows (#43660) 2026-08-20 21:14:37 +05:30
Kit Langton 6f629c2a9d refactor(core): use Latch for shell output gate (#43573) 2026-08-20 11:38:01 -04:00
Kit Langton 4651bd15de refactor(server): use Latch for shutdown gate (#43571) 2026-08-20 11:37:35 -04:00
Kit Langton ad7ebe84a0 refactor(tui): use Latch for renderer shutdown gate (#43572) 2026-08-20 11:37:22 -04:00
Kit Langton 38eeed56cd refactor(core): use Latch for MCP startup gate (#43570) 2026-08-20 11:37:19 -04:00
Kit Langton b453c2016c refactor(core): use Latch for plugin supervisor ready gate (#43569) 2026-08-20 11:37:14 -04:00
77 changed files with 3767 additions and 984 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Title generation and compaction summaries now build their model requests through the shared session request boundary, gaining unsupported-media filtering and image bounds while explicitly opting out of session context hooks: plugins that shape the agent conversation do not observe title or compaction requests. Title requests gain the fork-aware session prompt cache key, and compaction summaries in forked sessions reuse the fork root's prompt cache key instead of the fork's own.
+37
View File
@@ -0,0 +1,37 @@
name: deploy-posts
on:
push:
branches:
- v2
paths:
- packages/posts/**
- bun.lock
- .github/workflows/deploy-posts.yml
workflow_dispatch:
concurrency:
group: deploy-posts-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Build
working-directory: packages/posts
run: bun run build
- name: Deploy
working-directory: packages/posts
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1096 -556
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,100 @@
{
"version": 1,
"metadata": {
"provider": "openai",
"protocol": "openai-responses",
"transport": "websocket",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses-websocket",
"provider:openai",
"protocol:openai-responses",
"transport:websocket",
"tool",
"continuation"
],
"name": "openai-responses-websocket/continues-a-tool-call-over-one-socket",
"recordedAt": "2026-08-20T00:00:00.000Z"
},
"interactions": [
{
"transport": "websocket",
"connection": {
"sequence": 0,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call get_weather once, then reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_tool_1\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"id\":\"fc_ws_weather\",\"call_id\":\"call_ws_weather\",\"name\":\"get_weather\",\"arguments\":\"\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_ws_weather\",\"delta\":\"{\\\"city\\\":\\\"Paris\\\"}\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",\"id\":\"fc_ws_weather\",\"call_id\":\"call_ws_weather\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_tool_1\"}}"
},
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_ws_weather\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"previous_response_id\":\"resp_ws_tool_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_tool_2\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_tool_2\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_tool_2\",\"delta\":\"Paris is sunny.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_tool_2\",\"text\":\"Paris is sunny.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_tool_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Paris is sunny.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_tool_2\"}}"
}
]
}
]
}
@@ -0,0 +1,119 @@
{
"version": 1,
"metadata": {
"provider": "openai",
"protocol": "openai-responses",
"transport": "websocket",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses-websocket",
"provider:openai",
"protocol:openai-responses",
"transport:websocket",
"reconnect",
"full-context"
],
"name": "openai-responses-websocket/reconstructs-full-context-after-reconnect",
"recordedAt": "2026-08-20T00:00:00.000Z"
},
"interactions": [
{
"transport": "websocket",
"connection": {
"sequence": 0,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_reconnect_1\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_reconnect_1\",\"delta\":\"Alpha.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_reconnect_1\",\"text\":\"Alpha.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_reconnect_1\"}}"
}
]
},
{
"transport": "websocket",
"connection": {
"sequence": 1,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_reconnect_2\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_2\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_reconnect_2\",\"delta\":\"Beta.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_reconnect_2\",\"text\":\"Beta.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Beta.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_reconnect_2\"}}"
}
]
}
]
}
@@ -0,0 +1,129 @@
{
"version": 1,
"metadata": {
"provider": "openai",
"protocol": "openai-responses",
"transport": "websocket",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses-websocket",
"provider:openai",
"protocol:openai-responses",
"transport:websocket",
"continuation",
"recovery"
],
"name": "openai-responses-websocket/recovers-from-explicit-continuation-rejection",
"recordedAt": "2026-08-20T00:00:00.000Z"
},
"interactions": [
{
"transport": "websocket",
"connection": {
"sequence": 0,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_rejection_1\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_rejection_1\",\"delta\":\"Ready.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_rejection_1\",\"text\":\"Ready.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_rejection_1\"}}"
}
]
},
{
"transport": "websocket",
"connection": {
"sequence": 1,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"previous_response_id\":\"resp_ws_rejection_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"error\",\"error\":{\"code\":\"previous_response_not_found\",\"message\":\"Previous response not found\"}}"
},
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_rejection_2\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_2\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_rejection_2\",\"delta\":\"Recovered.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_rejection_2\",\"text\":\"Recovered.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Recovered.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_rejection_2\"}}"
}
]
}
]
}
@@ -0,0 +1,216 @@
import { describe, expect } from "bun:test"
import { Effect, Stream } from "effect"
import { Socket } from "effect/unstable/socket"
import { LLM, LLMRequest, Message, ToolRuntime } from "../../src/index.js"
import {
LLMClient,
WebSocketTransport,
type ChannelCheckpoint,
type ChannelObservation,
type WebSocketChannelExchange,
type WebSocketChannelExecutor,
type WebSocketConnection,
} from "../../src/route.js"
import { configure } from "../../src/providers/openai.js"
import { decodeJson } from "../../src/protocols/shared.js"
import { weatherRuntimeTool, weatherTool, weatherToolName } from "../recorded-scenarios.js"
import { recordedTests } from "../recorded-test.js"
const model = configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" }).responses("gpt-5.5")
const recorded = recordedTests({
prefix: "openai-responses-websocket",
provider: "openai",
protocol: "openai-responses",
requires: ["OPENAI_API_KEY"],
tags: ["transport:websocket"],
metadata: { transport: "websocket", model: model.id },
})
const observationFrame = (observation: ChannelObservation) => {
if (observation.type === "frame" || observation.type === "completed" || observation.type === "incomplete")
return Effect.succeed(observation.frame)
return Effect.fail(observation.error)
}
const terminal = (observation: ChannelObservation) => observation.type !== "frame"
// This deliberately models only sequential test traffic. Core owns production connection pooling and recovery.
const makeChannel = Effect.gen(function* () {
const constructor = yield* Socket.WebSocketConstructor
let connection: WebSocketConnection | undefined
let checkpoint: ChannelCheckpoint | undefined
let pending: ChannelCheckpoint | undefined
let opens = 0
const sent: unknown[] = []
const close = Effect.suspend(() => {
const current = connection
connection = undefined
return current ? current.close : Effect.void
})
yield* Effect.addFinalizer(() => close)
const executor: WebSocketChannelExecutor = {
execute: (exchange: WebSocketChannelExchange) =>
Effect.gen(function* () {
if (!connection) {
connection = yield* WebSocketTransport.open(exchange.connect).pipe(
Effect.provideService(Socket.WebSocketConstructor, constructor),
)
opens += 1
}
const current = connection
const create = yield* exchange.driver.create(checkpoint)
if (create.mode === "full") checkpoint = undefined
pending = undefined
sent.push(decodeJson(create.message))
yield* current.sendText(create.message)
const decoder = new TextDecoder()
return {
frames: current.messages.pipe(
Stream.map((message) => WebSocketTransport.messageText(message, decoder)),
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
Stream.tap((observation) =>
Effect.sync(() => {
if (!terminal(observation)) return
pending = observation.type === "completed" ? observation.checkpoint : undefined
if (observation.type !== "completed") checkpoint = undefined
}),
),
Stream.takeUntil(terminal),
Stream.mapEffect(observationFrame),
),
complete: Effect.sync(() => {
checkpoint = pending
pending = undefined
}),
}
}),
}
return {
executor,
sent,
opens: () => opens,
reconnect: (preserveCheckpoint = false) =>
close.pipe(
Effect.andThen(
Effect.sync(() => {
pending = undefined
if (!preserveCheckpoint) checkpoint = undefined
}),
),
),
}
})
describe("OpenAI Responses WebSocket recorded", () => {
recorded.effect.with("continues a tool call over one socket", { tags: ["tool", "continuation"] }, () =>
Effect.gen(function* () {
const channel = yield* makeChannel
const request = LLM.request({
id: "recorded_openai_responses_websocket_tool",
model,
system: "Call get_weather once, then reply exactly: Paris is sunny.",
prompt: "What is the weather in Paris?",
tools: [weatherTool],
generation: { maxTokens: 50 },
cache: "none",
})
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
const call = first.toolCalls[0]
if (!call) yield* Effect.die("Expected get_weather tool call")
const result = yield* ToolRuntime.dispatch({ [weatherToolName]: weatherRuntimeTool }, call)
const second = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [
...request.messages,
first.message,
Message.tool({ id: call.id, name: call.name, result: result.result }),
],
}),
{ webSocket: channel.executor },
)
expect(second.text).toBe("Paris is sunny.")
expect(channel.opens()).toBe(1)
expect(channel.sent).toHaveLength(2)
expect(channel.sent[1]).toMatchObject({
previous_response_id: expect.any(String),
input: [{ type: "function_call_output", call_id: call.id, output: expect.any(String) }],
})
}),
)
recorded.effect.with("reconstructs full context after reconnect", { tags: ["reconnect", "full-context"] }, () =>
Effect.gen(function* () {
const channel = yield* makeChannel
const request = LLM.request({
id: "recorded_openai_responses_websocket_reconnect",
model,
system: "Follow the user's exact reply instruction.",
prompt: "Reply exactly: Alpha.",
generation: { maxTokens: 30 },
cache: "none",
})
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
yield* channel.reconnect()
const second = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [...request.messages, first.message, Message.user("Reply exactly: Beta.")],
}),
{ webSocket: channel.executor },
)
expect(first.text).toBe("Alpha.")
expect(second.text).toBe("Beta.")
expect(channel.opens()).toBe(2)
expect(channel.sent[1]).not.toHaveProperty("previous_response_id")
expect(channel.sent[1]).toMatchObject({
input: [
{ role: "system", content: "Follow the user's exact reply instruction." },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Alpha." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Alpha." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Beta." }] },
],
})
}),
)
recorded.effect.with("recovers from explicit continuation rejection", { tags: ["continuation", "recovery"] }, () =>
Effect.gen(function* () {
const channel = yield* makeChannel
const request = LLM.request({
id: "recorded_openai_responses_websocket_rejection",
model,
system: "Follow the user's exact reply instruction.",
prompt: "Reply exactly: Ready.",
generation: { maxTokens: 30 },
cache: "none",
})
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
const continuation = LLMRequest.update(request, {
messages: [...request.messages, first.message, Message.user("Reply exactly: Recovered.")],
})
yield* channel.reconnect(true)
const rejected = yield* LLMClient.generate(continuation, { webSocket: channel.executor }).pipe(Effect.flip)
const recovered = yield* LLMClient.generate(continuation, { webSocket: channel.executor })
expect(rejected).toMatchObject({
reason: { _tag: "Transport", delivery: "rejected", recovery: "retry-full" },
})
expect(recovered.text).toBe("Recovered.")
expect(channel.opens()).toBe(2)
expect(channel.sent[1]).toHaveProperty("previous_response_id", expect.any(String))
expect(channel.sent[2]).not.toHaveProperty("previous_response_id")
expect(channel.sent[2]).toMatchObject({
input: [
{ role: "system", content: "Follow the user's exact reply instruction." },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Ready." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Ready." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Recovered." }] },
],
})
}),
)
})
+10 -2
View File
@@ -1,5 +1,7 @@
import { HttpRecorder } from "@opencode-ai/http-recorder"
import { NodeSocket } from "@effect/platform-node"
import { Layer } from "effect"
import { Socket } from "effect/unstable/socket"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
import { LLMClient, RequestExecutor } from "../src/route.js"
@@ -16,7 +18,7 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService | Socket.WebSocketConstructor
type RecordedTestsOptions = RecordedGroupOptions & {
readonly options?: HttpRecorder.RecorderOptions
@@ -69,7 +71,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
...metadata,
}
if (recording) {
if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes")
if (process.env.CI !== undefined) throw new Error("Unset CI before recording cassettes")
HttpRecorder.removeCassetteSync(cassette, { directory: FIXTURES_DIR })
}
const requestExecutor = RequestExecutor.layer.pipe(
@@ -81,10 +83,16 @@ export const recordedTests = (options: RecordedTestsOptions) =>
}),
),
)
const webSocket = HttpRecorder.layerWebSocketConstructor(cassette, {
...recorderOptions,
directory: FIXTURES_DIR,
metadata: recorderMetadata,
}).pipe(Layer.provide(NodeSocket.layerWebSocketConstructorWS))
return Layer.mergeAll(
requestExecutor,
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
webSocket,
)
},
})
+14
View File
@@ -377,6 +377,15 @@ export type Endpoint5_31Output =
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly title: string }
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.viewed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly idle: number }
}
| {
readonly id: Event.ID
readonly created: number
@@ -918,6 +927,10 @@ export type Endpoint5_35Input = { readonly sessionID: Session.ID; readonly varia
export type Endpoint5_35Output = void
export type SessionEnvironmentOperation<E = never> = (input: Endpoint5_35Input) => Effect.Effect<Endpoint5_35Output, E>
export type Endpoint5_36Input = { readonly sessionID: Session.ID }
export type Endpoint5_36Output = void
export type SessionViewOperation<E = never> = (input: Endpoint5_36Input) => Effect.Effect<Endpoint5_36Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
readonly create: SessionCreateOperation<E>
@@ -963,6 +976,7 @@ export interface SessionApi<E = never> {
readonly background: SessionBackgroundOperation<E>
readonly message: SessionMessageOperation<E>
readonly environment: SessionEnvironmentOperation<E>
readonly view: SessionViewOperation<E>
}
export type Endpoint6_0Input = {
@@ -86,6 +86,8 @@ import type {
Endpoint5_34Output,
Endpoint5_35Input,
Endpoint5_35Output,
Endpoint5_36Input,
Endpoint5_36Output,
Endpoint6_0Input,
Endpoint6_0Output,
Endpoint7_0Input,
@@ -613,6 +615,11 @@ const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35I
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_36 = (raw: RawClient["server.session"]) => (input: Endpoint5_36Input) =>
preserveEffect<Endpoint5_36Output>()(
raw["session.view"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup5 = (raw: RawClient["server.session"]) => ({
list: Endpoint5_0(raw),
create: Endpoint5_1(raw),
@@ -643,6 +650,7 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
background: Endpoint5_33(raw),
message: Endpoint5_34(raw),
environment: Endpoint5_35(raw),
view: Endpoint5_36(raw),
})
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -80,6 +80,8 @@ import type {
SessionMessageOutput,
SessionEnvironmentInput,
SessionEnvironmentOutput,
SessionViewInput,
SessionViewOutput,
MessageListInput,
MessageListOutput,
ModelListInput,
@@ -896,6 +898,17 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
view: (input: SessionViewInput, requestOptions?: RequestOptions) =>
request<SessionViewOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/view`,
successStatus: 204,
declaredStatuses: [404, 401, 400],
empty: true,
},
requestOptions,
),
},
message: {
list: (input: MessageListInput, requestOptions?: RequestOptions) =>
+42 -4
View File
@@ -322,6 +322,16 @@ export type SessionRenamed = {
data: { sessionID: string; title: string }
}
export type SessionViewed = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.viewed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; idle: number }
}
export type SessionDeleted = {
id: string
created: number
@@ -1657,7 +1667,8 @@ export type SessionInfo = {
model?: ModelRef
cost: MoneyUSD
tokens: TokenUsageInfo
time: { created: number; updated: number; archived?: number }
outcome?: "succeeded" | "failed" | "interrupted"
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
title?: string
location: LocationRef
subpath?: string
@@ -1942,6 +1953,7 @@ export type SessionEventDurable =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionViewed
| SessionDeleted
| SessionForked
| SessionInboxDelivered
@@ -2027,6 +2039,7 @@ export type V2Event =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionViewed
| SessionUsageUpdated
| SessionDeleted
| SessionForked
@@ -2499,7 +2512,14 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
readonly outcome?: "succeeded" | "failed" | "interrupted"
readonly time: {
readonly created: number
readonly updated: number
readonly idle?: number
readonly viewed?: number
readonly archived?: number
}
readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
@@ -2767,7 +2787,14 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
readonly outcome?: "succeeded" | "failed" | "interrupted"
readonly time: {
readonly created: number
readonly updated: number
readonly idle?: number
readonly viewed?: number
readonly archived?: number
}
readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
@@ -3035,7 +3062,14 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
readonly outcome?: "succeeded" | "failed" | "interrupted"
readonly time: {
readonly created: number
readonly updated: number
readonly idle?: number
readonly viewed?: number
readonly archived?: number
}
readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
@@ -3969,6 +4003,10 @@ export type SessionEnvironmentInput = {
export type SessionEnvironmentOutput = void
export type SessionViewInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionViewOutput = void
export type MessageListInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly limit?: {
+26
View File
@@ -844,6 +844,16 @@ export function createData(config: CreateDataInput) {
const currentAssistant = message.activeAssistant(draft)
if (currentAssistant) currentAssistant.retry = undefined
})
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
// Refresh only sessions this client already loaded; unloaded sessions hydrate on demand.
if (!store.session.info[event.data.sessionID]) return
result.session.invalidate(event.data.sessionID)
void result.session.sync(event.data.sessionID)
return
case "session.viewed":
if (!store.session.info[event.data.sessionID]) return
result.session.invalidate(event.data.sessionID)
void result.session.sync(event.data.sessionID)
return
case "session.revert.staged":
if (store.session.info[event.data.sessionID])
@@ -1152,6 +1162,22 @@ export function createData(config: CreateDataInput) {
: [],
])
const sessions = [info, ...children]
// Hydrate deeper descendant levels too: the family index spans nested subagents,
// and a grandchild's unread state must survive a client restart.
const visited = new Set([sessionID, ...children.map((child) => child.id)])
let frontier = children.map((child) => child.id)
while (frontier.length > 0) {
const levels = await Promise.all(
frontier.map((parentID) =>
api()
.session.list({ parentID, order: "desc" })
.then((response) => response.data),
),
)
const next = levels.flat().filter((child) => !visited.has(child.id) && visited.add(child.id))
sessions.push(...next)
frontier = next.map((child) => child.id)
}
setStore(
"session",
"info",
+11 -1
View File
@@ -136,8 +136,10 @@ test("event.subscribe terminates on Effect protocol decode failures", async () =
test("session methods retain decoded Effect inputs and outputs", async () => {
const logQueries: Array<Record<string, string>> = []
const requests: Array<{ method: string; url: string }> = []
const httpClient = HttpClient.make((request) => {
const url = request.url
requests.push({ method: request.method, url })
if (url.includes("/log")) {
logQueries.push(Object.fromEntries(request.urlParams.params))
return Effect.succeed(
@@ -183,6 +185,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
const created = yield* client.session.create({
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
})
yield* client.session.view({ sessionID: Session.ID.make("ses_test") })
yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
yield* client.session.switchModel({
sessionID: Session.ID.make("ses_test"),
@@ -207,7 +210,11 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
return { page, active, created, admitted, context, log, message }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
const listed = result.page.data[0]
if (!listed?.time.idle || !listed.time.viewed) throw new Error("Expected attention times")
expect(DateTime.toEpochMillis(listed.time.created)).toBe(1_717_171_717_000)
expect(DateTime.toEpochMillis(listed.time.idle)).toBe(1_717_171_717_002)
expect(DateTime.toEpochMillis(listed.time.viewed)).toBe(1_717_171_717_001)
expect(result.active).toEqual({ ses_test: { type: "running" } })
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
@@ -217,6 +224,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
expect(result.context).toEqual([])
expect(logQueries[0]).toEqual({ after: "0" })
expect(requests).toContainEqual({ method: "POST", url: "http://localhost:3000/api/session/ses_test/view" })
const logged = Array.from(result.log)
expect(logged.map((item) => item.type)).toEqual(["session.model.selected", "log.synced"])
expect(logged[0]?.type === "session.model.selected" && logged[0].created).toBe(1_717_171_717_000)
@@ -258,6 +266,8 @@ const session = {
time: {
created: 1_717_171_717_000,
updated: 1_717_171_717_000,
idle: 1_717_171_717_002,
viewed: 1_717_171_717_001,
},
title: "Test",
location: { directory: "/tmp/project" },
+5
View File
@@ -523,6 +523,7 @@ test("session methods use the public HTTP contract", async () => {
const page = await client.session.list({ limit: 10, order: "desc", parentID: null })
const active = await client.session.active()
const created = await client.session.create({ location: { directory: "/tmp/project" } })
await client.session.view({ sessionID: "ses_test" })
await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
await client.session.switchModel({
sessionID: "ses_test",
@@ -549,6 +550,7 @@ test("session methods use the public HTTP contract", async () => {
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
expect(page.cursor.next).toBe("next")
expect(page.data[0].time).toMatchObject({ idle: 1_717_171_717_002, viewed: 1_717_171_717_001 })
expect(active).toEqual({ ses_test: { type: "running" } })
expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test")
@@ -561,6 +563,7 @@ test("session methods use the public HTTP contract", async () => {
["GET", "http://localhost:3000/api/session?limit=10&order=desc&parentID=null"],
["GET", "http://localhost:3000/api/session/active"],
["POST", "http://localhost:3000/api/session"],
["POST", "http://localhost:3000/api/session/ses_test/view"],
["POST", "http://localhost:3000/api/session/ses_test/agent"],
["POST", "http://localhost:3000/api/session/ses_test/model"],
["POST", "http://localhost:3000/api/session/ses_test/prompt"],
@@ -635,6 +638,8 @@ const session = {
time: {
created: 1_717_171_717_000,
updated: 1_717_171_717_000,
idle: 1_717_171_717_002,
viewed: 1_717_171_717_001,
},
title: "Test",
location: { directory: "/tmp/project" },
+162 -44
View File
@@ -1,8 +1,10 @@
{
"version": "7",
"dialect": "sqlite",
"id": "dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936",
"prevIds": ["5c1aa56b-c3ee-4283-9a84-c0bf626dc604"],
"id": "3fb67508-0196-4bae-b2bd-c08ece7583fd",
"prevIds": [
"dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936"
],
"ddl": [
{
"name": "account_state",
@@ -1350,6 +1352,36 @@
"entityType": "columns",
"table": "session_v2"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_idle",
"entityType": "columns",
"table": "session_v2"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_viewed",
"entityType": "columns",
"table": "session_v2"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "idle_outcome",
"entityType": "columns",
"table": "session_v2"
},
{
"type": "integer",
"notNull": false,
@@ -1481,9 +1513,13 @@
"table": "worktree"
},
{
"columns": ["active_account_id"],
"columns": [
"active_account_id"
],
"tableTo": "account",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "SET NULL",
"nameExplicit": false,
@@ -1492,9 +1528,13 @@
"table": "account_state"
},
{
"columns": ["aggregate_id"],
"columns": [
"aggregate_id"
],
"tableTo": "event_sequence",
"columnsTo": ["aggregate_id"],
"columnsTo": [
"aggregate_id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1503,9 +1543,13 @@
"table": "event"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1514,9 +1558,13 @@
"table": "permission"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1525,9 +1573,13 @@
"table": "project_directory"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session_v2",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1536,9 +1588,13 @@
"table": "instruction_entry"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session_v2",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1547,9 +1603,13 @@
"table": "instruction_state"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session_v2",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1558,9 +1618,13 @@
"table": "session_inbox"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session_v2",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1569,9 +1633,13 @@
"table": "session_message"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session_v2",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1580,9 +1648,13 @@
"table": "session_pending"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1591,9 +1663,13 @@
"table": "session_v2"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1602,133 +1678,175 @@
"table": "worktree"
},
{
"columns": ["email", "url"],
"columns": [
"email",
"url"
],
"nameExplicit": false,
"name": "control_account_pk",
"entityType": "pks",
"table": "control_account"
},
{
"columns": ["project_id", "directory"],
"columns": [
"project_id",
"directory"
],
"nameExplicit": false,
"name": "project_directory_pk",
"entityType": "pks",
"table": "project_directory"
},
{
"columns": ["session_id", "key"],
"columns": [
"session_id",
"key"
],
"nameExplicit": false,
"name": "instruction_entry_pk",
"entityType": "pks",
"table": "instruction_entry"
},
{
"columns": ["project_id", "directory"],
"columns": [
"project_id",
"directory"
],
"nameExplicit": false,
"name": "worktree_pk",
"entityType": "pks",
"table": "worktree"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "account_state_pk",
"table": "account_state",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "account_pk",
"table": "account",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "credential_pk",
"table": "credential",
"entityType": "pks"
},
{
"columns": ["aggregate_id"],
"columns": [
"aggregate_id"
],
"nameExplicit": false,
"name": "event_sequence_pk",
"table": "event_sequence",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "event_pk",
"table": "event",
"entityType": "pks"
},
{
"columns": ["key"],
"columns": [
"key"
],
"nameExplicit": false,
"name": "kv_pk",
"table": "kv",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "permission_pk",
"table": "permission",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "project_pk",
"table": "project",
"entityType": "pks"
},
{
"columns": ["hash"],
"columns": [
"hash"
],
"nameExplicit": false,
"name": "instruction_blob_pk",
"table": "instruction_blob",
"entityType": "pks"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "instruction_state_pk",
"table": "instruction_state",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_inbox_pk",
"table": "session_inbox",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_message_pk",
"table": "session_message",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_pending_pk",
"table": "session_pending",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_v2_pk",
"table": "session_v2",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
@@ -2024,4 +2142,4 @@
}
],
"renames": []
}
}
+2
View File
@@ -43,6 +43,7 @@ import m40 from "./migration/20260808023530_workspace_domain.js"
import m41 from "./migration/20260811161259_execution_claim_attempts.js"
import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260819222447_session_viewed_state.js"
export const migrations = [
m00,
@@ -89,4 +90,5 @@ export const migrations = [
m41,
m42,
m43,
m44,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,15 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260819222447_session_viewed_state",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`time_idle\` integer;`)
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`time_viewed\` integer;`)
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`idle_outcome\` text;`)
})
},
}
export default migration
+3
View File
@@ -209,6 +209,9 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
\`model\` text,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`time_idle\` integer,
\`time_viewed\` integer,
\`idle_outcome\` text,
\`time_compacting\` integer,
\`time_archived\` integer,
\`time_suspended\` integer,
+11 -11
View File
@@ -5,7 +5,7 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Command } from "@opencode-ai/schema/command"
import { createHash } from "node:crypto"
import { isDeepStrictEqual } from "node:util"
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream, Types } from "effect"
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Credential } from "../credential.js"
import { Bus } from "../bus.js"
@@ -111,7 +111,7 @@ export class ToolCallError extends Schema.TaggedError<ToolCallError>()("MCP.Tool
type ServerEntry = {
readonly config: Mcp.ServerConfig
status: Status
readonly startup: Deferred.Deferred<void>
readonly startup: Latch.Latch
scope?: Scope.Closeable
client?: MCPClient.Connection
tools?: ReadonlyArray<Tool>
@@ -535,7 +535,7 @@ export const layer = (options?: Options) =>
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
}).pipe(Effect.ensuring(Deferred.succeed(entry.startup, undefined)))
}).pipe(Effect.ensuring(entry.startup.open))
const stopServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
const scope = entry.scope
@@ -562,7 +562,7 @@ export const layer = (options?: Options) =>
const entry: ServerEntry = {
config: serverConfig,
status: { status: "pending" },
startup: Deferred.makeUnsafe<void>(),
startup: Latch.makeUnsafe(),
}
entries.set(name, entry)
yield* Effect.gen(function* () {
@@ -575,7 +575,7 @@ export const layer = (options?: Options) =>
yield* startServer(name, entry)
}).pipe(
// Settle startup even when registration fails or replacement is interrupted, so readers cannot hang.
Effect.ensuring(Effect.sync(() => Deferred.doneUnsafe(entry.startup, Exit.void))),
Effect.ensuring(entry.startup.open),
)
})
@@ -597,7 +597,7 @@ export const layer = (options?: Options) =>
entries.set(name, {
config: server,
status: { status: "pending" },
startup: Deferred.makeUnsafe<void>(),
startup: Latch.makeUnsafe(),
})
}
yield* Effect.forEach(entries, ([name, entry]) => register(name, entry), { discard: true })
@@ -607,7 +607,7 @@ export const layer = (options?: Options) =>
for (const [name, entry] of entries) {
if (entry.config.disabled) {
entry.status = { status: "disabled" }
Deferred.doneUnsafe(entry.startup, Exit.void)
entry.startup.openUnsafe()
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
continue
}
@@ -685,7 +685,7 @@ export const layer = (options?: Options) =>
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
const whenAllReady = Effect.suspend(() =>
Effect.forEach(Array.from(entries.values()), (entry) => Deferred.await(entry.startup), {
Effect.forEach(Array.from(entries.values()), (entry) => entry.startup.await, {
concurrency: "unbounded",
discard: true,
}),
@@ -734,7 +734,7 @@ export const layer = (options?: Options) =>
}),
callTool: Effect.fn("MCP.callTool")(function* (input) {
const target = yield* requireServer(input.server)
yield* Deferred.await(target.entry.startup)
yield* target.entry.startup.await
if (!target.entry.client)
return yield* new ToolCallError({
server: target.name,
@@ -773,7 +773,7 @@ export const layer = (options?: Options) =>
}),
prompt: Effect.fn("MCP.prompt")(function* (input) {
const target = yield* requireServer(input.server)
yield* Deferred.await(target.entry.startup)
yield* target.entry.startup.await
if (!target.entry.client) return undefined
const result = yield* target.entry.client
.prompt({ name: input.name, args: input.args })
@@ -827,7 +827,7 @@ export const layer = (options?: Options) =>
}),
readResource: Effect.fn("MCP.readResource")(function* (input) {
const target = yield* requireServer(input.server)
yield* Deferred.await(target.entry.startup)
yield* target.entry.startup.await
if (!target.entry.client) return undefined
const result = yield* target.entry.client
.readResource({ uri: input.uri })
+5 -5
View File
@@ -3,7 +3,7 @@ export { Service, type Interface } from "./supervisor-service.js"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Event } from "@opencode-ai/schema/config"
import { Cause, Deferred, Effect, Layer, Schema, Stream } from "effect"
import { Cause, Effect, Latch, Layer, Schema, Stream } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import { ConfigPluginSource } from "../config/plugin/source.js"
@@ -137,7 +137,7 @@ export const layer = Layer.effect(
const sdk = yield* SdkPlugins.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const ready = { current: yield* Deferred.make<void>() }
const ready = yield* Latch.make()
let observed = 0
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
@@ -164,7 +164,7 @@ export const layer = Layer.effect(
Stream.mapEffect(() =>
Effect.gen(function* () {
observed++
if (yield* Deferred.isDone(ready.current)) ready.current = yield* Deferred.make<void>()
yield* ready.close
return observed
}),
),
@@ -176,12 +176,12 @@ export const layer = Layer.effect(
Stream.runForEach((target) =>
Effect.gen(function* () {
yield* activate()
if (observed === target) yield* Deferred.succeed(ready.current, undefined)
if (observed === target) yield* ready.open
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: Effect.suspend(() => Deferred.await(ready.current)) })
return Service.of({ flush: ready.await })
}),
)
+12
View File
@@ -171,6 +171,7 @@ export interface Interface {
readonly sessionID: SessionSchema.ID
readonly variables?: SessionEnvironment.Variables
}) => Effect.Effect<SessionEnvironment.Variables | undefined, NotFoundError>
readonly view: (input: { sessionID: SessionSchema.ID }) => Effect.Effect<void, NotFoundError>
readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly messages: (input: {
sessionID: SessionSchema.ID
@@ -459,6 +460,17 @@ const layer = Layer.effect(
if (input.variables !== undefined) yield* environments.set(input.sessionID, input.variables)
return yield* environments.get(input.sessionID)
}),
view: Effect.fn("Session.view")(function* (input) {
const row = yield* db
.select({ idle: SessionTable.time_idle, viewed: SessionTable.time_viewed })
.from(SessionTable)
.where(eq(SessionTable.id, input.sessionID))
.get()
.pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ sessionID: input.sessionID })
if (row.idle === null || (row.viewed !== null && row.viewed >= row.idle)) return
yield* bus.publish(SessionEvent.Viewed, { sessionID: input.sessionID, idle: row.idle })
}),
remove: Effect.fn("Session.remove")(function* (sessionID) {
const session = yield* result.get(sessionID)
yield* execution.interrupt(sessionID)
+65 -74
View File
@@ -1,6 +1,6 @@
export * as SessionCompaction from "./compaction.js"
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
import { LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
@@ -9,17 +9,12 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../effect/app-node-platform.js"
import { SessionEvent } from "./event.js"
import type { SessionMessage } from "./message.js"
import { SessionModelHeaders } from "./model-headers.js"
import { SessionModelHook } from "./model-hook.js"
import { SessionModelHttp } from "./model-http.js"
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
import { App } from "../app.js"
import { SessionModelRequest } from "./model-request.js"
import { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { toSessionError } from "./to-session-error.js"
import { Token } from "../util/token.js"
import { SessionUsage } from "./usage.js"
import { PluginHooks } from "../plugin/hooks.js"
import { Agent } from "../agent.js"
import { State } from "../state.js"
@@ -70,13 +65,12 @@ export type Draft = {
}
type Dependencies = {
readonly app: App.Info
readonly bus: Bus.Interface
readonly llm: {
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
}
readonly models: SessionRunnerModel.Interface
readonly hooks: PluginHooks.Interface
readonly modelRequests: SessionModelRequest.Interface
}
export type AutoInput = {
@@ -85,6 +79,8 @@ export type AutoInput = {
readonly resolved: SessionRunnerModel.Resolved
}
type RequiredInput = Pick<AutoInput, "messages" | "resolved">
export type ManualInput = {
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[]
@@ -92,8 +88,6 @@ export type ManualInput = {
readonly started?: boolean
}
type RequiredInput = Pick<AutoInput, "messages" | "resolved">
type Plan = {
readonly session: SessionSchema.Info
readonly resolved: SessionRunnerModel.Resolved
@@ -116,8 +110,19 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
const truncate = (value: string) =>
value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
export const truncateToolOutput = (value: string) => {
if (value.length <= TOOL_OUTPUT_MAX_CHARS) return value
let end = 0
for (let count = 0; count < TOOL_OUTPUT_MAX_CHARS && end < value.length; count++) {
const code = value.charCodeAt(end)
end +=
code >= 0xd800 && code <= 0xdbff && value.charCodeAt(end + 1) >= 0xdc00 && value.charCodeAt(end + 1) <= 0xdfff
? 2
: 1
}
if (end === value.length) return value
return `${value.slice(0, end)}\n[truncated]`
}
export const serializeToolContent = (content: SessionMessage.ToolStateCompleted["content"]) =>
content
@@ -146,7 +151,7 @@ const serialize = (message: SessionMessage.Info) => {
if (part.state.status === "completed")
return [
`[Assistant tool call]: ${part.name}(${input})`,
`[Tool result]: ${truncate(serializeToolContent(part.state.content))}`,
`[Tool result]: ${truncateToolOutput(serializeToolContent(part.state.content))}`,
]
if (part.state.status === "error")
return [`[Assistant tool call]: ${part.name}(${input})`, `[Tool error]: ${part.state.error.message}`]
@@ -157,7 +162,8 @@ const serialize = (message: SessionMessage.Info) => {
if (message.type === "system") return `[System update]: ${message.text}`
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output?.output ?? "")}`
if (message.type === "shell")
return `[Shell]: ${message.command}\n${truncateToolOutput(message.output?.output ?? "")}`
return ""
}
@@ -266,65 +272,51 @@ const make = (dependencies: Dependencies) => {
})
: Effect.void,
)
const request = yield* SessionModelHook.apply(
dependencies.hooks,
{ sessionID: plan.session.id, agent: Agent.ID.make("compaction"), model: plan.resolved.ref },
LLM.request({
model: plan.resolved.model,
promptCacheKey: SessionPromptCacheKey.make(plan.session.id),
http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) },
messages: [Message.user(plan.prompt)],
tools: [],
const prepared = yield* dependencies.modelRequests.prepare({
scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.resolved },
transcript: { system: [], messages: [Message.user(plan.prompt)] },
contextHooks: false,
})
yield* dependencies.llm.stream(prepared.request, prepared.options).pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event))
failure = {
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
message: event.message,
}
if (LLMEvent.is.textDelta(event)) {
chunks.push(event.text)
return dependencies.bus.publish(SessionEvent.Compaction.Delta, {
sessionID: plan.session.id,
text: event.text,
})
}
if (LLMEvent.is.stepFinish(event)) {
const step = SessionUsage.record(event.usage, plan.resolved.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
return Effect.void
}),
)
yield* dependencies.llm
.stream(request, {
http: SessionModelHttp.middleware(dependencies.hooks, {
sessionID: plan.session.id,
agent: Agent.ID.make("compaction"),
model: plan.resolved.ref,
Effect.catchTag("AI.Error", (error) =>
Effect.sync(() => {
failure = toSessionError(error)
}),
})
.pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event))
failure = {
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
message: event.message,
}
if (LLMEvent.is.textDelta(event)) {
chunks.push(event.text)
return dependencies.bus.publish(SessionEvent.Compaction.Delta, {
sessionID: plan.session.id,
text: event.text,
})
}
if (LLMEvent.is.stepFinish(event)) {
const step = SessionUsage.record(event.usage, plan.resolved.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
return Effect.void
}),
Effect.catchTag("AI.Error", (error) =>
Effect.sync(() => {
failure = toSessionError(error)
}),
),
Effect.onInterrupt(() =>
recordUsage.pipe(
Effect.andThen(
plan.reason === "auto"
? failed({
sessionID: plan.session.id,
reason: plan.reason,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
inputID: plan.inputID,
}).pipe(Effect.asVoid)
: Effect.void,
),
),
Effect.onInterrupt(() =>
recordUsage.pipe(
Effect.andThen(
plan.reason === "auto"
? failed({
sessionID: plan.session.id,
reason: plan.reason,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
inputID: plan.inputID,
}).pipe(Effect.asVoid)
: Effect.void,
),
),
)
),
)
yield* recordUsage
const summary = chunks.join("")
if (failure || !summary.trim()) {
@@ -425,14 +417,13 @@ export const layer = Layer.effect(
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const models = yield* SessionRunnerModel.Service
const app = yield* App.Metadata
const hooks = yield* PluginHooks.Service
return make({ bus, llm, models, app, hooks })
const modelRequests = yield* SessionModelRequest.Service
return make({ bus, llm, models, modelRequests })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient, SessionRunnerModel.node, App.node, PluginHooks.node],
deps: [Bus.node, llmClient, SessionRunnerModel.node, SessionModelRequest.node],
})
+3
View File
@@ -50,9 +50,12 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
}),
subpath: row.path ? RelativePath.make(row.path) : undefined,
revert: row.revert ? decodeRevert(row.revert) : undefined,
outcome: row.idle_outcome ?? undefined,
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),
idle: row.time_idle === null ? undefined : DateTime.makeUnsafe(row.time_idle),
viewed: row.time_viewed === null ? undefined : DateTime.makeUnsafe(row.time_viewed),
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
},
})
@@ -60,6 +60,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
Match.type<SessionEvent.DurableEvent>(),
Match.discriminatorsExhaustive("type")({
"session.created": () => Effect.void,
"session.viewed": () => Effect.void,
"session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) => {
return Effect.gen(function* () {
+24 -10
View File
@@ -61,13 +61,20 @@ interface PrepareInput {
readonly session: SessionSchema.Info
readonly agentID: Agent.ID
readonly model: SessionRunnerModel.Resolved
readonly tools: Tool.Snapshot
/** Omitted for requests that carry no tools (title, compaction). */
readonly tools?: Tool.Snapshot
}
readonly transcript: {
readonly system: Array<SystemPart>
readonly messages: Array<Message>
}
readonly toolChoice?: LLM.RequestInput["toolChoice"]
/**
* Session context hooks shape the agent conversation. Requests that are not
* part of the conversation (title, compaction) opt out: their transcripts
* pass through unchanged.
*/
readonly contextHooks?: false
/** Stateful Session WebSocket channels require an explicit durable-runner opt-in. */
readonly webSocket?: "session"
}
@@ -209,7 +216,10 @@ export const layer = Layer.effect(
const session = input.scope.session
const resolved = input.scope.model
const model = resolved.model
const tools = input.scope.tools
const tools = input.scope.tools ?? {
definitions: [],
execute: () => new Tool.Error({ message: "Tools are not available for this request" }),
}
const registry = new Map(tools.definitions.map((tool) => [tool.name, tool]))
// The definition objects we hand to hooks, mapped back to their tools. Hooks rename a
// tool by moving its definition to a new key; recognizing the object recovers the tool.
@@ -219,14 +229,18 @@ export const layer = Layer.effect(
),
)
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
const context = yield* hooks.trigger("session", "context", {
sessionID: session.id,
agent: input.scope.agentID,
model: resolved.ref,
system: input.transcript.system,
messages: input.transcript.messages,
tools: Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])),
})
const definitions = Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition]))
const context =
input.contextHooks === false
? { system: input.transcript.system, messages: input.transcript.messages, tools: definitions }
: yield* hooks.trigger("session", "context", {
sessionID: session.id,
agent: input.scope.agentID,
model: resolved.ref,
system: input.transcript.system,
messages: input.transcript.messages,
tools: definitions,
})
// Match each surviving entry back to its tool, by recognizing a moved definition or
// by key. Identity wins so a definition moved onto another tool's name still executes
// the tool it describes. Entries matching neither were invented by a hook and dropped.
+48 -3
View File
@@ -391,6 +391,37 @@ function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, me
.pipe(Effect.orDie)
}
function projectIdle(
db: DatabaseService,
event:
| typeof SessionEvent.Execution.Succeeded.Type
| typeof SessionEvent.Execution.Failed.Type
| typeof SessionEvent.Execution.Interrupted.Type,
) {
return Effect.gen(function* () {
yield* run(db, event)
if (event.type === SessionEvent.Execution.Interrupted.type && event.data.reason === "shutdown") return
const time = event.created
const outcome =
event.type === SessionEvent.Execution.Succeeded.type
? "succeeded"
: event.type === SessionEvent.Execution.Failed.type
? "failed"
: "interrupted"
yield* db
.update(SessionTable)
.set({
// Unread uses a strict timestamp comparison, so every terminal must advance even within one millisecond.
time_idle: sql`max(${time}, coalesce(${SessionTable.time_idle} + 1, ${time}))`,
idle_outcome: outcome,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
})
}
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const bus = yield* Bus.Service
@@ -512,6 +543,20 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.Viewed, (event) => {
const idle = event.data.idle
return db
.update(SessionTable)
.set({
// Monotone watermark: a duplicate or stale view never regresses, and a terminal event
// committing after the viewer's observation keeps the newer idle transition unread.
time_viewed: sql`max(${idle}, coalesce(${SessionTable.time_viewed}, ${idle}))`,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
})
yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event))
yield* bus.project(SessionEvent.InboxDelivered, (event) =>
@@ -580,9 +625,9 @@ const layer = Layer.effectDiscard(
delivery: event.data.delivery,
}),
)
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => projectIdle(db, event))
yield* bus.project(SessionEvent.Execution.Failed, (event) => projectIdle(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => projectIdle(db, event))
yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
Effect.gen(function* () {
yield* run(db, event)
+9 -2
View File
@@ -89,7 +89,15 @@ const classifyToolExits = (
.flatMap((cause) => {
if (Cause.hasInterrupts(cause)) return []
const reasons = cause.reasons.flatMap(
(reason): Array<Cause.Reason<never>> => (Cause.isFailReason(reason) ? [] : [reason]),
(reason): Array<Cause.Reason<never>> =>
Cause.isFailReason(reason)
? isDecline(reason.error)
? []
: // A typed failure here broke the ExecuteError contract (the per-fiber
// `catchTag("Tool.Error")` consumes honest ones). Surfacing it as a defect
// keeps it from being dropped, which would leave its call unsettled forever.
[Cause.makeDieReason(reason.error)]
: [reason],
)
return reasons.length > 0 ? [Cause.fromReasons(reasons)] : []
})
@@ -315,7 +323,6 @@ const layer = Layer.effect(
const loaded = yield* context.load(selected)
const { session, agent } = loaded
const resolved = loaded.model
const model = resolved.model
// Make room: history must fit the context window before the call. A pending manual
// compaction owns this instead; the runner executes it between steps.
const compactionInput = { session, messages: loaded.messages, resolved }
+3
View File
@@ -56,6 +56,9 @@ export const SessionTable = sqliteTable(
variant?: string
}>(),
...Timestamps,
time_idle: integer(),
time_viewed: integer(),
idle_outcome: text().$type<NonNullable<Session.Info["outcome"]>>(),
time_compacting: integer(),
time_archived: integer(),
/** The execution claim timestamp (historical column name; see SessionStore.claim). */
+25 -43
View File
@@ -1,6 +1,6 @@
export * as SessionTitle from "./title.js"
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
import { LLMClient, AIError, LLMEvent, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { Agent } from "../agent.js"
@@ -8,14 +8,10 @@ import { Database } from "../database/database.js"
import { Bus } from "../bus.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback"
import { App } from "../app.js"
import { llmClient } from "../effect/app-node-platform.js"
import { PluginHooks } from "../plugin/hooks.js"
import { SessionEvent } from "./event.js"
import { SessionHistory } from "./history.js"
import { SessionModelHeaders } from "./model-headers.js"
import { SessionModelHook } from "./model-hook.js"
import { SessionModelHttp } from "./model-http.js"
import { SessionModelRequest } from "./model-request.js"
import { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { SessionUsage } from "./usage.js"
@@ -25,15 +21,14 @@ const MAX_LENGTH = 100
const titleChanged = Symbol("Session title changed")
type Dependencies = {
readonly app: App.Info
readonly bus: Bus.Interface
readonly llm: {
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
}
readonly agents: Agent.Interface
readonly models: SessionRunnerModel.Interface
readonly modelRequests: SessionModelRequest.Interface
readonly store: SessionStore.Interface
readonly hooks: PluginHooks.Interface
}
export interface Interface {
@@ -81,39 +76,28 @@ const make = (dependencies: Dependencies) => {
})
: Effect.void,
)
const request = yield* SessionModelHook.apply(
dependencies.hooks,
{ sessionID: session.id, agent: agent.id, model: resolved.ref },
LLM.request({
model: resolved.model,
http: { headers: SessionModelHeaders.make(session, dependencies.app) },
system: agent.system,
const prepared = yield* dependencies.modelRequests.prepare({
scope: { session, agentID: agent.id, model: resolved },
transcript: {
system: agent.system ? [SystemPart.make(agent.system)] : [],
messages: [Message.user(firstUser.text)],
tools: [],
},
contextHooks: false,
})
const streamed = yield* dependencies.llm.stream(prepared.request, prepared.options).pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) failed = true
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
if (LLMEvent.is.stepFinish(event)) {
const step = SessionUsage.record(event.usage, resolved.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
return Effect.void
}),
Effect.as(true),
Effect.catchTag("AI.Error", () => Effect.succeed(false)),
Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)),
)
const streamed = yield* dependencies.llm
.stream(request, {
http: SessionModelHttp.middleware(dependencies.hooks, {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
}),
})
.pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) failed = true
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
if (LLMEvent.is.stepFinish(event)) {
const step = SessionUsage.record(event.usage, resolved.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
return Effect.void
}),
Effect.as(true),
Effect.catchTag("AI.Error", () => Effect.succeed(false)),
Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)),
)
yield* recordUsage
if (!streamed || failed) return
const title = chunks
@@ -146,11 +130,10 @@ export const layer = Layer.effect(
const llm = yield* LLMClient.Service
const agents = yield* Agent.Service
const models = yield* SessionRunnerModel.Service
const modelRequests = yield* SessionModelRequest.Service
const store = yield* SessionStore.Service
const database = yield* Database.Service
const app = yield* App.Metadata
const hooks = yield* PluginHooks.Service
const title = make({ bus, llm, agents, models, store, app, hooks })
const title = make({ bus, llm, agents, models, modelRequests, store })
return Service.of({
generateForFirstPrompt: (sessionID) => title.generateForFirstPrompt(database.db, sessionID),
})
@@ -165,9 +148,8 @@ export const node = makeLocationNode({
llmClient,
Agent.node,
SessionRunnerModel.node,
SessionModelRequest.node,
SessionStore.node,
Database.node,
App.node,
PluginHooks.node,
],
})
+5
View File
@@ -117,6 +117,11 @@ const layer = Layer.effect(
tokens_cache_write: input.data.info.tokens.cache.write,
time_created: DateTime.toEpochMillis(input.data.info.time.created),
time_updated: DateTime.toEpochMillis(input.data.info.time.updated),
time_idle: input.data.info.time.idle ? DateTime.toEpochMillis(input.data.info.time.idle) : null,
time_viewed: input.data.info.time.viewed
? DateTime.toEpochMillis(input.data.info.time.viewed)
: null,
idle_outcome: input.data.info.outcome ?? null,
time_archived: input.data.info.time.archived
? DateTime.toEpochMillis(input.data.info.time.archived)
: null,
+5 -5
View File
@@ -1,7 +1,7 @@
export * as Shell from "./shell.js"
import path from "path"
import { Context, Deferred, Duration, Effect, Fiber, Layer, Schema, Schedule, Stream } from "effect"
import { Context, Deferred, Duration, Effect, Fiber, Latch, Layer, Schema, Schedule, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { produce } from "immer"
import { Shell } from "@opencode-ai/schema/shell"
@@ -286,7 +286,7 @@ const layer = () =>
sessions.set(id, session)
const stream = createWriteStream(file)
const outputDone = Deferred.makeUnsafe<void>()
const outputDone = Latch.makeUnsafe()
const pump = handle.all.pipe(
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
@@ -304,8 +304,8 @@ const layer = () =>
stream.end(() => resolve())
}),
)
yield* Deferred.succeed(outputDone, undefined)
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
yield* outputDone.open
}).pipe(Effect.catch(() => outputDone.open)),
)
yield* Effect.promise(
() =>
@@ -324,7 +324,7 @@ const layer = () =>
draft.time.completed = Date.now()
})
yield* beforeWait
yield* Deferred.await(outputDone)
yield* outputDone.await
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// session still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
+63 -44
View File
@@ -2,7 +2,6 @@ export * as ShellTool from "./shell.js"
import path from "path"
import { ToolFailure } from "@opencode-ai/ai"
import type { Content } from "@opencode-ai/schema/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Schema, Scope } from "effect"
import { Config } from "../../config.js"
@@ -72,10 +71,27 @@ const Output = Schema.Struct({
type Output = typeof Output.Type
const modelOutput = (output: Output): string | undefined => {
if (output.status === "running") return BACKGROUND_INSTRUCTION
if (output.timeout) return "Command timed out before completion."
return `Command exited with code ${output.exit}.`
const resultMessages = (output: Output) => {
const notice = (() => {
if (output.status === "running") return BACKGROUND_INSTRUCTION
if (output.timeout) return "Command timed out before completion."
if (output.exit !== undefined) return `Command exited with code ${output.exit}.`
})()
return [output.output, ...(notice ? [notice] : [])]
}
const toolResult = (output: Output) => {
return {
output,
content: resultMessages(output).map((text) => ({ type: "text" as const, text })),
metadata: {
status: output.status,
truncated: output.truncated,
...(output.exit !== undefined ? { exit: output.exit } : {}),
...(output.shellID !== undefined ? { shellID: output.shellID } : {}),
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
},
}
}
export const Plugin = {
@@ -92,32 +108,50 @@ export const Plugin = {
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
sessionID: SessionSchema.ID,
id: string,
shellID: string,
command: string,
settled: Deferred.Deferred<Output>,
) {
yield* runtime.job.wait({ id: id }).pipe(
Effect.flatMap((result) => {
const state =
result.info?.status === "completed"
? "completed"
: result.info?.status === "error"
? "error"
: result.info?.status === "cancelled"
? "cancelled"
: undefined
if (state === undefined) return Effect.void
const text =
state === "completed"
? (result.info!.output ?? "")
Effect.flatMap((result) =>
Effect.gen(function* () {
const info = result.info
if (!info) return
const state =
info.status === "completed"
? "completed"
: info.status === "error"
? "error"
: info.status === "cancelled"
? "cancelled"
: undefined
if (state === undefined) return
const output = state === "completed" ? yield* Deferred.await(settled) : undefined
const text = output
? resultMessages(output).join("\n\n")
: state === "error"
? (result.info!.error ?? "Command failed")
? (info.error ?? "Command failed")
: "Command cancelled"
return runtime.session.synthetic({
sessionID,
text: `<shell id="${id}" state="${state}" command="${command}">\n${text}\n</shell>`,
description: command,
metadata: { source: "shell", jobID: id, state },
})
}),
yield* runtime.session.synthetic({
sessionID,
text: `<shell id="${id}" state="${state}" command="${command}">\n${text}\n</shell>`,
description: command,
metadata: {
source: "shell",
jobID: id,
shellID,
state,
...(output
? {
truncated: output.truncated,
...(output.exit !== undefined ? { exit: output.exit } : {}),
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
}
: {}),
},
})
}),
),
Effect.forkIn(scope, { startImmediately: true }),
)
})
@@ -268,7 +302,7 @@ export const Plugin = {
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.id, info.command)
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
@@ -282,7 +316,7 @@ export const Plugin = {
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.id, info.command)
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
@@ -296,22 +330,7 @@ export const Plugin = {
return yield* Deferred.await(settled)
}).pipe(
Effect.map((output) => {
const content: Array<Content> = [{ type: "text", text: output.output }]
const model = modelOutput(output)
if (model) content.push({ type: "text", text: model })
return {
output,
content,
metadata: {
status: output.status,
truncated: output.truncated,
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
...("timeout" in output && output.timeout !== undefined ? { timeout: output.timeout } : {}),
},
}
}),
Effect.map(toolResult),
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
),
+14 -1
View File
@@ -13,7 +13,20 @@ export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool.Context) =>
Effect.gen(function* () {
const decoded = yield* decodeInput(tool.input, input)
const result = yield* tool.execute(decoded, context)
// Tool implementations declare `Tool.Error` but plugins can fail with anything at
// runtime. A foreign typed failure would slip past every `catchTag("Tool.Error")`
// downstream and leave its call permanently unsettled, so the declared contract is
// enforced here at the untrusted boundary. Declines tunnel through as defects and
// interrupts are not errors; neither is touched.
const result = yield* tool.execute(decoded, context).pipe(
Effect.mapError((error: unknown) =>
error instanceof Tool.Error
? error
: new Tool.Error({
message: error instanceof globalThis.Error ? error.message : String(error),
}),
),
)
if (tool.output === undefined) {
if ("output" in result) return yield* Effect.die("Tool result declared output without an output schema")
return {
@@ -17,6 +17,7 @@ import previousV2Migration from "@opencode-ai/core/database/migration/2026080423
import workspaceMigration from "@opencode-ai/core/database/migration/20260808023530_workspace_domain"
import executionClaimsMigration from "@opencode-ai/core/database/migration/20260811161259_execution_claim_attempts"
import sessionInboxMigration from "@opencode-ai/core/database/migration/20260812181746_session_inbox"
import sessionViewedStateMigration from "@opencode-ai/core/database/migration/20260819222447_session_viewed_state"
import { Global } from "@opencode-ai/util/global"
const run = <A, E>(
@@ -77,6 +78,28 @@ describe("DatabaseMigration", () => {
)
})
test("adds nullable attention state to existing sessions", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session_v2 (id text PRIMARY KEY, title text)`)
yield* db.run(sql`INSERT INTO session_v2 (id, title) VALUES ('ses_existing', 'Existing')`)
yield* DatabaseMigration.applyOnly(db, [sessionViewedStateMigration])
yield* DatabaseMigration.applyOnly(db, [sessionViewedStateMigration])
expect(yield* db.get(sql`SELECT id, title, time_idle, time_viewed, idle_outcome FROM session_v2`)).toEqual({
id: "ses_existing",
title: "Existing",
time_idle: null,
time_viewed: null,
idle_outcome: null,
})
expect(yield* db.get(sql`SELECT count(*) AS count FROM migration`)).toEqual({ count: 1 })
}),
)
})
test("rejects a non-empty database without a session table", async () => {
await expect(
run(
+110 -29
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { LLMClient, LLMEvent, LanguageModel, type LLMRequest } from "@opencode-ai/ai"
import { LLMClient, LLMEvent, LanguageModel, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -19,6 +19,7 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { App } from "@opencode-ai/core/app"
import { Agent } from "@opencode-ai/core/agent"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
@@ -76,7 +77,14 @@ const models = Layer.mock(SessionRunnerModel.Service)({
})
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, SessionCompaction.node]),
LayerNode.group([
Database.node,
Bus.node,
SessionProjector.node,
SessionStore.node,
PluginHooks.node,
SessionCompaction.node,
]),
[
[Bus.node, Bus.configured({ persist: true })],
[llmClient, client],
@@ -110,6 +118,13 @@ test("compaction describes tool media without embedding base64", () => {
expect(serialized).not.toContain(base64)
})
test("compaction truncation does not split surrogate pairs", () => {
const prefix = "a".repeat(1_999)
expect(SessionCompaction.truncateToolOutput(`${prefix}😀suffix`)).toBe(`${prefix}😀\n[truncated]`)
expect(SessionCompaction.truncateToolOutput("😀".repeat(2_000))).toBe("😀".repeat(2_000))
})
test("compaction prompt requires the checkpoint headings in order", () => {
const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
@@ -174,6 +189,35 @@ it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
}),
)
/** Seeds the global project plus one session row, returning the projected session. */
const insertSession = (id: Session.ID, overrides?: Partial<typeof SessionTable.$inferInsert>) =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id,
project_id: Project.ID.global,
slug: id,
directory: "/project",
title: id,
version: "test",
...overrides,
})
.run()
.pipe(Effect.orDie)
const store = yield* SessionStore.Service
return yield* store
.get(id)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die(`session missing: ${id}`))))
})
it.effect("manual compaction summarizes short context instead of no-op", () =>
Effect.gen(function* () {
requests = []
@@ -189,33 +233,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
text: "Manual compaction should include this short conversation.",
time: { created: DateTime.makeUnsafe(0) },
}
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
parent_id: parentID,
slug: "manual-compaction",
directory: "/project",
title: "Manual compaction",
version: "test",
})
.run()
.pipe(Effect.orDie)
const session = yield* store
.get(sessionID)
.pipe(
Effect.flatMap((session) =>
session ? Effect.succeed(session) : Effect.die("manual compaction test session missing"),
),
)
const session = yield* insertSession(sessionID, { parent_id: parentID })
const delta = yield* bus
.subscribe(SessionEvent.Compaction.Delta)
@@ -265,3 +283,66 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
])
}),
)
it.effect("forked session compaction reuses the fork root prompt cache key", () =>
Effect.gen(function* () {
requests = []
const compaction = yield* SessionCompaction.Service
const sessionID = Session.ID.make("ses_fork_compaction")
const rootID = Session.ID.make("ses_fork_compaction_root")
const session = yield* insertSession(sessionID, {
fork_session_id: rootID,
fork_boundary: { type: "before", messageID: SessionMessage.ID.create() },
})
expect(
yield* compaction.compactManual({
session,
messages: [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Summarize the forked conversation.",
time: { created: DateTime.makeUnsafe(0) },
},
],
inputID: SessionMessage.ID.make("msg_fork_compaction"),
}),
).toEqual({ status: "completed" })
expect(requests).toHaveLength(1)
expect(requests[0]?.promptCacheKey).toBe(rootID)
}),
)
it.effect("keeps session context hooks away from compaction requests", () =>
Effect.gen(function* () {
requests = []
const compaction = yield* SessionCompaction.Service
// Context hooks shape the agent conversation; compaction is not part of it,
// so it opts out and the transcript passes through unchanged.
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
event.system.push(SystemPart.make("Injected conversation context"))
}),
)
const session = yield* insertSession(Session.ID.make("ses_hook_compaction"))
expect(
yield* compaction.compactManual({
session,
messages: [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Summarize this conversation.",
time: { created: DateTime.makeUnsafe(0) },
},
],
inputID: SessionMessage.ID.make("msg_hook_compaction"),
}),
).toEqual({ status: "completed" })
expect(requests).toHaveLength(1)
expect(requests[0]?.system).toEqual([])
}),
)
+16 -3
View File
@@ -845,7 +845,15 @@ describe("SessionTransfer", () => {
const imported = yield* transfer.import({
data: {
info: { ...template, id: sessionID },
info: {
...template,
id: sessionID,
time: {
...template.time,
idle: DateTime.makeUnsafe(200),
viewed: DateTime.makeUnsafe(150),
},
},
messages: [
{
id: sourceMessageID,
@@ -868,13 +876,18 @@ describe("SessionTransfer", () => {
const messages = yield* session.messages({ sessionID, order: "asc" })
expect(imported).toMatchObject({ id: sessionID, title: "Exported", location })
expect(imported.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
expect(messages).toMatchObject([
{ id: sourceMessageID, type: "user", text: "Imported message" },
{ id: errorMessageID, type: "compaction", error: { type: "test_error", message: "Original error" } },
])
expect(yield* Bus.latestSequence(db, sessionID)).toBe(2)
expect((yield* transfer.export({ sessionID })).messages).toEqual(messages)
expect((yield* transfer.export({ sessionID, sanitize: true })).messages).toMatchObject([
const exported = yield* transfer.export({ sessionID })
expect(exported.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
expect(exported.messages).toEqual(messages)
const sanitized = yield* transfer.export({ sessionID, sanitize: true })
expect(sanitized.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
expect(sanitized.messages).toMatchObject([
{ id: sourceMessageID, text: `[redacted:text:${sourceMessageID}]` },
{ id: errorMessageID, error: { type: "test_error", message: "Original error" } },
])
+46 -2
View File
@@ -1,5 +1,5 @@
import { expect } from "bun:test"
import { LLMClient, LLMEvent, LanguageModel, type LLMRequest } from "@opencode-ai/ai"
import { LLMClient, LLMEvent, LanguageModel, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Agent } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
@@ -14,6 +14,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionTitle } from "@opencode-ai/core/session/title"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Session } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
@@ -78,7 +79,15 @@ const models = Layer.mock(SessionRunnerModel.Service)({
})
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Agent.node, SessionTitle.node]),
LayerNode.group([
Database.node,
Bus.node,
SessionProjector.node,
SessionStore.node,
Agent.node,
PluginHooks.node,
SessionTitle.node,
]),
[
[llmClient, client],
[SessionRunnerModel.node, models],
@@ -155,6 +164,9 @@ it.effect("generates a title from the sole user message and renames the session"
"x-opencode-session": sessionID,
"x-opencode-client": "opencode",
})
expect(requests[0]?.promptCacheKey).toBe(sessionID)
expect(requests[0]?.tools).toEqual([])
expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator."])
expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build")
const renamed = yield* store.get(sessionID)
expect(renamed?.title).toBe("Generated Title")
@@ -323,6 +335,38 @@ it.effect("retries after a failed title request", () =>
}),
)
it.effect("keeps session context hooks away from title requests", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
// Context hooks shape the agent conversation; title generation is not part of
// it, so it opts out and the transcript passes through unchanged.
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
event.system.push(SystemPart.make("Keep titles in sentence case."))
}),
)
const sessionID = Session.ID.make("ses_title_context_hook")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Hook this title request")
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(sessionID)
expect(requests).toHaveLength(1)
expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator."])
}),
)
it.effect("preserves a manual rename completed while generation is in flight", () =>
Effect.gen(function* () {
requests = []
+210
View File
@@ -0,0 +1,210 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { DateTime, Effect, Layer } from "effect"
import { asc, eq } from "drizzle-orm"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { globalProjectLayer } from "./lib/project"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[Project.node, globalProjectLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
describe("Session.view", () => {
it.effect("copies the latest idle time without changing session recency", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const created = yield* session.create({ location })
expect(created.time.idle).toBeUndefined()
expect(created.time.viewed).toBeUndefined()
expect(created.outcome).toBeUndefined()
yield* session.view({ sessionID: created.id })
expect((yield* session.get(created.id)).time.viewed).toBeUndefined()
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
const idle = yield* session.get(created.id)
expect(idle.time.idle).toBeDefined()
expect(idle.time.viewed).toBeUndefined()
expect(idle.time.updated).toEqual(created.time.updated)
expect(idle.outcome).toBe("succeeded")
yield* session.view({ sessionID: created.id })
const viewed = yield* session.get(created.id)
if (!viewed.time.idle || !viewed.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
expect(viewed.time.viewed).toEqual(viewed.time.idle)
expect(viewed.time.updated).toEqual(created.time.updated)
expect(
yield* db
.select({ idle: SessionTable.time_idle, viewed: SessionTable.time_viewed })
.from(SessionTable)
.where(eq(SessionTable.id, created.id))
.get(),
).toEqual({
idle: DateTime.toEpochMillis(viewed.time.idle),
viewed: DateTime.toEpochMillis(viewed.time.viewed),
})
expect((yield* session.list()).data.find((item) => item.id === created.id)?.time).toEqual(viewed.time)
yield* session.view({ sessionID: created.id })
expect((yield* session.get(created.id)).time).toEqual(viewed.time)
yield* bus.publish(SessionEvent.Execution.Failed, {
sessionID: created.id,
error: { type: "unknown", message: "failed" },
})
const unread = yield* session.get(created.id)
if (!unread.time.idle || !unread.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
expect(DateTime.toEpochMillis(unread.time.idle)).toBeGreaterThan(DateTime.toEpochMillis(unread.time.viewed))
expect(unread.outcome).toBe("failed")
yield* session.view({ sessionID: created.id })
expect((yield* session.get(created.id)).time.viewed).toEqual(unread.time.idle)
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID: created.id, reason: "shutdown" })
expect((yield* session.get(created.id)).time.idle).toEqual(unread.time.idle)
expect((yield* session.get(created.id)).outcome).toBe("failed")
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID: created.id, reason: "user" })
const interrupted = yield* session.get(created.id)
if (!interrupted.time.idle || !interrupted.time.viewed)
return yield* Effect.die(new Error("Expected attention times"))
expect(DateTime.toEpochMillis(interrupted.time.idle)).toBeGreaterThan(
DateTime.toEpochMillis(interrupted.time.viewed),
)
expect(interrupted.outcome).toBe("interrupted")
expect(
(yield* db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, created.id))
.all()).filter((event) => event.type === Bus.versionedType(SessionEvent.Viewed.type, 1)),
).toHaveLength(2)
}),
)
it.effect("keeps a newer completion unread when the viewed watermark is stale", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location })
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
const observed = (yield* session.get(created.id)).time.idle
if (!observed) return yield* Effect.die(new Error("Expected idle time"))
// A failure commits between the viewer's observation and the viewed event.
yield* bus.publish(SessionEvent.Execution.Failed, {
sessionID: created.id,
error: { type: "unknown", message: "failed" },
})
yield* bus.publish(SessionEvent.Viewed, { sessionID: created.id, idle: DateTime.toEpochMillis(observed) })
const stale = yield* session.get(created.id)
if (!stale.time.idle || !stale.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
expect(stale.time.viewed).toEqual(observed)
expect(DateTime.toEpochMillis(stale.time.idle)).toBeGreaterThan(DateTime.toEpochMillis(stale.time.viewed))
// A duplicate stale watermark never regresses a newer acknowledgement.
yield* session.view({ sessionID: created.id })
const acked = yield* session.get(created.id)
expect(acked.time.viewed).toEqual(acked.time.idle)
yield* bus.publish(SessionEvent.Viewed, { sessionID: created.id, idle: DateTime.toEpochMillis(observed) })
expect((yield* session.get(created.id)).time.viewed).toEqual(acked.time.viewed)
}),
)
it.effect("rejects an unknown session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const sessionID = Session.ID.make("ses_missing_view")
expect(yield* Effect.flip(session.view({ sessionID }))).toEqual(new Session.NotFoundError({ sessionID }))
}),
)
it.effect("replays viewed state into a fresh database", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const sourceDb = (yield* Database.Service).db
const created = yield* session.create({ id: Session.ID.make("ses_view_replay"), location })
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
yield* session.view({ sessionID: created.id })
yield* bus.publish(SessionEvent.Execution.Failed, {
sessionID: created.id,
error: { type: "unknown", message: "failed" },
})
const expected = yield* session.get(created.id)
if (!expected.time.idle || !expected.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
const expectedIdle = DateTime.toEpochMillis(expected.time.idle)
const expectedViewed = DateTime.toEpochMillis(expected.time.viewed)
const serialized = (yield* sourceDb
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, created.id))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)).map((event) => ({
id: event.id,
created: event.created,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
}))
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const targetLayer = AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
[
[Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })],
[Bus.node, Bus.configured({ persist: true })],
],
)
yield* Effect.gen(function* () {
const db = (yield* Database.Service).db
const targetBus = yield* Bus.Service
const store = yield* SessionStore.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: location.directory, sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* Effect.forEach(serialized, (event) => targetBus.replay(event), { discard: true })
const replayed = yield* store.get(created.id)
expect(replayed?.time).toEqual(expected.time)
expect(replayed?.outcome).toBe("failed")
expect(expected.time.updated).toEqual(created.time.updated)
expect(expectedIdle).toBeGreaterThan(expectedViewed)
}).pipe(Effect.provide(Layer.fresh(targetLayer)))
}),
)
})
+18
View File
@@ -94,6 +94,24 @@ test("declared outputs cannot bypass validation and raw outputs stay JSON-compat
)
})
test("foreign typed failures settle as Tool.Error at the untrusted boundary", async () => {
class ForeignFailure extends Schema.TaggedError<ForeignFailure>()("Plugin.ForeignFailure", {
message: Schema.String,
}) {}
const lying: Info = {
name: "lying",
description: "Fails with a non-Tool.Error typed failure",
input: Schema.Struct({}),
execute: () => new ForeignFailure({ message: "transport died" }) as never,
}
const exit = await Effect.runPromiseExit(execute(lying, {}, context))
expect(exit._tag).toBe("Failure")
const error = exit._tag === "Failure" ? exit.cause.reasons.find((reason) => "error" in reason)?.error : undefined
expect(error).toBeInstanceOf(Tool.Error)
expect((error as Tool.Error).message).toBe("transport died")
})
test("execute supports callable namespace tools", async () => {
const callable: Info = {
name: "admin",
+42
View File
@@ -760,10 +760,52 @@ describe("ShellTool", () => {
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
expect((yield* shell.wait(id)).status).toBe("timeout")
expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.item.payload).toMatchObject({
text: expect.stringContaining("Command timed out before completion."),
description: idleCommand,
metadata: {
source: "shell",
shellID,
state: "completed",
timeout: true,
truncated: false,
},
})
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("preserves a background command's non-zero exit", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const admitted = yield* bus.subscribe(SessionEvent.InboxEnqueued).pipe(
Stream.filter((event) => event.data.sessionID === sessionID && event.data.item.type === "synthetic"),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
const settled = yield* executeTool(
registry,
call({ command: bodyExitCommand, background: true }, "call-background-nonzero"),
)
const shellID = settled.metadata?.shellID
expect(typeof shellID).toBe("string")
expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.item.payload).toMatchObject({
text: expect.stringContaining("Command exited with code 7."),
description: bodyExitCommand,
metadata: {
source: "shell",
jobID: "call-background-nonzero",
shellID,
state: "completed",
exit: 7,
truncated: false,
},
})
}),
+3
View File
@@ -60,6 +60,9 @@ const session = (
model: null,
time_created: 1,
time_updated: 2,
time_idle: null,
time_viewed: null,
idle_outcome: null,
time_compacting: 3,
time_archived: null,
time_suspended: null,
@@ -31,8 +31,11 @@ interface PendingRecordings {
}
type Frame = string | Uint8Array
const normalizeProtocols = (protocols?: string | Array<string>): Array<string> =>
protocols === undefined ? [] : typeof protocols === "string" ? [protocols] : [...protocols]
const normalizeProtocols = (protocols: unknown): Array<string> => {
if (typeof protocols === "string") return [protocols]
if (Array.isArray(protocols)) return protocols.filter((protocol): protocol is string => typeof protocol === "string")
return []
}
const frameFromWebSocketData = async (data: unknown): Promise<Frame> => {
if (typeof data === "string") return data
if (data instanceof Blob) return new Uint8Array(await data.arrayBuffer())
@@ -371,7 +374,7 @@ const makeRecordingWebSocketConstructor = (
return (url, protocols) => {
const sequence = nextSequence++
const requestedProtocols = normalizeProtocols(protocols)
const native = upstream(url, requestedProtocols)
const native = Reflect.apply(upstream, undefined, [url, protocols])
const events: WebSocketEvent[] = []
let opened = false
let failed = false
@@ -80,6 +80,38 @@ describe("WebSocket", () => {
])
})
test("constructor recording forwards handshake options", async () => {
using directory = tempDirectory("http-recorder-websocket-constructor-")
let received: unknown
const recorder = HttpRecorder.layerWebSocketConstructor("websocket/constructor-options", {
directory: directory.path,
}).pipe(
Layer.provide(
Layer.succeed(Socket.WebSocketConstructor, (url, options) => {
received = options
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- the fixture implements the WebSocket surface used by the recorder.
return new EchoWebSocket(url) as unknown as globalThis.WebSocket
}),
),
)
await Effect.runPromise(
Effect.gen(function* () {
const constructor = yield* Socket.WebSocketConstructor
const options = { headers: { authorization: "Bearer fixture" } }
const socket = Reflect.apply(constructor, undefined, ["wss://echo.example.test/options", options])
yield* Effect.callback<void>((resume) => {
socket.addEventListener("open", () => {
socket.close()
resume(Effect.void)
})
})
}).pipe(Effect.scoped, Effect.provide(recorder)),
)
expect(received).toEqual({ headers: { authorization: "Bearer fixture" } })
})
test("constructor replay validates dynamic URLs and protocols without opening a live socket", async () => {
using directory = tempDirectory("http-recorder-websocket-constructor-")
await seedCassetteDirectory(directory.path, "websocket/constructor", [
+4
View File
@@ -0,0 +1,4 @@
.astro/
dist/
node_modules/
.wrangler/
+16
View File
@@ -0,0 +1,16 @@
import cloudflare from "@astrojs/cloudflare"
import mdx from "@astrojs/mdx"
import { defineConfig } from "astro/config"
export default defineConfig({
site: "https://opencode.ai",
base: "/posts",
output: "server",
adapter: cloudflare({ imageService: "passthrough" }),
integrations: [mdx()],
vite: {
server: {
allowedHosts: true,
},
},
})
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/posts",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build && bun script/prepare-cloudflare.ts",
"deploy": "wrangler deploy --config dist/server/wrangler.json",
"typecheck": "astro check"
},
"dependencies": {
"@astrojs/mdx": "7.0.5",
"@fontsource/commit-mono": "5.3.0",
"astro": "7.1.3"
},
"devDependencies": {
"@astrojs/cloudflare": "14.1.4",
"@astrojs/check": "0.9.6",
"@types/bun": "catalog:",
"typescript": "catalog:",
"wrangler": "4.110.0"
}
}
+10
View File
@@ -0,0 +1,10 @@
<svg width="641" height="115" viewBox="0 0 641 115" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M49.2857 32.8573H16.4286V82.143H49.2857V32.8573ZM65.7143 98.5716H0V16.4287H65.7143V98.5716Z" fill="white"/>
<path d="M98.5692 82.143H131.426V32.8573H98.5692V82.143ZM147.855 98.5716H98.5692V115H82.1406V16.4287H147.855V98.5716Z" fill="white"/>
<path d="M230.003 65.7144H180.718V82.143H230.003V98.5716H164.289V16.4287H230.003V65.7144ZM180.718 49.2859H213.575V32.8573H180.718V49.2859Z" fill="white"/>
<path d="M295.715 32.8573H262.858V98.5716H246.43V16.4287H295.715V32.8573ZM312.144 98.5716H295.715V32.8573H312.144V98.5716Z" fill="white"/>
<path d="M394.285 32.8573H344.999V82.143H394.285V98.5716H328.57V16.4287H394.285V32.8573Z" fill="white"/>
<path d="M459.997 32.8573H427.14V82.143H459.997V32.8573ZM476.425 98.5716H410.711V16.4287H476.425V98.5716Z" fill="white"/>
<path d="M542.145 32.8571H509.288V82.1429H542.145V32.8571ZM558.574 98.5714H492.859V16.4286H542.145V0H558.574V98.5714Z" fill="white"/>
<path d="M591.429 32.8573V49.2859H624.286V32.8573H591.429ZM640.714 65.7144H591.429V82.143H640.714V98.5716H575V16.4287H640.714V65.7144Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,10 @@
export {}
const path = "dist/server/wrangler.json"
const config = await Bun.file(path).json()
delete config.kv_namespaces
delete config.images
delete config.previews
await Bun.write(path, JSON.stringify(config))
@@ -0,0 +1,45 @@
<div class="counter" data-counter>
<span>Clicks</span>
<output>0</output>
<button type="button">Increment</button>
</div>
<script>
document.querySelectorAll<HTMLElement>("[data-counter]").forEach((counter) => {
const output = counter.querySelector("output")
counter.querySelector("button")?.addEventListener("click", () => {
if (output) output.value = String(Number(output.value) + 1)
})
})
</script>
<style>
.counter {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 0.75rem 1rem;
margin-block: 2.5rem;
padding: 1rem;
border: 1px solid var(--border);
background: var(--code);
}
output {
color: var(--muted);
}
button {
grid-column: 1 / -1;
padding: 0.65rem 1rem;
border: 0;
color: var(--background);
background: var(--foreground);
font: inherit;
cursor: pointer;
}
button:hover {
opacity: 0.85;
}
</style>
+14
View File
@@ -0,0 +1,14 @@
import { defineCollection } from "astro:content"
import { glob } from "astro/loaders"
import { z } from "astro/zod"
const posts = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/posts" }),
schema: z.object({
title: z.string(),
description: z.string(),
published: z.coerce.date(),
}),
})
export const collections = { posts }
@@ -0,0 +1,17 @@
---
title: "Example one"
description: "Markdown for the words, components for everything else."
published: 2026-08-20
---
import Counter from "../../components/Counter.astro"
This is a post written in MDX. Most of it is ordinary Markdown, rendered to static HTML with no client-side JavaScript.
## Interactive when it matters
Components can appear directly between paragraphs. This one is an Astro component with a tiny browser script:
<Counter />
The component could also come from another workspace package or use React, Solid, Svelte, or Vue when the interaction calls for it.
@@ -0,0 +1,9 @@
---
title: "Lorem ipsum"
description: "A closer look at what happens between a prompt and a result."
published: 2026-08-07
---
An agent alternates between reasoning, acting, and observing. The useful part is not any single step, but how quickly the loop incorporates new information.
Interactive explanations let us inspect that process instead of reducing it to a static diagram.
@@ -0,0 +1,9 @@
---
title: "Example two"
description: "The best interface gets out of the way of the work."
published: 2026-08-14
---
Software should make difficult work feel direct. Every control has to earn the attention it asks for.
The goal is not fewer capabilities. It is less distance between intent and result.
+32
View File
@@ -0,0 +1,32 @@
---
import "@fontsource/commit-mono/400.css"
import "@fontsource/commit-mono/500.css"
import "../styles/global.css"
interface Props {
title?: string
description?: string
}
const title = Astro.props.title ? `${Astro.props.title} | OpenCode Posts` : "OpenCode Posts"
const description = Astro.props.description ?? "Notes from the OpenCode team."
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<meta name="description" content={description} />
<meta name="theme-color" content="#131010" />
<title>{title}</title>
</head>
<body>
<main>
<a class="site-logo" href="https://opencode.ai/" aria-label="OpenCode home">
<img src={`${import.meta.env.BASE_URL}/logo-dark.svg`} alt="OpenCode" />
</a>
<slot />
</main>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
---
import { getCollection, render } from "astro:content"
import Layout from "../layouts/Layout.astro"
export const prerender = true
export async function getStaticPaths() {
return (await getCollection("posts")).map((post) => ({ params: { slug: post.id }, props: { post } }))
}
const post = Astro.props.post
const rendered = await render(post)
const Content = rendered.Content
---
<Layout title={post.data.title} description={post.data.description}>
<article>
<div class="post-header">
<time datetime={post.data.published.toISOString()}>
{post.data.published.toLocaleDateString("en", { month: "long", day: "numeric", year: "numeric" })}
</time>
<h1>{post.data.title}</h1>
<p class="description">{post.data.description}</p>
</div>
<Content />
</article>
</Layout>
+25
View File
@@ -0,0 +1,25 @@
---
import { getCollection } from "astro:content"
import Layout from "../layouts/Layout.astro"
export const prerender = true
const posts = (await getCollection("posts")).sort(
(a, b) => b.data.published.valueOf() - a.data.published.valueOf(),
)
---
<Layout>
<ol class="post-list">
{posts.map((post) => (
<li>
<a href={`${import.meta.env.BASE_URL}/${post.id}/`}>
<span>{post.data.title}</span>
<time datetime={post.data.published.toISOString()}>
{post.data.published.toLocaleDateString("en", { month: "short", day: "numeric", year: "numeric" })}
</time>
</a>
</li>
))}
</ol>
</Layout>
+139
View File
@@ -0,0 +1,139 @@
:root {
color-scheme: dark;
font-family: "Commit Mono", ui-monospace, monospace;
color: var(--foreground);
background: var(--background);
font-synthesis: none;
--foreground: #f1efe8;
--background: #131010;
--muted: rgb(241 239 232 / 45%);
--border: rgb(241 239 232 / 12%);
--code: rgb(255 255 255 / 6%);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: var(--background);
font-size: 18px;
line-height: 1.75;
letter-spacing: 0.02em;
}
main {
width: min(100% - 2rem, 672px);
margin-inline: auto;
padding-block: 4rem;
}
.site-logo {
display: block;
width: 8.75rem;
margin-bottom: 5rem;
}
.site-logo img {
display: block;
width: 100%;
height: auto;
}
h1,
h2 {
font-weight: 500;
letter-spacing: 0;
}
h1 {
margin: 0 0 1rem;
color: #fff;
font-size: clamp(2rem, 8vw, 3rem);
line-height: 1.15;
}
h2 {
margin: 3rem 0 1rem;
font-size: 1.25rem;
}
p {
margin-block: 0 1.25rem;
}
a {
color: inherit;
text-underline-offset: 0.2em;
}
time,
.description {
color: var(--muted);
}
.description {
font-size: 1.05rem;
}
.post-header {
margin-bottom: 4rem;
}
.post-header time {
display: block;
margin-bottom: 1rem;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.post-list {
margin: 0;
padding: 0;
list-style: none;
}
.post-list a {
display: grid;
grid-template-columns: 1fr auto;
align-items: baseline;
gap: 1rem;
margin-inline: -1.25rem;
padding: 1.1rem 1.25rem;
text-decoration: none;
transition: background-color 120ms ease;
}
.post-list a:hover {
background: rgb(255 255 255 / 4%);
}
.post-list span:first-child {
color: #fff;
font-size: 1.125rem;
font-weight: 500;
}
.post-list time {
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
code {
padding: 0.15em 0.35em;
background: var(--code);
font: inherit;
font-size: 0.9em;
}
@media (max-width: 480px) {
.post-list a {
grid-template-columns: 1fr;
gap: 0.25rem;
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"types": ["bun"]
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "opencode-posts",
"compatibility_date": "2026-07-25",
"workers_dev": false,
"preview_urls": false,
"routes": [
{
"pattern": "opencode.ai/posts*",
"zone_name": "opencode.ai"
}
]
}
+180 -14
View File
@@ -362,7 +362,7 @@
}
}
},
"description": "Retrieve currently loaded plugins.",
"description": "Retrieve enabled server plugins and their current status.",
"summary": "List plugins"
}
},
@@ -3547,7 +3547,7 @@
}
}
},
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.",
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input and next-in-line control items (manual compaction, moves) while queued prompts remain parked.",
"summary": "Interrupt session execution"
}
},
@@ -3789,6 +3789,65 @@
}
}
},
"/api/session/{sessionID}/view": {
"post": {
"tags": ["session"],
"operationId": "v2.session.view",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
},
"required": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
}
},
"description": "Mark the latest recorded idle transition as viewed.",
"summary": "View session"
}
},
"/api/session/{sessionID}/message": {
"get": {
"tags": ["session"],
@@ -9914,15 +9973,106 @@
"required": ["_tag", "agentID", "message"],
"additionalProperties": false
},
"Plugin.Info": {
"type": "object",
"properties": {
"id": {
"type": "string"
"Plugin.Source": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["builtin"]
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["package"]
},
"package": {
"type": "string"
}
},
"required": ["type", "package"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["local"]
},
"path": {
"type": "string"
}
},
"required": ["type", "path"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["sdk"]
}
},
"required": ["type"],
"additionalProperties": false
}
},
"required": ["id"],
"additionalProperties": false
]
},
"Plugin.Info": {
"anyOf": [
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"status": {
"type": "string",
"enum": ["active"]
},
"tui": {
"type": "boolean"
}
},
"required": ["id", "source", "status", "tui"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"status": {
"type": "string",
"enum": ["failed"]
},
"error": {
"type": "string"
},
"tui": {
"type": "boolean"
}
},
"required": ["source", "status", "error", "tui"],
"additionalProperties": false
}
]
},
"Session.ForkBoundary": {
"anyOf": [
@@ -10128,6 +10278,10 @@
"tokens": {
"$ref": "#/components/schemas/TokenUsage.Info"
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
},
"time": {
"type": "object",
"properties": {
@@ -10137,6 +10291,12 @@
"updated": {
"type": "number"
},
"idle": {
"type": "number"
},
"viewed": {
"type": "number"
},
"archived": {
"type": "number"
}
@@ -10480,14 +10640,11 @@
"name": {
"type": "string"
},
"text": {
"type": "string"
},
"mention": {
"$ref": "#/components/schemas/Prompt.Mention"
}
},
"required": ["id", "name", "text"],
"required": ["id", "name"],
"additionalProperties": false
},
"Arrays_3": {
@@ -11003,6 +11160,9 @@
"required": ["type", "id", "name", "state", "time"],
"additionalProperties": false
},
"Session.Message.ProviderState_4": {
"type": "object"
},
"Session.Message.Assistant.Retry": {
"type": "object",
"properties": {
@@ -11099,6 +11259,12 @@
"type": "string",
"enum": ["stop", "length", "tool-calls", "content-filter", "error", "unknown"]
},
"rawFinish": {
"type": "string"
},
"providerState": {
"$ref": "#/components/schemas/Session.Message.ProviderState_4"
},
"cost": {
"$ref": "#/components/schemas/Money.USD"
},
+1
View File
@@ -33,6 +33,7 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"prettier": "3.6.2",
"typescript": "catalog:"
}
}
+2 -1
View File
@@ -1,8 +1,9 @@
import { OpenApi } from "effect/unstable/httpapi"
import { format } from "prettier"
import { fileURLToPath } from "url"
import { ClientApi } from "../src/client.js"
const document = JSON.stringify(OpenApi.fromApi(ClientApi), null, 2) + "\n"
const document = await format(JSON.stringify(OpenApi.fromApi(ClientApi), null, 2), { parser: "json", printWidth: 120 })
const target = fileURLToPath(new URL("../openapi.json", import.meta.url))
if (process.argv.includes("--check")) {
+13
View File
@@ -707,6 +707,19 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.post("session.view", "/api/session/:sessionID/view", {
params: { sessionID: Session.ID },
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.view",
summary: "View session",
description: "Mark the latest recorded idle transition as viewed.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "session",
+12
View File
@@ -105,6 +105,17 @@ export const Renamed = Event.durable({
})
export type Renamed = typeof Renamed.Type
export const Viewed = Event.durable({
type: "session.viewed",
...options,
schema: {
...Base,
/** Epoch-millisecond idle watermark the viewer observed; projection never marks a newer idle transition viewed. */
idle: Schema.Finite,
},
})
export type Viewed = typeof Viewed.Type
export const UsageRecorded = Event.durable({
type: "session.usage.recorded",
...options,
@@ -585,6 +596,7 @@ export const Definitions = Event.inventory(
ModelSelected,
Moved,
Renamed,
Viewed,
UsageUpdated,
Deleted,
Forked,
+4
View File
@@ -37,9 +37,13 @@ export const Info = Schema.Struct({
model: Model.Ref.pipe(optional),
cost: Money.USD,
tokens: TokenUsage.Info,
/** Outcome of the last completed execution, recorded at `time.idle`. Absent until a run reaches a terminal transition. */
outcome: Schema.Literals(["succeeded", "failed", "interrupted"]).pipe(optional),
time: Schema.Struct({
created: DateTimeUtcFromMillis,
updated: DateTimeUtcFromMillis,
idle: DateTimeUtcFromMillis.pipe(optional),
viewed: DateTimeUtcFromMillis.pipe(optional),
archived: DateTimeUtcFromMillis.pipe(optional),
}),
title: Schema.String.pipe(optional),
+21 -9
View File
@@ -54,17 +54,29 @@ describe("contract hygiene", () => {
}),
).toEqual({ text: "completed" })
const info = Session.Info.make({
id: Session.ID.make("ses_untitled"),
projectID: Project.ID.make("global"),
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: {
created: DateTime.makeUnsafe(0),
updated: DateTime.makeUnsafe(0),
idle: undefined,
viewed: undefined,
},
title: undefined,
location: { directory: AbsolutePath.make("/project") },
})
const encoded = Schema.encodeSync(Session.Info)(info)
expect(encoded).not.toHaveProperty("title")
expect(encoded.time).toEqual({ created: 0, updated: 0 })
expect(
Schema.encodeSync(Session.Info)({
id: Session.ID.make("ses_untitled"),
projectID: Project.ID.make("global"),
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
title: undefined,
location: { directory: AbsolutePath.make("/project") },
}),
).not.toHaveProperty("title")
...info,
time: { ...info.time, idle: DateTime.makeUnsafe(2), viewed: DateTime.makeUnsafe(1) },
}).time,
).toEqual({ created: 0, updated: 0, idle: 2, viewed: 1 })
})
test("session inbox items omit the internal enqueue sequence", () => {
@@ -83,6 +83,7 @@ describe("public event manifest", () => {
"session.model.selected.1",
"session.moved.1",
"session.renamed.1",
"session.viewed.1",
"session.usage.recorded.1",
"session.forked.2",
"session.inbox.delivered.1",
+16
View File
@@ -180,6 +180,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.view",
Effect.fn(function* (ctx) {
yield* session.view({ sessionID: ctx.params.sessionID }).pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.remove",
Effect.fn(function* (ctx) {
+5 -5
View File
@@ -3,7 +3,7 @@ export * as ServerProcess from "./process"
import { NodeHttpServer } from "@effect/platform-node"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Scope } from "effect"
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { createServer } from "node:http"
import { ServerAuth } from "./auth"
@@ -47,7 +47,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const hostname = options.hostname ?? "127.0.0.1"
const port = Option.fromNullishOr(options.port)
const shutdown = yield* Deferred.make<void>()
const shutdown = yield* Latch.make()
const status = yield* Status.make()
const bound = yield* listen({ hostname, port })
const application = yield* Ref.make(Option.none<App>())
@@ -61,7 +61,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
)
.pipe(withoutParentSpan)
if (lifecycle)
yield* lifecycle.onListen(bound.http.address, Deferred.succeed(shutdown, undefined).pipe(Effect.asVoid)).pipe(
yield* lifecycle.onListen(bound.http.address, shutdown.open.pipe(Effect.asVoid)).pipe(
Effect.flatMap((cleanup) =>
Effect.addFinalizer(() => Scope.close(bound.scope, Exit.void).pipe(Effect.andThen(cleanup))),
),
@@ -101,7 +101,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
const app = Context.get(context, HttpRouter.HttpRouter).asHttpEffect()
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
yield* status.ready
return { address: bound.http.address, shutdown: Deferred.await(shutdown) }
return { address: bound.http.address, shutdown: shutdown.await }
}).pipe(
Effect.catchCause((cause) => {
if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
@@ -119,7 +119,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
}),
)
if (!lifecycle) return yield* boot
return yield* Effect.raceFirst(boot, Deferred.await(shutdown).pipe(Effect.andThen(Effect.interrupt)))
return yield* Effect.raceFirst(boot, shutdown.await.pipe(Effect.andThen(Effect.interrupt)))
})
function listen(options: { readonly hostname: string; readonly port: Option.Option<number> }) {
+30
View File
@@ -52,6 +52,36 @@ it.live("serves unauthenticated and answers CORS preflight when no password is c
}).pipe(Effect.scoped),
)
it.live("serves the session view operation and missing-session error", () =>
Effect.gen(function* () {
const handler = yield* ServerFetch.make(options)
const created = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
}),
).then((response) => response.json()),
)
if (typeof created !== "object" || created === null || !("data" in created))
return yield* Effect.die(new Error("Expected a session response"))
const data = created.data
if (typeof data !== "object" || data === null || !("id" in data) || typeof data.id !== "string")
return yield* Effect.die(new Error("Expected a session ID"))
const viewed = yield* Effect.promise(() =>
handler(new Request(`http://opencode.local/api/session/${data.id}/view`, { method: "POST" })),
)
expect(viewed.status).toBe(204)
const missing = yield* Effect.promise(() =>
handler(new Request("http://opencode.local/api/session/ses_missing_view/view", { method: "POST" })),
)
expect(missing.status).toBe(404)
}).pipe(Effect.scoped),
)
// Pins the eager-boot guarantee: the application layer is built before the handler returns, so
// an aborted first request cannot interrupt layer construction and wedge every later request
// (the Effect-TS/effect#6319 failure class that lazy first-request builds are prone to).
+4 -4
View File
@@ -1,6 +1,6 @@
import { render, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { registerOpencodeSpinner } from "./component/register-spinner"
import { Deferred, Effect } from "effect"
import { Effect, Latch } from "effect"
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { OpenCode, type SessionInfo } from "@opencode-ai/client"
import { Global } from "@opencode-ai/util/global"
@@ -274,13 +274,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
.forEach((result) => log("error", "Failed to dispose TUI resource", { error: result.reason }))
}),
)
const shutdown = yield* Deferred.make<unknown>()
const shutdown = yield* Latch.make()
const onSighup = () => destroyRenderer(renderer)
yield* Effect.acquireRelease(
Effect.sync(() => process.on("SIGHUP", onSighup)),
() => Effect.sync(() => process.off("SIGHUP", onSighup)),
)
renderer.once("destroy", () => Deferred.doneUnsafe(shutdown, Effect.void))
renderer.once("destroy", () => shutdown.openUnsafe())
yield* Effect.tryPromise(async () => {
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
void renderer.getPalette({ size: 16 }).catch(() => undefined)
@@ -443,7 +443,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
renderer.requestRender()
}
})
yield* Deferred.await(shutdown)
yield* shutdown.await
return { epilogue: exit.epilogue, reason: exit.reason }
}),
)
+42 -37
View File
@@ -25,12 +25,12 @@ import {
type ClosedSessionTab,
type SessionTab,
type SessionTabHistory,
type SessionTabUnread,
} from "./session-tabs-model"
type TabsState = {
tabs: SessionTab[]
unread: Record<string, SessionTabUnread>
// Read only long enough to remove the former client-owned state from persisted tab files.
unread?: Record<string, unknown>
}
type PersistedState = {
@@ -43,7 +43,7 @@ type ScrollAnchor = {
screenY: number
}
const empty = (): TabsState => ({ tabs: [], unread: {} })
const empty = (): TabsState => ({ tabs: [] })
// Deliberately after connect settles: the visible session's mount syncs win the first slots.
const TAB_PREFETCH_DELAY = 300
@@ -60,7 +60,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const paths = useTuiPaths()
const renderer = useRenderer()
const enabled = () => config.tabs.enabled
// Focus reporting emits transitions, so an interactive launch owns unread state until its first blur.
// Focus reporting emits transitions, so an interactive launch may acknowledge viewed sessions until its first blur.
const [focused, setFocused] = createSignal(true)
// Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of
// mutating in place, which per-row animations and drag state depend on.
@@ -105,16 +105,20 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const session = data.session.get(sessionID)
return session?.title ?? persisted ?? fallback ?? (session ? withTimestampedFallback(session) : undefined)
}
const isUnread = (sessionID: string) => {
const info = data.session.get(sessionID)
return info?.time.idle !== undefined && (info.time.viewed === undefined || info.time.idle > info.time.viewed)
}
const family = (sessionID: string) => {
const session = root(sessionID)
const members = data.session.family(session)
return members.length > 0 ? members : [session]
}
const normalize = (value: TabsState) => ({
tabs: value.tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
}, []),
unread: Object.entries(value.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
const sessionID = root(entry[0])
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
return result
}, {}),
})
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
const newTab = createMemo((open = false) => {
@@ -125,29 +129,23 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}, false)
const status = (sessionID: string) => {
const session = root(sessionID)
const members = data.session.family(session)
const family = members.length > 0 ? members : [session]
const members = family(session)
const unread = members.filter(isUnread)
return {
unread: state().unread[session],
unread:
unread.length === 0
? undefined
: unread.some((id) => data.session.get(id)?.outcome === "failed")
? ("error" as const)
: ("activity" as const),
promptPulse: promptPulses()[session] ?? 0,
attention: family.some(
attention: members.some(
(id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
),
busy: family.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
busy: members.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
}
}
function markUnread(sessionID: string, unread: SessionTabUnread) {
if (!enabled() || !focused()) return
const session = root(sessionID)
if (current() === session || !state().tabs.some((tab) => tab.sessionID === session)) return
if (state().unread[session] === unread) return
update((draft) => {
if (!draft.tabs.some((tab) => tab.sessionID === session)) return
draft.unread[session] = unread
})
}
createEffect(() => {
if (!enabled()) return
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
@@ -167,14 +165,25 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
})
// Viewed state is server-global, so acknowledgement runs even with tabs disabled: other
// clients rely on this client reporting what its user has seen.
const acknowledged = new Map<string, number>()
createEffect(() => {
if (!enabled() || !focused()) return
if (!focused()) return
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
const sessionID = root(route.data.sessionID)
if (!state().unread[sessionID]) return
update((draft) => {
delete draft.unread[sessionID]
const pending = family(route.data.sessionID).flatMap((id) => {
const idle = data.session.get(id)?.time.idle
if (idle === undefined || !isUnread(id) || acknowledged.get(id) === idle) return []
return [{ id, idle }]
})
if (pending.length === 0) return
// Record before the request so event-driven re-runs don't re-post the same watermark.
for (const entry of pending) acknowledged.set(entry.id, entry.idle)
void Promise.all(
pending.map((entry) =>
client.api.session.view({ sessionID: entry.id }).catch(() => acknowledged.delete(entry.id)),
),
)
})
createEffect(() => {
@@ -184,7 +193,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
update((draft) => {
const next = normalize(draft)
draft.tabs = next.tabs
draft.unread = next.unread
delete draft.unread
})
})
@@ -205,7 +214,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const sessionIDs = signature.split("\n")
let stale = false
void (async () => {
await Promise.allSettled(sessionIDs.map((sessionID) => data.session.sync(sessionID)))
await Promise.allSettled(sessionIDs.map((sessionID) => data.session.sync(sessionID, { children: true })))
if (stale) return
const locations = new Map(
sessionIDs
@@ -239,9 +248,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
})
onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity")))
onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error")))
onCleanup(
event.on("session.moved", (evt) => {
if (!enabled() || !state().tabs.some((tab) => tab.sessionID === root(evt.data.sessionID))) return
@@ -277,7 +283,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
history = previous.history
update((draft) => {
draft.tabs = closeSessionTab(draft.tabs, target).tabs
delete draft.unread[target]
})
setPromptPulses((pulses) => {
if (pulses[target] === undefined) return pulses
@@ -373,7 +378,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
cycleUnread(direction: 1 | -1) {
if (!enabled()) return
const tab = cycleSessionTab(state().tabs, current(), direction, (tab) =>
Boolean(state().unread[tab.sessionID] || status(tab.sessionID).attention),
Boolean(status(tab.sessionID).unread || status(tab.sessionID).attention),
)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
+23 -5
View File
@@ -121,7 +121,14 @@ const TRANSCRIPT_BACKFILL_CHUNK = 60
type PendingAction = "steer" | "queue" | "cancel"
const context = createContext<{
/** Content width: terminal width minus vertical tabs, sidebar, and padding. */
width: number
/**
* Shared reactive terminal size. Transcript-row components must read this
* instead of calling useTerminalDimensions(), which registers one renderer
* resize listener per mounted component and grows with transcript length.
*/
terminal: { width: number; height: number }
sessionID: string
thinkingMode: () => ThinkingMode
showThinking: () => boolean
@@ -1124,12 +1131,25 @@ export function Session(props: { verticalTabsWidth: number }) {
),
)
// Memoized per axis so width readers do not re-run on height-only resizes
// (dimensions() is one object signal with identity equality) and vice versa.
const terminalWidth = createMemo(() => dimensions().width)
const terminalHeight = createMemo(() => dimensions().height)
return (
<context.Provider
value={{
get width() {
return contentWidth()
},
terminal: {
get width() {
return terminalWidth()
},
get height() {
return terminalHeight()
},
},
sessionID: route.sessionID,
thinkingMode,
showThinking,
@@ -1805,7 +1825,6 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
const ctx = use()
const data = useData()
const local = useLocal()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const model = createMemo(
() =>
@@ -1829,10 +1848,10 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
<span style={{ fg: props.message.error ? theme.text.subdued : local.agent.color(props.message.agent) }}>
{Locale.titlecase(props.message.agent)}
</span>
<Show when={dimensions().width >= 28}>
<Show when={ctx.terminal.width >= 28}>
<span style={{ fg: theme.text.subdued }}> · {model()}</span>
</Show>
<Show when={duration() && (dimensions().width < 28 || dimensions().width >= 36)}>
<Show when={duration() && (ctx.terminal.width < 28 || ctx.terminal.width >= 36)}>
<span style={{ fg: theme.text.subdued }}> · {Locale.duration(duration())}</span>
</Show>
<Show when={interrupted()}>
@@ -2521,9 +2540,8 @@ function ToolImages(props: { parts: readonly SessionMessageAssistantTool[] }) {
function SessionImages(props: { images: readonly { uri: string }[]; paddingLeft?: number }) {
const ctx = use()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const images = createMemo(() => (ctx.config.session?.image_preview ? props.images : []))
const height = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
const height = createMemo(() => Math.max(4, Math.min(8, Math.floor(ctx.terminal.height / 4))))
const visible = createMemo(() => images().slice(0, 3))
return (
+4 -3
View File
@@ -2993,15 +2993,16 @@ async function mountData(parents: Record<string, string>, costs: Record<string,
return { data, app }
}
test("syncs direct child session info with a navigated root", async () => {
test("syncs descendant session info with a navigated root", async () => {
const { data, app } = await mountData({ child: "root", sibling: "root", grandchild: "child" })
try {
await data.session.sync("root", { children: true })
expect(data.session.get("root")?.id).toBe("root")
expect(data.session.get("child")?.parentID).toBe("root")
expect(data.session.get("sibling")?.parentID).toBe("root")
expect(data.session.get("grandchild")).toBeUndefined()
expect(data.session.family("root")).toEqual(["root", "child", "sibling"])
// Nested subagents hydrate too, so family unread state survives a restart.
expect(data.session.get("grandchild")?.parentID).toBe("child")
expect(data.session.family("root")).toEqual(["root", "child", "sibling", "grandchild"])
} finally {
app.renderer.destroy()
}
@@ -34,6 +34,9 @@ test("scopes sessions to the active session location", async () => {
return json({ directory, project: { id: project, directory, canonical: directory } })
}
if (url.pathname !== "/api/session") return undefined
// Family hydration queries children by parentID; only project-scoped list requests matter here.
const parentID = url.searchParams.get("parentID")
if (parentID && parentID !== "null") return json({ data: [], cursor: {} })
const project = url.searchParams.get("project") ?? ""
requestedProjects.push(project)
return json({
+194 -64
View File
@@ -35,7 +35,11 @@ async function renderSessionTabs(
persisted?: string[]
sessionGate?: Promise<void>
sessionDirectories?: Record<string, string>
sessionParents?: Record<string, string>
sessionTimes?: Record<string, { idle?: number; viewed?: number }>
sessionOutcomes?: Record<string, "succeeded" | "failed" | "interrupted">
newLocation?: "launch" | "inherit"
tabsEnabled?: boolean
},
) {
const temporary = options?.state ? undefined : await tmpdir()
@@ -46,16 +50,25 @@ async function renderSessionTabs(
await Bun.write(
file,
JSON.stringify({
global: { tabs: [], unread: {} },
cwd: { [directory]: { tabs: options.persisted.map((sessionID) => ({ sessionID })), unread: {} } },
global: { tabs: [], unread: { ses_legacy: "error" } },
cwd: {
[directory]: {
tabs: options.persisted.map((sessionID) => ({ sessionID })),
unread: { ses_legacy: "activity" },
},
},
}),
)
}
const events = createEventStream()
const sessions: string[] = []
const views: string[] = []
const locations: string[] = []
const vcsLocations: string[] = []
const calls = createFetch(async (url) => {
const sessionTimes = Object.fromEntries(
Object.entries(options?.sessionTimes ?? {}).map(([sessionID, time]) => [sessionID, { ...time }]),
)
const calls = createFetch(async (url, request) => {
if (url.pathname === "/api/location") {
const requested = url.searchParams.get("location[directory]") ?? directory
locations.push(requested)
@@ -72,22 +85,40 @@ async function renderSessionTabs(
data: { branch: { current: "main", default: "main" } },
})
}
if (url.pathname === "/api/session" && url.searchParams.has("parentID")) {
const parentID = url.searchParams.get("parentID")
const children = Object.entries(options?.sessionParents ?? {})
.filter(([, parent]) => parent === parentID)
.map(([sessionID]) => sessionInfo(sessionID))
return json({ data: children, cursor: {} })
}
const viewed = url.pathname.match(/^\/api\/session\/([^/]+)\/view$/)?.[1]
if (viewed && request.method === "POST") {
views.push(viewed)
const time = (sessionTimes[viewed] ??= {})
time.viewed = time.idle
return new Response(null, { status: 204 })
}
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
if (!sessionID) return undefined
sessions.push(sessionID)
await options?.sessionGate
return json({
data: {
id: sessionID,
title: sessionID === initialSessionID ? options?.title : undefined,
projectID: "project",
location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
},
})
return json({ data: sessionInfo(sessionID) })
}, events)
function sessionInfo(sessionID: string) {
return {
id: sessionID,
parentID: options?.sessionParents?.[sessionID],
title: sessionID === initialSessionID ? options?.title : undefined,
projectID: "project",
location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
outcome: options?.sessionOutcomes?.[sessionID],
time: { created: 0, updated: 0, ...sessionTimes[sessionID] },
}
}
let tabs!: ReturnType<typeof useSessionTabs>
let route!: ReturnType<typeof useRoute>
let client!: ReturnType<typeof useClient>
@@ -109,7 +140,7 @@ async function renderSessionTabs(
<StorageProvider>
<ConfigProvider
config={createTuiResolvedConfig({
tabs: { enabled: true },
tabs: { enabled: options?.tabsEnabled ?? true },
session: { new_location: options?.newLocation ?? "launch" },
})}
>
@@ -138,9 +169,13 @@ async function renderSessionTabs(
route,
data,
sessions,
views,
locations,
vcsLocations,
state,
setSessionTime(sessionID: string, time: { idle?: number; viewed?: number }) {
sessionTimes[sessionID] = time
},
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
focus: () => app.renderer.emit("focus"),
blur: () => app.renderer.emit("blur"),
@@ -153,14 +188,6 @@ async function renderSessionTabs(
}
}
const executionSucceeded = (sessionID: string): OpenCodeEvent => ({
id: `evt_done_${sessionID}`,
created: Date.now(),
type: "session.execution.succeeded",
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID },
})
test("loads persisted tab metadata concurrently on connect", async () => {
let release!: () => void
const sessionGate = new Promise<void>((resolve) => (release = resolve))
@@ -230,10 +257,10 @@ test("stores session tabs for the current working directory by default", async (
const file = path.join(setup.state, "test", "tui", "tabs.json")
await wait(() => Bun.file(file).size > 0)
const stored = await Bun.file(file).json()
expect(stored.global).toEqual({ tabs: [], unread: {} })
expect(stored.global).toEqual({ tabs: [] })
expect(Object.keys(stored.cwd)).toEqual([directory])
expect(stored.cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID)).toEqual(["first"])
expect(stored.cwd[directory].unread).toEqual({})
expect(stored.cwd[directory]).not.toHaveProperty("unread")
} finally {
await setup.destroy()
}
@@ -257,47 +284,150 @@ test("keeps scroll anchors for open session tabs", async () => {
}
})
test("only the foreground TUI mutates unread state", async () => {
await using temporary = await tmpdir()
let foreground: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
let background: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
test("derives unread state from server session times", async () => {
const setup = await renderSessionTabs("first", {
home: true,
persisted: ["first", "second"],
sessionTimes: { second: { idle: 2 } },
})
try {
foreground = await renderSessionTabs("first", { state: temporary.path, persisted: ["first", "second"] })
background = await renderSessionTabs("second", { state: temporary.path })
foreground.focus()
background.blur()
await wait(() => foreground?.tabs.tabs().length === 2 && background?.tabs.tabs().length === 2, 2_000, "shared tabs")
const firstDone = executionSucceeded("first")
foreground.emit(firstDone)
background.emit(firstDone)
await Promise.all([foreground.flush(), background.flush()])
expect(foreground.tabs.status("first").unread).toBeUndefined()
expect(background.tabs.status("first").unread).toBeUndefined()
const secondDone = executionSucceeded("second")
foreground.emit(secondDone)
background.emit(secondDone)
await wait(
() =>
foreground?.tabs.status("second").unread === "activity" &&
background?.tabs.status("second").unread === "activity",
10_000,
"shared unread activity",
)
foreground.tabs.select("second")
await wait(
() =>
foreground?.tabs.status("second").unread === undefined &&
background?.tabs.status("second").unread === undefined,
10_000,
"shared unread clearing",
)
await wait(() => setup.tabs.status("second").unread === "activity")
expect(setup.tabs.status("first").unread).toBeUndefined()
} finally {
if (foreground) await foreground.destroy()
if (background) await background.destroy()
await setup.destroy()
}
})
test("marks unread failed sessions with error styling", async () => {
const setup = await renderSessionTabs("first", {
home: true,
persisted: ["first", "second"],
sessionTimes: { first: { idle: 2 }, second: { idle: 2 } },
sessionOutcomes: { second: "failed" },
})
try {
await wait(() => setup.tabs.status("second").unread === "error")
expect(setup.tabs.status("first").unread).toBe("activity")
} finally {
await setup.destroy()
}
})
test("acknowledges viewed sessions even when tabs are disabled", async () => {
const setup = await renderSessionTabs("first", {
tabsEnabled: false,
sessionTimes: { first: { idle: 2 } },
})
try {
await setup.data.session.sync("first")
await wait(() => setup.views.includes("first"))
expect(setup.tabs.tabs()).toEqual([])
} finally {
await setup.destroy()
}
})
test("purges legacy persisted unread records", async () => {
const setup = await renderSessionTabs("first", { persisted: ["first"] })
try {
const file = path.join(setup.state, "test", "tui", "tabs.json")
// Normalize rewrites the active scope; the legacy record must not survive it.
await wait(async () => {
const stored = await Bun.file(file).json()
return !("unread" in stored.cwd[directory])
})
} finally {
await setup.destroy()
}
})
test("refreshes server session times after terminal events", async () => {
const setup = await renderSessionTabs("first", { home: true, persisted: ["first"] })
try {
// Terminal events refresh only already-loaded sessions, so ensure the initial sync landed.
await wait(() => setup.data.session.get("first") !== undefined)
setup.setSessionTime("first", { idle: 2 })
setup.emit({
id: "evt_done_first",
created: 2,
type: "session.execution.succeeded",
durable: { aggregateID: "first", seq: 1, version: 1 },
data: { sessionID: "first" },
})
await wait(() => setup.tabs.status("first").unread === "activity")
} finally {
await setup.destroy()
}
})
test("views a selected unread session only while focused", async () => {
const setup = await renderSessionTabs("first", {
home: true,
persisted: ["first"],
sessionTimes: { first: { idle: 2 } },
})
try {
setup.blur()
setup.route.navigate({ type: "session", sessionID: "first" })
await wait(() => setup.tabs.current() === "first" && setup.tabs.status("first").unread === "activity")
await Bun.sleep(20)
expect(setup.views).toEqual([])
setup.focus()
await wait(() => setup.views.includes("first"))
setup.emit({
id: "evt_viewed_first",
created: 3,
type: "session.viewed",
durable: { aggregateID: "first", seq: 2, version: 1 },
data: { sessionID: "first", idle: 2 },
})
await wait(() => setup.tabs.status("first").unread === undefined)
expect(setup.views).toEqual(["first"])
} finally {
await setup.destroy()
}
})
test("views unread child sessions through their root tab", async () => {
const setup = await renderSessionTabs("root", {
home: true,
persisted: ["root"],
sessionParents: { child: "root" },
sessionTimes: { child: { idle: 2 } },
})
try {
setup.blur()
await setup.data.session.sync("child")
await wait(() => setup.tabs.status("root").unread === "activity")
setup.route.navigate({ type: "session", sessionID: "root" })
await Bun.sleep(20)
expect(setup.views).toEqual([])
setup.focus()
await wait(() => setup.views.includes("child"))
expect(setup.views).not.toContain("root")
} finally {
await setup.destroy()
}
})
test("aggregates and acknowledges unread grandchildren after a restart", async () => {
const setup = await renderSessionTabs("root", {
home: true,
persisted: ["root"],
sessionParents: { child: "root", grandchild: "child" },
sessionTimes: { grandchild: { idle: 2 } },
})
try {
// No manual syncs: tab prefetch alone must hydrate the nested subagent.
await wait(() => setup.tabs.status("root").unread === "activity")
setup.route.navigate({ type: "session", sessionID: "root" })
await wait(() => setup.views.includes("grandchild"))
expect(setup.views).toEqual(["grandchild"])
} finally {
await setup.destroy()
}
})