Compare commits

...
Author SHA1 Message Date
Kit Langton 9f06708d0e fix(plugin): load Effect from the host for plugins 2026-10-04 23:14:49 -07:00
usrnk1andLuke Parker e86ec98711 feat(desktop): move review controls into their panels (#51312)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-10-05 03:18:48 +00:00
Luke Parker c542a43503 fix(cli): indent every row of the pairing QR code on Windows (#53265) 2026-10-05 03:02:36 +00:00
Luke Parker 2db9c2e1b3 fix(app): match TUI inbox, steer, queue, and revert behavior (#53076) 2026-10-05 12:54:06 +10:00
Luke Parker 3a861ff8b0 feat(app): show running subagents and shells in the session header (#53247) 2026-10-05 12:30:44 +10:00
Aiden Cline 22803892bc feat(ai): support Anthropic Messages on Bedrock Mantle (#53073) 2026-10-04 20:51:33 -05:00
Aiden Cline 69ba898a7b perf(ai): lazily parse partial JSON on tool-input-delta (#53255) 2026-10-04 20:41:46 -05:00
opencode-agent[bot]andrekram1-node 375ff827aa feat(tui): show read ranges after file paths (#53250)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-10-04 20:14:48 -05:00
Luke Parker ed747091e9 fix(gui-extensions): hold agent previews for sessions that are not on screen (#53249) 2026-10-05 00:56:05 +00:00
Aiden Cline 44193bd71f refactor(ai): untrace stream event handlers and inner protocol helpers (#53232) 2026-10-04 19:43:29 -05:00
Aiden Cline 238d4cf5ca refactor(ai): simplify sseFraming and untrace inner request-lowering helpers (#53072) 2026-10-04 16:27:06 -05:00
Kit Langton 3e77d5c170 fix(client): preserve service on protocol-only reconnect mismatch (#50825) 2026-10-04 13:30:43 -07:00
Kit Langton eaf80d99e5 chore(app): clear lint warnings in GUI files touched by the Effect upgrade (#53098) 2026-10-04 12:04:16 -07:00
opencode-agent[bot] 0a46301e36 chore(core): refresh bundled models.dev snapshot 2026-10-04 13:59:27 +00:00
223 changed files with 6380 additions and 1430 deletions

No files matched your search

+1
View File
@@ -42,6 +42,7 @@ const RESPECTS_INLINE_HINTS = new Set([
"alibaba-messages",
"anthropic-messages",
"anthropic-compatible-messages",
"bedrock-mantle-messages",
"cloudflare-ai-gateway-messages",
"google-vertex-messages",
"meta-messages",
@@ -584,7 +584,7 @@ const serverToolResultType = (name: string): AnthropicServerToolResultType | und
return undefined
}
const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult")(function* (
const lowerServerToolResult = Effect.fnUntraced(function* (
part: ToolResultPart,
providerMetadataKey: string,
) {
@@ -657,7 +657,7 @@ const citationsFromMetadata = (metadata: MediaPart["metadata"]): AnthropicDocume
const isHttpUrl = (value: string) => /^https?:\/\//i.test(value.trim())
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (
const lowerMedia = Effect.fnUntraced(function* (
part: MediaPart,
breakpoints?: Cache.Breakpoints,
) {
@@ -847,7 +847,7 @@ const splitsLocalToolResults = (messages: LLMRequest["messages"], index: number)
return pending.size > 0
}
const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* (
const lowerNativeSystemUpdate = Effect.fnUntraced(function* (
message: LLMRequest["messages"][number],
breakpoints: Cache.Breakpoints,
) {
@@ -862,7 +862,7 @@ const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUp
}
})
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
const lowerMessages = Effect.fnUntraced(function* (
request: LLMRequest,
breakpoints: Cache.Breakpoints,
) {
@@ -989,6 +989,9 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
return messages
})
// TODO: Move per-model capability heuristics (`supportsEffortUpdates`, `supportsNativeSystemUpdates`,
// `supportsThinkingBlockBinding`) into explicit model/provider `compatibility` metadata so the protocol
// only reads `request.model.compatibility`.
// Per-turn effort started with Claude Opus 5 and every Claude 5.1 model; later versions of any family inherit it.
const supportsEffortUpdates = (model: LLMRequest["model"]) => {
const override = model.compatibility?.supportsEffortUpdates
@@ -1300,7 +1303,7 @@ const onContentBlockStart = (
return [{ ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }, [...events, result]]
}
const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(function* (
const onContentBlockDelta = Effect.fnUntraced(function* (
state: ParserState,
event: AnthropicEvent & { readonly delta: AnthropicStreamDelta },
) {
@@ -1368,7 +1371,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
return [state, NO_EVENTS] satisfies StepResult
})
const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(function* (
const onContentBlockStop = Effect.fnUntraced(function* (
state: ParserState,
event: AnthropicEvent,
) {
@@ -1439,7 +1442,7 @@ const onMessageDelta = (
]
}
const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (state: ParserState) {
const onMessageStop = Effect.fnUntraced(function* (state: ParserState) {
if (Object.keys(state.compactions).length)
return yield* ProviderShared.eventError(ADAPTER, "Response ended with an incomplete compaction block")
const result = yield* ToolStream.finishAll(ADAPTER, state.tools)
@@ -285,7 +285,7 @@ const lowerToolCall = (part: ToolCallPart, normalizeID: (id: string) => string):
},
})
const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent")(function* (
const lowerToolResultContent = Effect.fnUntraced(function* (
part: ToolResultPart,
documentNames: Set<string>,
) {
@@ -305,7 +305,7 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent
return content
})
const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (
const lowerToolResult = Effect.fnUntraced(function* (
part: ToolResultPart,
documentNames: Set<string>,
normalizeID: (id: string) => string,
@@ -322,7 +322,7 @@ const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (
// Keep Claude and Nova tool-result images inline; put other models' images beside the result.
const keepToolImagesInline = (id: string) => id.includes("anthropic.claude-") || id.includes("amazon.nova-")
const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
const lowerMessages = Effect.fnUntraced(function* (
request: LLMRequest,
breakpoints: BedrockCache.Breakpoints,
) {
+3 -3
View File
@@ -75,7 +75,7 @@ const usesSse = (request: MediaProtocol.Addressed<Request>) => request.mode ===
const CONTAINERS: Readonly<Record<string, "raw" | "wav" | "mp3">> = { pcm: "raw", wav: "wav", mp3: "mp3" }
const outputFormat = Effect.fn("CartesiaSpeech.outputFormat")(function* (request: MediaProtocol.Addressed<Request>) {
const outputFormat = Effect.fnUntraced(function* (request: MediaProtocol.Addressed<Request>) {
const sse = usesSse(request)
const format = request.format ?? (sse ? "pcm" : "mp3")
const container = CONTAINERS[format]
@@ -121,7 +121,7 @@ const fromRequest = Effect.fn("CartesiaSpeech.fromRequest")(function* (request:
// 6. Stream parsing
// ---------------------------------------------------------------------------
const onEvent = Effect.fn("CartesiaSpeech.onEvent")(function* (state: State, frame: string) {
const onEvent = Effect.fnUntraced(function* (state: State, frame: string) {
const event = yield* decodeEvent(frame)
if (event.type === "chunk" && event.data !== undefined) return SpeechStream.delta(state, event.data)
if (event.type === "timestamps" && event.word_timestamps !== undefined) {
@@ -140,7 +140,7 @@ const onEvent = Effect.fn("CartesiaSpeech.onEvent")(function* (state: State, fra
return [state, []] as const
})
const finish = Effect.fn("CartesiaSpeech.finish")(function* (
const finish = Effect.fnUntraced(function* (
state: State,
context: MediaProtocol.ResponseContext<Request>,
) {
+1 -1
View File
@@ -252,7 +252,7 @@ const mapUsage = (usage: typeof NativeUsage.Type) =>
})
// Lifecycle deltas open blocks on demand and ends are no-ops for closed blocks, so content-start needs no handling.
const step = Effect.fn("CohereChat.step")(function* (state: State, event: Event) {
const step = Effect.fnUntraced(function* (state: State, event: Event) {
const events: LLMEvent[] = []
switch (event.type) {
case "message-start":
@@ -95,7 +95,7 @@ const OUTPUT_FORMATS: Readonly<Record<string, string>> = {
}
/** WAV is served only by the non-streaming endpoints. */
const outputFormat = Effect.fn("ElevenLabsSpeech.outputFormat")(function* (request: MediaProtocol.Addressed<Request>) {
const outputFormat = Effect.fnUntraced(function* (request: MediaProtocol.Addressed<Request>) {
const format = request.providerOptions?.outputFormat ?? OUTPUT_FORMATS[request.format ?? "mp3"]
if (format === undefined)
return yield* route.unsupported(
@@ -138,7 +138,7 @@ const path = (request: MediaProtocol.Addressed<Request>) =>
// 6. Stream parsing
// ---------------------------------------------------------------------------
const onRecord = Effect.fn("ElevenLabsSpeech.onRecord")(function* (state: State, frame: string) {
const onRecord = Effect.fnUntraced(function* (state: State, frame: string) {
const record = yield* decodeRecord(frame)
const [next, events] = SpeechStream.delta(state, record.audio_base64)
const alignment = record.alignment
@@ -169,7 +169,7 @@ const describeOutput = (format: string) => {
return encoding === undefined ? SpeechStream.container(codec, sampleRate) : SpeechStream.pcm(encoding, sampleRate)
}
const finish = Effect.fn("ElevenLabsSpeech.finish")(function* (
const finish = Effect.fnUntraced(function* (
state: State,
context: MediaProtocol.ResponseContext<Request>,
) {
+2 -2
View File
@@ -283,7 +283,7 @@ const lowerToolConfig = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
tool: (name) => ({ functionCallingConfig: { mode: "ANY" as const, allowedFunctionNames: [name] } }),
})
const lowerContentPart = Effect.fn("Gemini.lowerContentPart")(function* (part: TextPart | MediaPart) {
const lowerContentPart = Effect.fnUntraced(function* (part: TextPart | MediaPart) {
if (part.type === "text") return { text: part.text }
return yield* GeminiGenerateContent.mediaPart("Gemini", part.media)
})
@@ -302,7 +302,7 @@ const lowerToolCall = (part: ToolCallPart, omitIds: boolean, metadataKey: string
thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey),
})
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest) {
const contents: GeminiContent[] = []
const metadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
const omitCallIds = omitsFunctionCallIds(request.model.id)
@@ -182,7 +182,7 @@ type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
// =============================================================================
// Request Body Construction
// =============================================================================
const mediaContent = Effect.fn("GoogleInteractions.mediaContent")(function* (asset: Media.Asset) {
const mediaContent = Effect.fnUntraced(function* (asset: Media.Asset) {
if (
asset.kind !== "image" &&
asset.kind !== "audio" &&
@@ -208,7 +208,7 @@ const signature = (metadata: ProviderMetadata | undefined, key: string) => {
: undefined
}
const lowerMessages = Effect.fn("GoogleInteractions.lowerMessages")(function* (request: LLMRequest) {
const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest) {
const steps: InputStep[] = []
const key = request.model.route.providerMetadataKey ?? String(request.model.provider)
for (const message of request.messages) {
@@ -275,7 +275,7 @@ const lowerMessages = Effect.fn("GoogleInteractions.lowerMessages")(function* (r
return steps
})
const lowerToolResult = Effect.fn("GoogleInteractions.lowerToolResult")(function* (part: ToolResultPart) {
const lowerToolResult = Effect.fnUntraced(function* (part: ToolResultPart) {
if (part.result.type === "json" && ProviderShared.isRecord(part.result.value)) return part.result.value
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
@@ -364,7 +364,7 @@ const mapUsage = (usage: RawUsage | undefined, key: string) => {
})
}
const onStart = Effect.fn("GoogleInteractions.onStart")(function* (
const onStart = Effect.fnUntraced(function* (
state: ParserState,
index: number,
step: OutputStep,
@@ -402,7 +402,7 @@ const onStart = Effect.fn("GoogleInteractions.onStart")(function* (
return [{ ...state, lifecycle, tools, steps: { ...state.steps, [index]: step } }, events] satisfies StepResult
})
const onDelta = Effect.fn("GoogleInteractions.onDelta")(function* (
const onDelta = Effect.fnUntraced(function* (
state: ParserState,
index: number,
delta: typeof Delta.Type,
@@ -447,7 +447,7 @@ const onDelta = Effect.fn("GoogleInteractions.onDelta")(function* (
return yield* ProviderShared.eventError(ADAPTER, `Unsupported Interactions delta: ${delta.type}`, encodeJson(delta))
})
const onStop = Effect.fn("GoogleInteractions.onStop")(function* (state: ParserState, index: number) {
const onStop = Effect.fnUntraced(function* (state: ParserState, index: number) {
const step = state.steps[index]
if (!step) return yield* ProviderShared.eventError(ADAPTER, "Interactions step.stop without step.start")
const events: LLMEvent[] = []
@@ -465,7 +465,7 @@ const onStop = Effect.fn("GoogleInteractions.onStop")(function* (state: ParserSt
return [{ ...state, tools: result.tools }, result.events ?? []] satisfies StepResult
})
const step = Effect.fn("GoogleInteractions.step")(function* (state: ParserState, event: Event) {
const step = Effect.fnUntraced(function* (state: ParserState, event: Event) {
switch (event.event_type) {
case "step.start":
return yield* onStart(state, event.index, event.step)
+1 -1
View File
@@ -94,7 +94,7 @@ const fromRequest = Effect.fn("GoogleSpeech.fromRequest")(function* (request: Me
// 6. Stream parsing
// ---------------------------------------------------------------------------
const step = Effect.fn("GoogleSpeech.step")(function* (state: State, frame: string) {
const step = Effect.fnUntraced(function* (state: State, frame: string) {
const chunk = yield* decodeChunk(frame)
const blocked = GeminiGenerateContent.blocked(route.name, chunk, frame)
if (blocked !== undefined) return yield* blocked
@@ -138,7 +138,7 @@ const turn = (part: Schema.Schema.Type<typeof AudioTranscription>) => {
}
}
const step = Effect.fn("GoogleTranscription.step")(function* (state: State, frame: string) {
const step = Effect.fnUntraced(function* (state: State, frame: string) {
const chunk = yield* decodeChunk(frame)
const blocked = GeminiGenerateContent.blocked(route.name, chunk, frame)
if (blocked !== undefined) return yield* blocked
+3 -3
View File
@@ -95,7 +95,7 @@ const HOSTED_TOOLS = {
image_generation_call: {
name: "image_generation",
input: () => ({}),
result: Effect.fn("MetaResponses.imageResult")(function* (raw: ResponsesHostedTools.Item) {
result: Effect.fnUntraced(function* (raw: ResponsesHostedTools.Item) {
const item = yield* Schema.decodeUnknownEffect(ImageItem)(raw).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(
@@ -136,7 +136,7 @@ const HOSTED_TOOLS = {
},
} satisfies ResponsesHostedTools.Definitions
const onEvent = Effect.fn("MetaResponses.onEvent")(function* (
const onEvent = Effect.fnUntraced(function* (
state: OpenResponses.ParserState,
input: OpenResponses.Event,
) {
@@ -173,7 +173,7 @@ const onEvent = Effect.fn("MetaResponses.onEvent")(function* (
] satisfies OpenResponses.StepResult
})
const step = Effect.fn("MetaResponses.step")(function* (state: ParserState, input: OpenResponses.Event) {
const step = Effect.fnUntraced(function* (state: ParserState, input: OpenResponses.Event) {
const completedItems = new Set(state.completedItems)
const event = OpenResponses.normalize(state, input)
if (event.type === "response.output_item.done" && event.item && completedItems.has(event.item.id))
+8 -8
View File
@@ -226,7 +226,7 @@ const MistralEvent = Schema.StructWithRest(
type MistralEvent = Schema.Schema.Type<typeof MistralEvent>
const MistralStreamEvent = Schema.Union([Schema.Literal(DONE), Protocol.jsonEvent(MistralEvent)])
const lowerMedia = Effect.fn("MistralChat.lowerMedia")(function* (part: MediaPart) {
const lowerMedia = Effect.fnUntraced(function* (part: MediaPart) {
const mime = part.media.mediaType.toLowerCase()
const url =
ProviderShared.mediaUrl(part.media) ??
@@ -236,7 +236,7 @@ const lowerMedia = Effect.fn("MistralChat.lowerMedia")(function* (part: MediaPar
return yield* ProviderShared.invalidRequest(`Mistral Chat does not support media type ${part.media.mediaType}`)
})
const lowerUser = Effect.fn("MistralChat.lowerUser")(function* (message: LLMRequest["messages"][number]) {
const lowerUser = Effect.fnUntraced(function* (message: LLMRequest["messages"][number]) {
const content: MistralUserContent[] = []
for (const part of message.content) {
if (part.type === "text") {
@@ -260,7 +260,7 @@ const lowerToolCall = (part: ToolCallPart, normalizeID: (id: string) => string):
function: { name: part.name, arguments: ProviderShared.encodeJson(part.input) },
})
const lowerAssistant = Effect.fn("MistralChat.lowerAssistant")(function* (
const lowerAssistant = Effect.fnUntraced(function* (
message: LLMRequest["messages"][number],
normalizeID: (id: string) => string,
prefix: boolean,
@@ -298,7 +298,7 @@ const lowerAssistant = Effect.fn("MistralChat.lowerAssistant")(function* (
}
})
const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* (
const lowerToolResults = Effect.fnUntraced(function* (
message: LLMRequest["messages"][number],
normalizeID: (id: string) => string,
) {
@@ -335,7 +335,7 @@ const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* (
return output
})
const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request: LLMRequest) {
const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest) {
const normalizeID = MistralToolID.normalizer(request)
const messages: MistralMessage[] =
request.system.length === 0
@@ -596,7 +596,7 @@ const toolText = (tool: MistralToolDelta) => {
return value === null || value === undefined ? "" : ProviderShared.encodeJson(value)
}
const appendTools = Effect.fn("MistralChat.appendTools")(function* (
const appendTools = Effect.fnUntraced(function* (
initial: ParserState,
events: LLMEvent[],
deltas: ReadonlyArray<MistralToolDelta>,
@@ -662,7 +662,7 @@ const hasLateContent = (event: MistralEvent) => {
)
}
const step = Effect.fn("MistralChat.step")(function* (state: ParserState, event: MistralEvent) {
const step = Effect.fnUntraced(function* (state: ParserState, event: MistralEvent) {
if (event.error) {
const body = ProviderShared.encodeJson(event)
return yield* new AIError({
@@ -726,7 +726,7 @@ const step = Effect.fn("MistralChat.step")(function* (state: ParserState, event:
] as const
})
const finishEvents = Effect.fn("MistralChat.finishEvents")(function* (state: ParserState) {
const finishEvents = Effect.fnUntraced(function* (state: ParserState) {
if (!state.finishReason)
return yield* new AIError({
reason: new InvalidProviderOutputError({
+14 -13
View File
@@ -442,7 +442,7 @@ interface ReasoningStreamItem {
// =============================================================================
// Request Lowering
// =============================================================================
export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (protocolName: string, tool: ToolDefinition) {
export const lowerTool = Effect.fnUntraced(function* (protocolName: string, tool: ToolDefinition) {
if (tool.native !== undefined)
return yield* ProviderShared.invalidRequest(`${protocolName} does not support provider-native tool ${tool.name}`)
return {
@@ -507,7 +507,10 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
}
}
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
const decodeImageDetail = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesInputImage.fields.detail))
const decodeMessageMetadata = ProviderShared.validateWith(Schema.decodeUnknownEffect(MessageMetadata))
const lowerMedia = Effect.fnUntraced(function* (
part: MediaPart,
request: LLMRequest,
adapter: ProviderAdapter,
@@ -516,9 +519,8 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
const media = part.media.inline()
const providerMedia = adapter.lowerMedia?.({ part, media, request })
if (providerMedia) return providerMedia
const detail = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesInputImage.fields.detail))(
part.providerMetadata?.[metadataKey(request.model)]?.detail,
)
const rawDetail = part.providerMetadata?.[metadataKey(request.model)]?.detail
const detail = rawDetail === undefined ? undefined : yield* decodeImageDetail(rawDetail)
const mime = part.media.mediaType.toLowerCase()
const url = ProviderShared.mediaUrl(part.media)
const location = url ?? (yield* ProviderShared.requireInlineMedia(adapter.name, part.media)).dataUrl
@@ -591,7 +593,7 @@ const lowerToolResultOutput = Effect.fnUntraced(function* (
const DEFAULT_EFFORT = "medium"
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
const lowerMessages = Effect.fnUntraced(function* (
request: LLMRequest,
adapter: ProviderAdapter,
) {
@@ -599,9 +601,8 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
const providerMetadataKey = metadataKey(request.model)
for (const message of request.messages) {
const metadata = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(MessageMetadata)),
)(message.providerMetadata?.[providerMetadataKey])
const rawMetadata = message.providerMetadata?.[providerMetadataKey]
const metadata = rawMetadata === undefined ? undefined : yield* decodeMessageMetadata(rawMetadata)
if (message.role === "system") {
const update = effortUpdate(message)
if (update) {
@@ -755,7 +756,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
return input
})
export const lowerConversation = Effect.fn("OpenResponses.lowerConversation")(function* (
export const lowerConversation = Effect.fnUntraced(function* (
request: LLMRequest,
adapter: ProviderAdapter,
) {
@@ -1142,7 +1143,7 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
]
}
const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgumentsDelta")(function* (
const onFunctionCallArgumentsDelta = Effect.fnUntraced(function* (
state: ParserState,
event: Event,
) {
@@ -1173,7 +1174,7 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
})
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const onOutputItemDone = Effect.fnUntraced(function* (
state: ParserState,
item: NormalizedEvent["item"],
) {
@@ -1309,7 +1310,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
return [state, NO_EVENTS] satisfies StepResult
})
const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (state: ParserState, event: Event) {
const onResponseFinish = Effect.fnUntraced(function* (state: ParserState, event: Event) {
let current = state
const events: LLMEvent[] = []
if (event.type === "response.completed") {
+8 -8
View File
@@ -360,7 +360,7 @@ const lowerToolCall = (
extra_content: decodeExtraContent(part.providerMetadata?.[options.providerMetadataKey]?.extraContent),
})
const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart) {
const lowerMedia = Effect.fnUntraced(function* (part: MediaPart) {
// Chat Completions accepts PDFs, and no other documents, as inline `file` parts; file URLs are not supported.
if (part.media.mediaType.toLowerCase() === "application/pdf")
return {
@@ -406,7 +406,7 @@ const lowerReasoningDetail = (detail: ReasoningDetail) => {
const isKimiDetail = (detail: { readonly type: string }) => detail.type === "summary" || detail.type === "encrypted"
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
const lowerUserMessage = Effect.fnUntraced(function* (
message: OpenAIChatRequestMessage,
options: LoweringOptions,
) {
@@ -430,7 +430,7 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
return { role: "user" as const, content }
})
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
const lowerAssistantMessage = Effect.fnUntraced(function* (
message: OpenAIChatRequestMessage,
configuredField: string | undefined,
requireReasoning: boolean,
@@ -495,7 +495,7 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
return { ...result, [field]: reasoningText }
})
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
const lowerToolMessages = Effect.fnUntraced(function* (
message: OpenAIChatRequestMessage,
options: LoweringOptions,
) {
@@ -532,7 +532,7 @@ const toolMessage = (toolCallID: string, text: string, cacheControl: OpenAIChatC
content: cacheControl === undefined ? text : [{ type: "text" as const, text, cache_control: cacheControl }],
})
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
const lowerMessage = Effect.fnUntraced(function* (
message: OpenAIChatRequestMessage,
reasoningField: string | undefined,
requireReasoning: boolean,
@@ -544,7 +544,7 @@ const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
return (yield* lowerToolMessages(message, options)).messages
})
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest, options: LoweringOptions) {
const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest, options: LoweringOptions) {
const system: OpenAIChatMessage[] =
request.system.length === 0
? []
@@ -855,7 +855,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
// Streaming parsers are small state machines: every event returns a new state
// plus the common `LLMEvent`s produced by that event. Tool calls are accumulated
// because OpenAI streams JSON arguments across multiple deltas.
const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event: OpenAIChatEvent, reason: string) {
const mapFinishReason = Effect.fnUntraced(function* (event: OpenAIChatEvent, reason: string) {
switch (reason) {
case "error":
return yield* new AIError({
@@ -1214,7 +1214,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
] as const
})
const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: ParserState) {
const finishEvents = Effect.fnUntraced(function* (state: ParserState) {
if (state.finishReason === undefined && state.requireFinishReason)
return yield* new AIError({
reason: new InvalidProviderOutputError({
+2 -2
View File
@@ -208,7 +208,7 @@ const eventImage = (frame: string, label: string, data: string, format: string,
info: info(format, size),
})
const onEvent = Effect.fn("OpenAIImages.onEvent")(function* (state: State, frame: string) {
const onEvent = Effect.fnUntraced(function* (state: State, frame: string) {
const event = yield* decodeEvent(frame)
const format = event.output_format
if ("partial_image_index" in event) {
@@ -229,7 +229,7 @@ const onEvent = Effect.fn("OpenAIImages.onEvent")(function* (state: State, frame
] as const
})
const onDocument = Effect.fn("OpenAIImages.onDocument")(function* (frame: Exclude<Frame, string>) {
const onDocument = Effect.fnUntraced(function* (frame: Exclude<Frame, string>) {
const invalid = (message: string, cause?: unknown) => route.frameError(message, frame.document, cause)
const decoded = yield* decodeDocument(frame.document).pipe(
Effect.mapError((cause) => invalid(`${route.name} returned an invalid response`, cause)),
@@ -157,7 +157,7 @@ const nativeImageTool = (tool: ToolDefinition) => {
return Schema.is(OpenAIResponsesImageGenerationTool)(native) ? native : undefined
}
const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDefinition) {
const lowerTool = Effect.fnUntraced(function* (tool: ToolDefinition) {
const native = nativeImageToolInput(tool)
if (native !== undefined) {
if (Schema.is(OpenAIResponsesImageGenerationTool)(native)) return native
@@ -168,7 +168,7 @@ const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDe
// Native namespaces hold only function tools, so deeper levels flatten into
// the leaf names the same way non-native protocols flatten the whole tree.
const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (tool: ToolEntry) {
const lowerToolEntry = Effect.fnUntraced(function* (tool: ToolEntry) {
if (tool.type === "tool") return yield* lowerTool(tool)
// OpenAI requires a namespace description; fall back to a generic one so a
// missing description never blocks the request.
@@ -237,7 +237,7 @@ const checkpointBody = {
}),
}
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
const hostedToolResult = Effect.fnUntraced(function* (item: ResponsesHostedTools.Item) {
const isError = item.error !== undefined && item.error !== null
if (item.type === "image_generation_call" && item.result) {
yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
+1 -1
View File
@@ -94,7 +94,7 @@ const fromRequest = Effect.fn("OpenAISpeech.fromRequest")(function* (request: Me
const isSse = (body: MediaProtocol.Body) => body.type === "json" && body.value.stream_format === "sse"
const onEvent = Effect.fn("OpenAISpeech.onEvent")(function* (state: State, frame: string) {
const onEvent = Effect.fnUntraced(function* (state: State, frame: string) {
const event = yield* decodeEvent(frame)
if (event.type === "speech.audio.delta") return SpeechStream.delta(state, event.audio)
const usage = event.usage
@@ -210,7 +210,7 @@ const segment = (value: Schema.Schema.Type<typeof Segment>): TranscriptionSegmen
speaker: value.speaker,
})
const onEvent = Effect.fn("OpenAITranscription.onEvent")(function* (state: State, frame: string) {
const onEvent = Effect.fnUntraced(function* (state: State, frame: string) {
if (!EVENT_TYPES.has((yield* decodeEventType(frame)).type)) return [state, []] as const
const event = yield* decodeEvent(frame)
if (event.type === "error")
+2 -2
View File
@@ -154,7 +154,7 @@ export const wrapSystemUpdate = (parts: ReadonlyArray<{ readonly text: string }>
* raw retrieved, tool, or web content into privileged updates: keep untrusted
* data in ordinary user/tool messages instead.
*/
export const systemUpdateText = Effect.fn("ProviderShared.systemUpdateText")(function* (
export const systemUpdateText = Effect.fnUntraced(function* (
route: string,
message: LLMRequest["messages"][number],
) {
@@ -167,7 +167,7 @@ export const systemUpdateText = Effect.fn("ProviderShared.systemUpdateText")(fun
})
/** Lower an unsupported privileged update into visible, in-order user text. */
export const wrappedSystemUpdate = Effect.fn("ProviderShared.wrappedSystemUpdate")(function* (
export const wrappedSystemUpdate = Effect.fnUntraced(function* (
route: string,
message: LLMRequest["messages"][number],
) {
@@ -82,7 +82,7 @@ const endpoint = (model: string) => (model.startsWith("sd3") ? "sd3" : model)
const RESERVED_FORM_FIELDS = new Set(["image", "prompt", "mode", "model"])
const form = Effect.fn("StabilityImages.form")(function* (
const form = Effect.fnUntraced(function* (
identity: MediaProtocol.Identity,
fields: Record<string, unknown>,
native: Record<string, unknown> | undefined,
@@ -76,7 +76,7 @@ function documentName(filename: string | undefined, names: Set<string>) {
return name
}
const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: MediaPart) {
const mediaBase64 = Effect.fnUntraced(function* (part: MediaPart) {
const media = yield* ProviderShared.requireInlineMedia("Bedrock Converse", part.media)
const bytes = yield* Effect.fromResult(Encoding.decodeBase64(media.base64)).pipe(
Effect.mapError((cause) =>
@@ -91,7 +91,7 @@ const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: Media
// document block. Image MIME types not in `IMAGE_FORMATS` (e.g. `image/svg+xml`)
// get an image-specific error so the caller knows it's a format-support issue,
// not a kind-detection issue.
export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart, documentNames: Set<string>) {
export const lower = Effect.fnUntraced(function* (part: MediaPart, documentNames: Set<string>) {
const mime = part.media.mediaType.toLowerCase()
const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS]
if (imageFormat) {
@@ -11,7 +11,7 @@ interface State {
readonly responseID?: string
}
const onOutputItem = Effect.fn("ResponsesCheckpoint.onOutputItem")(function* (
const onOutputItem = Effect.fnUntraced(function* (
state: State,
input: OpenResponses.Event,
) {
@@ -63,7 +63,7 @@ export const make = <Body>(body: RouteBody<Body>): TriggerCompactOperation =>
checkpoints: {},
}),
terminal: OpenResponses.terminal,
step: Effect.fn("ResponsesCheckpoint.step")(function* (state: State, event: OpenResponses.Event) {
step: Effect.fnUntraced(function* (state: State, event: OpenResponses.Event) {
if (event.response?.id && state.responseID && event.response.id !== state.responseID)
return yield* ProviderShared.eventError(source.id, "Compaction response ID changed during execution")
if (event.type === "response.created") return [{ ...state, responseID: event.response?.id }, []] as const
@@ -33,7 +33,7 @@ export const onDone: (
state: OpenResponses.ParserState,
item: Item,
tools: Definitions,
) => Effect.Effect<OpenResponses.StepResult, AIError> = Effect.fn("ResponsesHostedTools.onDone")(
) => Effect.Effect<OpenResponses.StepResult, AIError> = Effect.fnUntraced(
function* (state, item, tools) {
const tool = tools[item.type]
if (!tool) return [state, []] satisfies OpenResponses.StepResult
+15 -8
View File
@@ -60,14 +60,21 @@ const inputStart = (tool: PendingTool) =>
providerMetadata: tool.providerMetadata,
})
const inputDelta = (tool: PendingTool, text: string) =>
LLMEvent.toolInputDelta({
id: tool.id,
name: tool.name,
namespace: tool.namespace,
text,
input: Option.getOrElse(parsePartialInput(tool.input), () => ({})),
})
const inputDelta = (tool: PendingTool, text: string): LLMEvent => {
const raw = tool.input
let parsed: unknown
return {
...LLMEvent.toolInputDelta({
id: tool.id,
name: tool.name,
namespace: tool.namespace,
text,
}),
get input() {
return (parsed ??= Option.getOrElse(parsePartialInput(raw), () => ({})))
},
}
}
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
const raw = inputOverride ?? tool.input
@@ -1,15 +1,20 @@
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import type { ProviderPackage } from "../provider-package.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
import { claudeVersion } from "../protocols/utils/claude-model.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("amazon-bedrock")
export type Config = RouteDefaultsInput & {
export type OpenAIOptionsInput = OpenAIProviderOptionsInput
export type MessagesOptionsInput = AnthropicMessages.ProviderOptionsInput
export type Config = Omit<RouteDefaultsInput, "providerOptions"> & {
/** Bedrock API key. Falls back to `AWS_BEARER_TOKEN_BEDROCK`; bearer auth takes precedence over SigV4. */
readonly apiKey?: string
/** `sigv4` ignores `apiKey` fallbacks from the environment; `bearer` requires a token. */
@@ -20,11 +25,11 @@ export type Config = RouteDefaultsInput & {
/** Shared config profile for the default credential chain. */
readonly profile?: string
readonly region?: string
readonly providerOptions?: OpenAIProviderOptionsInput
readonly providerOptions?: OpenAIProviderOptionsInput | AnthropicMessages.ProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
export type Settings<Options = OpenAIProviderOptionsInput> = ProviderPackage.Settings &
Options & {
readonly apiKey?: string
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
@@ -34,6 +39,8 @@ export type Settings = ProviderPackage.Settings &
readonly topP?: number
}
export type MessagesSettings = Settings<AnthropicMessages.ProviderOptionsInput>
const responsesRoute = Route.make({
id: "bedrock-mantle-responses",
provider: id,
@@ -50,12 +57,35 @@ const chatRoute = OpenAIChat.route.with({
providerMetadataKey: "mantle",
})
export const routes = [responsesRoute, chatRoute]
const messagesRoute = Route.make({
id: "bedrock-mantle-messages",
provider: id,
providerMetadataKey: "mantle",
protocol: {
...AnthropicMessages.protocol,
// Mantle rejects mid-conversation `output_config` on Opus 5.0; support starts at 5.1+.
supportsEffortUpdates: (request) => {
const override = request.model.compatibility?.supportsEffortUpdates
if (override !== undefined) return override
const version = claudeVersion(request.model.id)
return version !== undefined && (version.major > 5 || (version.major === 5 && version.minor >= 1))
},
},
endpoint: Endpoint.path(AnthropicMessages.PATH),
transport: AnthropicMessages.transport<AnthropicMessages.AnthropicMessagesBody>(),
headers: () => ({ "anthropic-version": "2023-06-01" }),
})
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) => {
export const routes = [responsesRoute, chatRoute, messagesRoute]
const configuredRoute = <Body, Prepared>(
route: Route<Body, Prepared>,
input: Config,
defaultBaseURL = (region: string) => `https://bedrock-mantle.${region}.api.aws/v1`,
) => {
const region = BedrockAuth.resolveRegion(input)
return route.with({
endpoint: { baseURL: input.baseURL ?? `https://bedrock-mantle.${region}.api.aws/v1` },
endpoint: { baseURL: input.baseURL ?? defaultBaseURL(region) },
auth: BedrockAuth.resolveAuth(input, region, {
service: "bedrock-mantle",
name: "Bedrock Mantle",
@@ -87,6 +117,11 @@ export const configure = (input: Config = {}) => {
})
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
const configuredChatRoute = configuredRoute(chatRoute, input)
const configuredMessagesRoute = configuredRoute(
messagesRoute,
input,
(region) => `https://bedrock-mantle.${region}.api.aws/anthropic/v1`,
)
const modelDefaults = defaults(input)
const responses = (modelID: string | ModelID) =>
configuredResponsesRoute
@@ -96,11 +131,14 @@ export const configure = (input: Config = {}) => {
configuredChatRoute
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
const messages = (modelID: string | ModelID) =>
configuredMessagesRoute.with(modelDefaults).model<AnthropicMessages.ProviderOptionsInput>({ id: modelID })
return {
id,
model: responses,
chat,
messages,
responses,
configure,
}
@@ -119,7 +157,7 @@ const fromSettings = ({
region,
topP,
...providerOptions
}: Settings) =>
}: Settings<Config["providerOptions"]>) =>
configure({
apiKey,
auth,
@@ -137,6 +175,10 @@ export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptio
modelID,
settings,
) => fromSettings(settings).chat(modelID)
export const messagesModel: ProviderPackage.Definition<
MessagesSettings,
AnthropicMessages.ProviderOptionsInput
>["model"] = (modelID, settings) => fromSettings(settings).messages(modelID)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
@@ -0,0 +1,2 @@
export { messagesModel as model } from "../../amazon-bedrock-mantle.js"
export type { MessagesSettings as Settings } from "../../amazon-bedrock-mantle.js"
+1 -1
View File
@@ -50,7 +50,7 @@ export const gpt5DefaultOptions = (modelID: string): ProviderOptions | undefined
export const openAIDefaultOptions = (modelID: string): ProviderOptions | undefined =>
mergeProviderOptions(openAIProviderOptions({ store: false }), gpt5DefaultOptions(modelID))
export const withOpenAIOptions = <Options extends { readonly providerOptions?: OpenAIProviderOptionsInput }>(
export const withOpenAIOptions = <Options extends { readonly providerOptions?: ProviderOptions }>(
modelID: string,
options: Options,
): Omit<Options, "providerOptions"> & { readonly providerOptions?: ProviderOptions } => {
+27 -31
View File
@@ -1,5 +1,5 @@
import { Effect, Stream } from "effect"
import { makeParser, type Event } from "effect/unstable/encoding/Sse"
import { makeParser } from "effect/unstable/encoding/Sse"
import { AIError, InvalidProviderOutputError } from "../schema/index.js"
/**
@@ -42,43 +42,39 @@ export const sseFraming = (
Stream.decodeText(),
Stream.mapAccumEffect(
() => {
const output: Event[] = []
const output: string[] = []
return {
output,
parser: makeParser((event) => {
if (event._tag === "Event") output.push(event)
if (
event._tag === "Event" &&
(events === undefined || events.has(event.event)) &&
event.data.length > 0 &&
// Some OpenAI-compatible proxies serialize an empty flush as a bare
// `data: null`, between events or after `[DONE]`. No protocol has a
// null event, so it carries nothing and must not abort the stream.
event.data !== "null" &&
// Vertex AI partner models (e.g. `xai/grok-4.6`) send their SSE
// keepalive comment as `data: : keepalive` while reasoning.
event.data !== ": keepalive" &&
(event.data !== "[DONE]" || includeDone || (events !== undefined && event.event !== "message"))
)
output.push(event.data)
}),
}
},
(state, chunk) =>
Effect.gen(function* () {
const error = state.parser.feed(chunk)
if (error)
return yield* new AIError({
reason: new InvalidProviderOutputError({
route: "sse",
message: error.message,
body: chunk,
cause: error,
}),
})
return [state, state.output.splice(0)] as const
}),
(state, chunk) => {
const error = state.parser.feed(chunk)
if (!error) return Effect.succeed([state, state.output.splice(0)] as const)
const reason = new InvalidProviderOutputError({
route: "sse",
message: error.message,
body: chunk,
cause: error,
})
return Effect.fail(new AIError({ reason }))
},
),
Stream.filter(
(event) =>
(events === undefined || events.has(event.event)) &&
event.data.length > 0 &&
// Some OpenAI-compatible proxies serialize an empty flush as a bare
// `data: null`, between events or after `[DONE]`. No protocol has a
// null event, so it carries nothing and must not abort the stream.
event.data !== "null" &&
// Vertex AI partner models (e.g. `xai/grok-4.6`) send their SSE
// keepalive comment as `data: : keepalive` while reasoning.
event.data !== ": keepalive" &&
(event.data !== "[DONE]" || includeDone || (events !== undefined && event.event !== "message")),
),
Stream.map((event) => event.data),
)
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
+26 -1
View File
@@ -6,7 +6,7 @@ import { compileRequest } from "../src/route/client.js"
import { AnthropicMessages } from "../src/protocols/anthropic-messages.js"
import { OpenAIResponses } from "../src/protocols/openai-responses.js"
import { Gemini } from "../src/protocols/gemini.js"
import { GoogleVertexMessages, OpenAI } from "../src/providers.js"
import { AmazonBedrockMantle, GoogleVertexMessages, OpenAI } from "../src/providers.js"
import { applyEffortUpdates } from "../src/effort-updates.js"
import { it, testEffect } from "./lib/effect.js"
import { dynamicResponse } from "./lib/http.js"
@@ -241,6 +241,31 @@ describe("Anthropic Messages effort updates", () => {
}),
)
it.effect("strips markers for Opus 5.0 on Bedrock Mantle Messages while lowering Opus 5.5", () =>
Effect.gen(function* () {
const mantle = AmazonBedrockMantle.configure({ apiKey: "test", region: "us-east-1" })
const opus50 = yield* compileRequest(
LLM.request({
model: mantle.messages("anthropic.claude-opus-5"),
messages: conversation,
providerOptions: { effort: "low" },
}),
)
const opus55 = yield* compileRequest(
LLM.request({
model: mantle.messages("anthropic.claude-opus-5-5"),
messages: conversation,
providerOptions: { effort: "low" },
}),
)
expect(systemMessages(opus50.body)).toHaveLength(0)
expect(opus50.body.output_config).toEqual({ effort: "low" })
expect(systemMessages(opus55.body)).toEqual([{ role: "system", content: [], output_config: { effort: "low" } }])
expect(opus55.body.output_config).toEqual({ effort: "high" })
}),
)
it.effect("strips markers on the Vertex Anthropic route, whose protocol wrapper does not forward support", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -0,0 +1,57 @@
{
"version": 1,
"metadata": {
"model": "anthropic.claude-opus-5-5",
"tags": [
"prefix:bedrock-mantle-messages",
"provider:amazon-bedrock",
"protocol:anthropic-messages",
"reasoning",
"effort-update"
],
"name": "bedrock-mantle-messages/applies-mid-conversation-effort-updates-and-thinking-block-binding-on-opus-5-5",
"recordedAt": "2026-10-04T03:56:23.139Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages",
"headers": {
"anthropic-beta": "interleaved-thinking-2025-05-14,thinking-binding-controls-2026-08-01",
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic.claude-opus-5-5\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Compute 37 * 43 step by step, then reply with only the integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"adaptive\",\"display\":\"summarized\",\"block_binding\":{\"prefix_mismatch_behavior\":\"drop_block\"}},\"output_config\":{\"effort\":\"high\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-5-5\",\"id\":\"msg_bdrk_y7h2x5bz6w6ggmekuxekbi6rseptglsljkwgj36phisnsnu3syea\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":31,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\"},\"input_transformations\":[]}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"They\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" want\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" only\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" the final\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" integer, so\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" I just\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" need\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" to compute 37\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"×43 \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"using the dif\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"ference of squares: 40\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"²−3²\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" = 1600−9 =\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" 1591.\\n\\n\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"CAQSogUKEggSEAEYAjgBQgh0aGlua2luZxIM48GlQ7IvNDW3jk2BGgw0Aq5cIs+m8gjYEIQiMFOyG984KYDLRzchzn2ApX6Rs3r7+CtXL0KeCRsXr1XKY3Y0UNK73Qrjyk94TGFPTSq9BCCZALbMiaeiIDqgB3H8jfrU26rP6kORoTJuF84ByBhXGI9AGo30c3RU2ZueXE/oLvRkK12g8r3TPql2fam1gcC/SeZ627fqGykV6G8WrPH7kthjbR45vnRuaqgFZmyA4C/XCxEH0zSSlVAhXf3Um9sjO/RD8lMJttQCAJSurbB5wmKbUQmZeOjIn8augHV/KmsGcFaHBjGLNfnwfEUwLEbRjNh5eIsmcs9dFfIeYHYNRgtLnBkERACoKPQLp2xU090/wCwrFHIGgLYbklR5wUhGqrAERhOAybqMxlNN3dwd7dV5Tbd16xEghDWb6wUtoxnNQdsUsV/0F/b8Ov9TSu93db/QJAkR74OBMtPuEu914SxtC4NBtqX5YZQAo//O4piaQGzFRVE7TahKzMS5bD111w/qz/cLpqEI/bZMqsXKdzBuMMOAox9TvgTumemdiiPu8tn2ONQeAnuNLjArrr7Jz1mbE9GqPl6aJW9HYWPb0BxCQ/hRXFz5d0v+okmmn5KCV2nD55wk2ySWz9OxIMQUsJ6uEUVOZPk7O0ZJ2ppoLAx6GYq2f55nNcUCsbKB4gB4+QlNp3Tf5IemDdZaY/tGW71dgpYI7iWYYEhqNtlC8pXpoEIbO31IvAHS9BGeQRYQQh1h0muEz4S1+YICPTdXnUmjc0uJ9MGBIKOuBsUhg+E4dzZzOfwS4KteNmJgjhj53WVdjJgRslemZle0tZphrTLLgvZpLZDVIYmOkIpbo2tf5Cflx/rf/NPg7RgB\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"1591\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"container\":null},\"usage\":{\"input_tokens\":31,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":86,\"output_tokens_details\":{\"thinking_tokens\":82}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages",
"headers": {
"anthropic-beta": "interleaved-thinking-2025-05-14,mid-conversation-output-config-2026-07-01,thinking-binding-controls-2026-08-01",
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic.claude-opus-5-5\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Compute 37 * 43 step by step, then reply with only the integer.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"They want only the final integer, so I just need to compute 37×43 using the difference of squares: 40²−3² = 1600−9 = 1591.\\n\\n\",\"signature\":\"CAQSogUKEggSEAEYAjgBQgh0aGlua2luZxIM48GlQ7IvNDW3jk2BGgw0Aq5cIs+m8gjYEIQiMFOyG984KYDLRzchzn2ApX6Rs3r7+CtXL0KeCRsXr1XKY3Y0UNK73Qrjyk94TGFPTSq9BCCZALbMiaeiIDqgB3H8jfrU26rP6kORoTJuF84ByBhXGI9AGo30c3RU2ZueXE/oLvRkK12g8r3TPql2fam1gcC/SeZ627fqGykV6G8WrPH7kthjbR45vnRuaqgFZmyA4C/XCxEH0zSSlVAhXf3Um9sjO/RD8lMJttQCAJSurbB5wmKbUQmZeOjIn8augHV/KmsGcFaHBjGLNfnwfEUwLEbRjNh5eIsmcs9dFfIeYHYNRgtLnBkERACoKPQLp2xU090/wCwrFHIGgLYbklR5wUhGqrAERhOAybqMxlNN3dwd7dV5Tbd16xEghDWb6wUtoxnNQdsUsV/0F/b8Ov9TSu93db/QJAkR74OBMtPuEu914SxtC4NBtqX5YZQAo//O4piaQGzFRVE7TahKzMS5bD111w/qz/cLpqEI/bZMqsXKdzBuMMOAox9TvgTumemdiiPu8tn2ONQeAnuNLjArrr7Jz1mbE9GqPl6aJW9HYWPb0BxCQ/hRXFz5d0v+okmmn5KCV2nD55wk2ySWz9OxIMQUsJ6uEUVOZPk7O0ZJ2ppoLAx6GYq2f55nNcUCsbKB4gB4+QlNp3Tf5IemDdZaY/tGW71dgpYI7iWYYEhqNtlC8pXpoEIbO31IvAHS9BGeQRYQQh1h0muEz4S1+YICPTdXnUmjc0uJ9MGBIKOuBsUhg+E4dzZzOfwS4KteNmJgjhj53WVdjJgRslemZle0tZphrTLLgvZpLZDVIYmOkIpbo2tf5Cflx/rf/NPg7RgB\"},{\"type\":\"text\",\"text\":\"1591\"}]},{\"role\":\"system\",\"content\":[],\"output_config\":{\"effort\":\"low\"}},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Add 9 to that result. Reply with only the integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"adaptive\",\"display\":\"summarized\",\"block_binding\":{\"prefix_mismatch_behavior\":\"drop_block\"}},\"output_config\":{\"effort\":\"high\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-5-5\",\"id\":\"msg_bdrk_yzpmbqh3aag3i7kymdm7ctqputcpeuln3cy44msgbdnctkcweena\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":139,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":4,\"service_tier\":\"standard\"},\"input_transformations\":[]}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"1600\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"container\":null},\"usage\":{\"input_tokens\":139,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":4,\"output_tokens_details\":{\"thinking_tokens\":0}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
}
@@ -0,0 +1,37 @@
{
"version": 1,
"metadata": {
"model": "anthropic.claude-haiku-4-5",
"tags": [
"prefix:bedrock-mantle-messages",
"provider:amazon-bedrock",
"protocol:anthropic-messages",
"reasoning",
"system-update"
],
"name": "bedrock-mantle-messages/lowers-system-updates-to-wrapped-user-text-on-haiku-4-5-with-budget-thinking",
"recordedAt": "2026-10-04T03:56:18.874Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages",
"headers": {
"anthropic-beta": "interleaved-thinking-2025-05-14",
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic.claude-haiku-4-5\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 19 multiplied by 23?\"},{\"type\":\"text\",\"text\":\"<system-update>\\nReply with only the integer.\\n</system-update>\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":1024}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_bdrk_prlwlyj7mdyfbm6ejq74ibwylnwd6deibs6j5xjq6lsc4dzuyzqa\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":65,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\"}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"19\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" × 23 = ?\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n\\nLet me calculate\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" this:\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n19 × 23 =\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" 19 × (20 +\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" 3)\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n= 19 × 20\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" + 19 × 3\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n= 380 + 57\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n= 437\\n\\nOr\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" alternatively\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\":\\n19 × 23 \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"= (\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"20 - 1) × \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"23\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n= 20 × 23 \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"- 23\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n= 460 - 23\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n= 437\\n\\nSo\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" the\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" answer is 437.\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"ErEDCoQBCBIQARgCKkBdVqTX3D5rRVz4O3SlBHJaAUb4Jv4CxOT9igcrHKXsDtU6Wwh9dw7QJbtvpCA0UJh0Wi0PCzu1Tlp8W8jmIU9wMhljbGF1ZGUtaGFpa3UtNC01LTIwMjUxMDAxOABCCHRoaW5raW5nWgwyMzI3NzE4NTY3ODGoAeKch9YGEgyP1xZ80Q63y4Uko9EaDHZo9a+qBGcqeBRLcSIw5/J5NpbvXm6aIqCN58pfjKvW9/35kh6B+t2jS/7dssFzks6J4m+5jtG7BRf9x65GKtkBOhxxxMXrJDYW8RJkXU9iKampxOVFPJuSrvSw43H1MF4V/dt2RFIXeKMNmIJ4umr4nTNVtSlaIEUt+uYmT/F0QL4wXB1Pd40e4VMcBqkJ3CmHkspPJgkyLursJIpDVJQ4TMCHDBzvEObimdAgoAzHM8ahMVOzb9nW9C7ZGE7OFinrq/uR+AzXR0N4zyBIenjcDF76n+OFm3ap3S7ngBQl6+2enaRiseO/UZ4UzgrYYkxk/V4oobGytu64Dn+ngeceb7P+uszDG8BsDs3zXWQdi2w56OvD6HGFVhgB\"}}\n\nevent: content_block_stop\ndata: {Line truncated
}
}
]
}
@@ -0,0 +1,59 @@
{
"version": 1,
"metadata": {
"model": "anthropic.claude-opus-4-8",
"tags": [
"prefix:bedrock-mantle-messages",
"provider:amazon-bedrock",
"protocol:anthropic-messages",
"tool",
"tool-loop",
"reasoning",
"system-update"
],
"name": "bedrock-mantle-messages/replays-signed-thinking-through-a-tool-loop-and-native-system-update",
"recordedAt": "2026-10-04T04:05:26.433Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages",
"headers": {
"anthropic-beta": "interleaved-thinking-2025-05-14",
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic.claude-opus-4-8\",\"system\":[{\"type\":\"text\",\"text\":\"You are a concise assistant.\",\"cache_control\":{\"type\":\"ephemeral\"}}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"First calculate 37 * 43. Then call get_weather for Paris. After receiving the tool result, state both the product and the weather.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"adaptive\",\"display\":\"summarized\"},\"output_config\":{\"effort\":\"medium\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-4-8\",\"id\":\"msg_bdrk_fycfxoffsnlt6mj5wu2urbd2psp276vwn4pddgbaazfim6fxcvhq\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":439,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\"}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"37\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" times\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" 43\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" is 1591\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\", that\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" one\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"'s\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" quick to do\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" my\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"self. Now I'll go\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" ahead and call get\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"_weather.\\n\\n\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"EqoDCngIEhABGAIqQIGpXRhzl8JEPJWxprG5sxaT1WjZ8/kEJgxZTN2qtzM5uC63GiumUjsHzfEIJikrldkFbwPbc0enJCXIoLr0G5UyDWNsYXVkZS1xdWluY2U4AEIIdGhpbmtpbmdaDDIzMjc3MTg1Njc4MagBhaGH1gYSDI7iCpVUnPgOaCirJBoMR20SZNRICFBH7f84IjDDaA7qNPHUlJN+eQa/aYqKn6Z2LobkjfWtscoBJs9sjR8qTy477EOozPy6lTkt1Pwq3wFcgWmJHdh5a0V2iRnSN4CtMEwxVPqC2im7vDCJDpNeAld8VmAO0u594guMJca+jme4lM/MW5oy3WrkmcL6Uc+qRByhUXhSqTWra8w9Xxf5S2nbPcT+FXjx1z1oDR1I8SIMzZrUM1DGOtQp7L/oWPadGwJYZ9EqKf9beRmSoc23kJKF5eA6+ZE9+HbS5gG1XxMDRFvx5rETS+mYMamOsfdowxWGWP7TzqHMhv+VGSCXoy2IXEIPBkXwen0K+dSAtgy/t21chzEmsGlBdBrAn7Ab0rCSqHvbZNZuA2T8CFPtGAE=\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"37 × 43 \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"= 1591.\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":2,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_bdrk_011We3Z1qCBXge7JLoyNwDat\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\": \\\"Pa\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ris\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":2}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"container\":null},\"usage\":{\"input_tokens\":439,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":135,\"output_tokens_details\":{\"thinking_tokens\":73}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages",
"headers": {
"anthropic-beta": "interleaved-thinking-2025-05-14",
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic.claude-opus-4-8\",\"system\":[{\"type\":\"text\",\"text\":\"You are a concise assistant.\",\"cache_control\":{\"type\":\"ephemeral\"}}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"First calculate 37 * 43. Then call get_weather for Paris. After receiving the tool result, state both the product and the weather.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"37 times 43 is 1591, that one's quick to do myself. Now I'll go ahead and call get_weather.\\n\\n\",\"signature\":\"EqoDCngIEhABGAIqQIGpXRhzl8JEPJWxprG5sxaT1WjZ8/kEJgxZTN2qtzM5uC63GiumUjsHzfEIJikrldkFbwPbc0enJCXIoLr0G5UyDWNsYXVkZS1xdWluY2U4AEIIdGhpbmtpbmdaDDIzMjc3MTg1Njc4MagBhaGH1gYSDI7iCpVUnPgOaCirJBoMR20SZNRICFBH7f84IjDDaA7qNPHUlJN+eQa/aYqKn6Z2LobkjfWtscoBJs9sjR8qTy477EOozPy6lTkt1Pwq3wFcgWmJHdh5a0V2iRnSN4CtMEwxVPqC2im7vDCJDpNeAld8VmAO0u594guMJca+jme4lM/MW5oy3WrkmcL6Uc+qRByhUXhSqTWra8w9Xxf5S2nbPcT+FXjx1z1oDR1I8SIMzZrUM1DGOtQp7L/oWPadGwJYZ9EqKf9beRmSoc23kJKF5eA6+ZE9+HbS5gG1XxMDRFvx5rETS+mYMamOsfdowxWGWP7TzqHMhv+VGSCXoy2IXEIPBkXwen0K+dSAtgy/t21chzEmsGlBdBrAn7Ab0rCSqHvbZNZuA2T8CFPtGAE=\"},{\"type\":\"text\",\"text\":\"37 × 43 = 1591.\"},{\"type\":\"tool_use\",\"id\":\"toolu_bdrk_011We3Z1qCBXge7JLoyNwDat\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_bdrk_011We3Z1qCBXge7JLoyNwDat\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperatureC\\\":18}\"}]},{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Reply in French in one short sentence.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"adaptive\",\"display\":\"summarized\"},\"output_config\":{\"effort\":\"medium\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-4-8\",\"id\":\"msg_bdrk_uak5dibczxblk4xunblaiowmid7zhky7ozwinjhpjtcbtgzvuwyq\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":611,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":3,\"service_tier\":\"standard\"}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Le produ\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"it est \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"1591 \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"et il\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" f\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ait beau à Paris av\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ec 18°C.\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"container\":null},\"usage\":{\"input_tokens\":611,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":28,\"output_tokens_details\":{\"thinking_tokens\":0}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
}
@@ -0,0 +1,37 @@
{
"version": 1,
"metadata": {
"model": "anthropic.claude-opus-5",
"tags": [
"prefix:bedrock-mantle-messages",
"provider:amazon-bedrock",
"protocol:anthropic-messages",
"reasoning",
"effort-update"
],
"name": "bedrock-mantle-messages/strips-unsupported-mid-conversation-effort-updates-on-opus-5-0",
"recordedAt": "2026-10-04T03:56:23.956Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages",
"headers": {
"anthropic-beta": "interleaved-thinking-2025-05-14",
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic.claude-opus-5\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 12 + 30?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"42\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Add 8 to that result. Reply with only the integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":1024,\"thinking\":{\"type\":\"adaptive\",\"display\":\"summarized\"},\"output_config\":{\"effort\":\"low\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-5\",\"id\":\"msg_bdrk_5mwa5agz3cvj44w72lno46ljoylvzjhipc4jmraridpzxktjquha\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":38,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\"}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"50\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"container\":null},\"usage\":{\"input_tokens\":38,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":3,\"output_tokens_details\":{\"thinking_tokens\":0}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
}
-68
View File
@@ -7,67 +7,6 @@ const configuration = (provider: string, message: string) =>
expect.objectContaining({ _tag: "ProviderConfiguration", provider, message })
describe("provider package entrypoints", () => {
test("semantic API aliases expose the same contract", async () => {
const modules = await Promise.all([
import("@opencode/ai/providers/openai"),
import("@opencode/ai/providers/openai/responses"),
import("@opencode/ai/providers/openai/chat"),
import("@opencode/ai/providers/anthropic"),
import("@opencode/ai/providers/anthropic-compatible"),
import("@opencode/ai/providers/openai-compatible"),
import("@opencode/ai/providers/openai-compatible/responses"),
import("@opencode/ai/providers/amazon-bedrock"),
import("@opencode/ai/providers/azure"),
import("@opencode/ai/providers/azure/responses"),
import("@opencode/ai/providers/azure/chat"),
import("@opencode/ai/providers/google"),
import("@opencode/ai/providers/google-vertex"),
import("@opencode/ai/providers/google-vertex/gemini"),
import("@opencode/ai/providers/google-vertex/chat"),
import("@opencode/ai/providers/google-vertex/responses"),
import("@opencode/ai/providers/google-vertex/messages"),
import("@opencode/ai/providers/openrouter"),
import("@opencode/ai/providers/xai"),
import("@opencode/ai/providers/amazon-bedrock/mantle"),
import("@opencode/ai/providers/amazon-bedrock/mantle/chat"),
import("@opencode/ai/providers/amazon-bedrock/mantle/responses"),
import("@opencode/ai/providers/togetherai"),
import("@opencode/ai/providers/cerebras"),
import("@opencode/ai/providers/deepinfra"),
import("@opencode/ai/providers/groq"),
import("@opencode/ai/providers/baseten"),
import("@opencode/ai/providers/deepseek"),
import("@opencode/ai/providers/fireworks"),
import("@opencode/ai/providers/cloudflare-ai-gateway"),
import("@opencode/ai/providers/cloudflare-workers-ai"),
import("@opencode/ai/providers/minimax"),
import("@opencode/ai/providers/minimax/messages"),
import("@opencode/ai/providers/minimax/chat"),
import("@opencode/ai/providers/minimax/responses"),
import("@opencode/ai/providers/moonshot"),
import("@opencode/ai/providers/moonshot/chat"),
import("@opencode/ai/providers/moonshot/messages"),
import("@opencode/ai/providers/moonshot/responses"),
import("@opencode/ai/providers/zai"),
import("@opencode/ai/providers/zai/chat"),
import("@opencode/ai/providers/zai-coding-plan"),
import("@opencode/ai/providers/zai-coding-plan/chat"),
import("@opencode/ai/providers/zai-coding-plan/messages"),
import("@opencode/ai/providers/zai-coding-plan/responses"),
import("@opencode/ai/providers/alibaba"),
import("@opencode/ai/providers/alibaba/chat"),
import("@opencode/ai/providers/alibaba/messages"),
import("@opencode/ai/providers/alibaba/responses"),
])
for (const module of modules) expect(module.model).toBeFunction()
expect(modules[0].model).toBe(modules[1].model)
expect(modules[8].model).toBe(modules[9].model)
expect(modules[12].model).toBe(modules[13].model)
expect(modules[19].model).toBe(modules[21].model)
expect(modules[19].model).not.toBe(modules[20].model)
})
test("maps Alibaba API entrypoints onto explicit regional routes", async () => {
const modules = await Promise.all([
import("@opencode/ai/providers/alibaba"),
@@ -75,7 +14,6 @@ describe("provider package entrypoints", () => {
import("@opencode/ai/providers/alibaba/messages"),
import("@opencode/ai/providers/alibaba/responses"),
])
expect(modules[0].model).toBe(modules[1].model)
const settings = {
region: "eu-central-1",
workspaceID: "llm-fixture",
@@ -103,7 +41,6 @@ describe("provider package entrypoints", () => {
import("@opencode/ai/providers/moonshot/messages"),
import("@opencode/ai/providers/moonshot/responses"),
])
expect(modules[0].model).toBe(modules[1].model)
const settings = {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
@@ -147,7 +84,6 @@ describe("provider package entrypoints", () => {
import("@opencode/ai/providers/minimax/chat"),
import("@opencode/ai/providers/minimax/responses"),
])
expect(modules[0].model).toBe(modules[1].model)
const settings = {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
@@ -174,8 +110,6 @@ describe("provider package entrypoints", () => {
import("@opencode/ai/providers/zai-coding-plan/messages"),
import("@opencode/ai/providers/zai-coding-plan/responses"),
])
expect(modules[0].model).toBe(modules[1].model)
expect(modules[2].model).toBe(modules[3].model)
const routes = [
"zai-chat",
"zai-chat",
@@ -458,7 +392,6 @@ describe("provider package entrypoints", () => {
test("selects Vertex entrypoints with the same model contract", async () => {
const GoogleVertex = await import("@opencode/ai/providers/google-vertex")
const GoogleVertexGemini = await import("@opencode/ai/providers/google-vertex/gemini")
const GoogleVertexChat = await import("@opencode/ai/providers/google-vertex/chat")
const GoogleVertexResponses = await import("@opencode/ai/providers/google-vertex/responses")
const GoogleVertexMessages = await import("@opencode/ai/providers/google-vertex/messages")
@@ -483,7 +416,6 @@ describe("provider package entrypoints", () => {
project: "vertex-project",
})
expect(GoogleVertexGemini.model).toBe(GoogleVertex.model)
expect(gemini.route.id).toBe("google-vertex-gemini")
expect(gemini.route.protocol).toBe("gemini")
expect(gemini.route.endpoint.baseURL).toBe("https://aiplatform.googleapis.com/v1/publishers/google")
+254 -21
View File
@@ -1,9 +1,8 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message } from "../../src/index.js"
import { LLM, LLMEvent, LLMRequest, Message, ToolDefinition } from "../../src/index.js"
import { AmazonBedrockMantle } from "../../src/providers.js"
import { model } from "../../src/providers/amazon-bedrock/mantle.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
@@ -19,14 +18,14 @@ const credentials = {
}
describe("Amazon Bedrock Mantle provider", () => {
it.effect("uses Responses by default and exposes Chat explicitly", () =>
it.effect("uses Responses by default and exposes Chat and Messages explicitly", () =>
Effect.gen(function* () {
const provider = AmazonBedrockMantle.configure({ credentials })
expect(provider.model).toBe(provider.responses)
expect(AmazonBedrockMantle.model).toBe(AmazonBedrockMantle.responsesModel)
expect(model).toBe(AmazonBedrockMantle.responsesModel)
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenResponses.httpTransport)
const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" }))
const messages = yield* compileRequest(
LLM.request({ model: provider.messages("anthropic.claude-opus-4-8"), prompt: "Hi", cache: "none" }),
)
const responses = yield* compileRequest(
LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }),
)
@@ -36,6 +35,11 @@ describe("Amazon Bedrock Mantle provider", () => {
protocol: "openai-chat",
body: { model: "openai.gpt-oss-120b" },
})
expect(messages).toMatchObject({
route: "bedrock-mantle-messages",
protocol: "anthropic-messages",
body: { model: "anthropic.claude-opus-4-8", stream: true },
})
expect(responses).toMatchObject({
route: "bedrock-mantle-responses",
protocol: "open-responses",
@@ -43,43 +47,77 @@ describe("Amazon Bedrock Mantle provider", () => {
})
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
expect(provider.chat("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
expect(provider.messages("anthropic.claude-opus-4-8").route.providerMetadataKey).toBe("mantle")
}),
)
it.effect("preserves configured top-p generation defaults for Chat and Responses", () =>
it.effect("preserves configured top-p generation defaults for Chat, Messages, and Responses", () =>
Effect.gen(function* () {
const settings = { apiKey: "test-key", topP: 0.8 }
const chat = yield* compileRequest(
LLM.request({ model: AmazonBedrockMantle.chatModel("openai.gpt-oss-safeguard-20b", settings), prompt: "Hi" }),
)
const messages = yield* compileRequest(
LLM.request({ model: AmazonBedrockMantle.messagesModel("anthropic.claude-opus-4-8", settings), prompt: "Hi" }),
)
const responses = yield* compileRequest(
LLM.request({ model: AmazonBedrockMantle.responsesModel("openai.gpt-oss-120b", settings), prompt: "Hi" }),
)
expect(chat.body.top_p).toBe(0.8)
expect(messages.body.top_p).toBe(0.8)
expect(responses.body.top_p).toBe(0.8)
}),
)
it.effect("uses the Mantle endpoint and signing service", () =>
it.effect("uses the Mantle endpoint and signing service across Responses and Messages", () =>
Effect.gen(function* () {
const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
const model = AmazonBedrockMantle.configure({ credentials, region: "us-west-1" }).responses("openai.gpt-oss-120b")
yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request)
seen.push({ url: request.url, authorization: request.headers.get("authorization") ?? undefined })
return input.respond("", { headers: { "content-type": "text/event-stream" } })
}),
const configured = AmazonBedrockMantle.configure({ credentials, region: "us-west-1" })
for (const selected of [
configured.responses("openai.gpt-oss-120b"),
configured.messages("anthropic.claude-opus-4-8"),
]) {
yield* LLMClient.generate(LLM.request({ model: selected, prompt: "Hi" })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request)
seen.push({ url: request.url, authorization: request.headers.get("authorization") ?? undefined })
return input.respond("", { headers: { "content-type": "text/event-stream" } })
}),
),
),
),
Effect.flip,
Effect.flip,
)
}
expect(seen.map((item) => item.url)).toEqual([
"https://bedrock-mantle.us-west-1.api.aws/v1/responses",
"https://bedrock-mantle.us-west-1.api.aws/anthropic/v1/messages",
])
expect(seen.every((item) => item.authorization?.includes("/us-west-1/bedrock-mantle/aws4_request"))).toBe(true)
}).pipe(withProcessEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined })),
)
it.effect("applies inline cache breakpoints on Mantle Messages", () =>
Effect.gen(function* () {
const model = AmazonBedrockMantle.configure({ apiKey: "test-key" }).messages("anthropic.claude-opus-4-8")
const prepared = yield* compileRequest(
LLM.request({
model,
system: "You are concise.",
messages: [Message.user("Hello")],
cache: "auto",
}),
)
expect(seen[0]?.url).toBe("https://bedrock-mantle.us-west-1.api.aws/v1/responses")
expect(seen[0]?.authorization).toContain("/us-west-1/bedrock-mantle/aws4_request")
expect(prepared.body.system).toEqual([
{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } },
])
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Hello", cache_control: { type: "ephemeral" } }] },
])
}),
)
@@ -201,3 +239,198 @@ describe("Amazon Bedrock Mantle recorded", () => {
}),
)
})
const recordedMessages = recordedTests({
prefix: "bedrock-mantle-messages",
provider: "amazon-bedrock",
protocol: "anthropic-messages",
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
options: { redact: { allowRequestHeaders: ["anthropic-version", "anthropic-beta"] } },
})
const mantleMessages = (modelID: string) =>
AmazonBedrockMantle.configure({
apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture",
region: "us-east-1",
}).messages(modelID)
const weatherTool = ToolDefinition.make({
name: "get_weather",
description: "Get the current weather in a city",
inputSchema: {
type: "object",
properties: { city: { type: "string", enum: ["Paris"] } },
required: ["city"],
additionalProperties: false,
},
})
describe("Amazon Bedrock Mantle Messages recorded", () => {
recordedMessages.effect.with(
"replays signed thinking through a tool loop and native system update",
{ tags: ["tool", "tool-loop", "reasoning", "system-update"], metadata: { model: "anthropic.claude-opus-4-8" } },
() =>
Effect.gen(function* () {
const model = mantleMessages("anthropic.claude-opus-4-8")
const initial = LLM.request({
model,
system: "You are a concise assistant.",
prompt:
"First calculate 37 * 43. Then call get_weather for Paris. After receiving the tool result, state both the product and the weather.",
tools: [weatherTool],
providerOptions: {
thinking: { type: "adaptive", display: "summarized" },
effort: "medium",
},
generation: { maxTokens: 2048 },
})
const first = yield* LLMClient.generate(initial)
expect(first.finishReason.normalized).toBe("tool-calls")
expect(first.toolCalls).toMatchObject([{ name: "get_weather", input: { city: "Paris" } }])
expect(first.reasoning.length).toBeGreaterThan(0)
expect(first.events.some(LLMEvent.is.toolInputDelta)).toBe(true)
const reasoningPart = first.message.content.find((part) => part.type === "reasoning")
const signature = (reasoningPart?.providerMetadata?.mantle as { readonly signature?: unknown } | undefined)
?.signature
expect(typeof signature).toBe("string")
const followUp = LLMRequest.update(initial, {
messages: [
...initial.messages,
first.message,
...first.toolCalls.map((call) =>
Message.tool({ id: call.id, name: call.name, result: { condition: "sunny", temperatureC: 18 } }),
),
Message.system("Reply in French in one short sentence."),
],
})
const compiled = yield* compileRequest(followUp)
expect(compiled.body.messages[1]?.content[0]).toEqual({
type: "thinking",
thinking: first.reasoning,
signature: signature as string,
})
expect(compiled.body.messages.at(-1)).toEqual({
role: "system",
content: [
{ type: "text", text: "Reply in French in one short sentence.", cache_control: { type: "ephemeral" } },
],
})
const second = yield* LLMClient.generate(followUp)
expect(second.finishReason.normalized).toBe("stop")
expect(second.text).toContain("1591")
expect(second.text).toContain("18")
}),
120_000,
)
recordedMessages.effect.with(
"lowers system updates to wrapped user text on Haiku 4.5 with budget thinking",
{ tags: ["reasoning", "system-update"], metadata: { model: "anthropic.claude-haiku-4-5" } },
() =>
Effect.gen(function* () {
const request = LLM.request({
model: mantleMessages("anthropic.claude-haiku-4-5"),
messages: [Message.user("What is 19 multiplied by 23?"), Message.system("Reply with only the integer.")],
providerOptions: {
thinking: { type: "enabled", budgetTokens: 1024 },
},
generation: { maxTokens: 2048 },
})
const compiled = yield* compileRequest(request)
expect(compiled.body.thinking).toEqual({ type: "enabled", budget_tokens: 1024 })
expect(compiled.body.messages.some((message) => message.role === "system")).toBe(false)
const response = yield* LLMClient.generate(request)
expect(response.finishReason.normalized).toBe("stop")
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.text.trim()).toContain("437")
}),
120_000,
)
recordedMessages.effect.with(
"applies mid-conversation effort updates and thinking block binding on Opus 5.5",
{ tags: ["reasoning", "effort-update"], metadata: { model: "anthropic.claude-opus-5-5" } },
() =>
Effect.gen(function* () {
const model = mantleMessages("anthropic.claude-opus-5-5")
const firstRequest = LLM.request({
model,
prompt: "Compute 37 * 43 step by step, then reply with only the integer.",
providerOptions: {
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
},
generation: { maxTokens: 2048 },
})
const firstCompiled = yield* compileRequest(firstRequest)
expect(firstCompiled.body.thinking).toEqual({
type: "adaptive",
display: "summarized",
block_binding: { prefix_mismatch_behavior: "drop_block" },
})
const first = yield* LLMClient.generate(firstRequest)
expect(first.finishReason.normalized).toBe("stop")
expect(first.reasoning.length).toBeGreaterThan(0)
expect(first.text.replaceAll(",", "")).toContain("1591")
const secondRequest = LLM.request({
model,
messages: [
...firstRequest.messages,
first.message,
Message.effort({ effort: "low", previous: "high" }),
Message.user("Add 9 to that result. Reply with only the integer."),
],
providerOptions: {
thinking: { type: "adaptive", display: "summarized" },
effort: "low",
},
generation: { maxTokens: 2048 },
})
const secondCompiled = yield* compileRequest(secondRequest)
expect(secondCompiled.body.output_config).toEqual({ effort: "high" })
expect(secondCompiled.body.messages.filter((message) => message.role === "system")).toEqual([
{ role: "system", content: [], output_config: { effort: "low" } },
])
const second = yield* LLMClient.generate(secondRequest)
expect(second.finishReason.normalized).toBe("stop")
expect(second.text.replaceAll(",", "")).toContain("1600")
}),
120_000,
)
recordedMessages.effect.with(
"strips unsupported mid-conversation effort updates on Opus 5.0",
{ tags: ["reasoning", "effort-update"], metadata: { model: "anthropic.claude-opus-5" } },
() =>
Effect.gen(function* () {
const request = LLM.request({
model: mantleMessages("anthropic.claude-opus-5"),
messages: [
Message.user("What is 12 + 30?"),
Message.assistant("42"),
Message.effort({ effort: "low", previous: "high" }),
Message.user("Add 8 to that result. Reply with only the integer."),
],
providerOptions: {
thinking: { type: "adaptive", display: "summarized" },
effort: "low",
},
generation: { maxTokens: 1024 },
})
const compiled = yield* compileRequest(request)
expect(compiled.body.output_config).toEqual({ effort: "low" })
expect(compiled.body.messages.some((message) => message.role === "system")).toBe(false)
const response = yield* LLMClient.generate(request)
expect(response.finishReason.normalized).toBe("stop")
expect(response.text.trim()).toContain("50")
}),
120_000,
)
})
@@ -43,6 +43,7 @@ describe("native OpenAI-compatible providers", () => {
[Azure.configure({ resourceName: "resource", apiKey: "test" }).responses("model"), "azure"],
[AmazonBedrock.configure({ apiKey: "test" }).model("model"), "bedrock"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).chat("model"), "mantle"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).messages("model"), "mantle"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"), "mantle"],
[Google.configure({ apiKey: "test" }).model("model"), "google"],
[GoogleVertex.configure(vertex).model("model"), "vertex"],
@@ -15,21 +15,25 @@ export function mountTimelineVirtualizer(input: { count: number; rowHeight: numb
function Fixture() {
const [state, setState] = createStore({ pinned: true, ready: false })
const rows = Array.from(
{ length: input.count },
(_, index) => new TimelineRow.UserMessage({ userMessageID: `message-${index}` }),
)
const rowByKey = new Map(rows.map((row) => [TimelineRow.key(row), row]))
const indexes = new Map(rows.map((row, index) => [row.userMessageID, index]))
let viewport!: HTMLDivElement
let content!: HTMLDivElement
let container!: HTMLDivElement
const timeline = createTimelineVirtualizer({
sessionKey: () => "cold-reveal-fixture",
projection: {
rows: () => rows,
rowByKey: () => rowByKey,
activeMessageID: () => undefined,
messageByID: () => new Map(),
messageRowIndex: () => indexes,
messageLastRowIndex: () => indexes,
},
@@ -62,6 +66,7 @@ export function mountTimelineVirtualizer(input: { count: number; rowHeight: numb
host.dataset.observedHeight = String(entries[0].borderBoxSize[0].blockSize)
host.dataset.viewportResizes = String(Number(host.dataset.viewportResizes) + 1)
})
const reveal = new MutationObserver(() => {
if (content.style.visibility === "hidden" || host.dataset.firstReveal) return
// Capture the first reveal, not a later frame after geometry has recovered.
@@ -71,11 +76,14 @@ export function mountTimelineVirtualizer(input: { count: number; rowHeight: numb
pendingMarkdown: content.querySelectorAll('[data-component="markdown"]:not([data-markdown-ready])').length,
viewportHeight: viewport.clientHeight,
scrollTop: viewport.scrollTop,
clipped: mounted
.filter((element) => element.firstElementChild!.getBoundingClientRect().height > element.offsetHeight + 1)
.map((element) => element.dataset.timelineKey),
clipped: mounted.flatMap((element) =>
element.firstElementChild!.getBoundingClientRect().height > element.offsetHeight + 1
? [element.dataset.timelineKey]
: [],
),
})
})
onCleanup(() => {
resize.disconnect()
reveal.disconnect()
@@ -7,6 +7,7 @@ test.use({ permissions: ["clipboard-read", "clipboard-write"] })
async function draft(page: Page) {
const { editor } = await openDraft(page, { name: "ComposerDraft", provider: NO_PROVIDER })
await editor.click()
return editor
}
@@ -15,9 +16,11 @@ async function expectCaretVisible(input: Locator) {
.poll(() =>
input.evaluate((element) => {
const selection = window.getSelection()
if (!selection?.isCollapsed || !selection.rangeCount || !element.contains(selection.anchorNode)) return false
const caret = selection.getRangeAt(0).getBoundingClientRect()
const viewport = (element.closest("[data-scrollable]") ?? element).getBoundingClientRect()
return caret.height > 0 && caret.top >= viewport.top - 1 && caret.bottom <= viewport.bottom + 1
}),
)
@@ -28,11 +31,14 @@ test("keeps a 25000-line crash report editable in a new session", async ({ page
const input = await draft(page)
const text = "Thread 0 Crashed:\n" + "0 Example 0x0000000100000000 frame + 32\n".repeat(25000) + "End of report"
await page.evaluate((text) => navigator.clipboard.writeText(text), text)
const events = await input.evaluateHandle((element) => {
const events = { count: 0 }
element.addEventListener("input", () => events.count++)
return events
})
await page.keyboard.press("ControlOrMeta+V")
await expect.poll(async () => (await input.innerText()) === text).toBe(true)
expect(await events.evaluate((events) => events.count)).toBe(1)
@@ -46,6 +52,7 @@ test("keeps a 25000-line crash report editable in a new session", async ({ page
await expectCaretVisible(input)
const thumb = await scroll.locator(".scroll-view__thumb").boundingBox()
const bounds = await scroll.boundingBox()
if (!thumb || !bounds) throw new Error("Missing composer scrollbar bounds")
await page.mouse.move(thumb.x + thumb.width / 2, thumb.y + thumb.height / 2)
await page.mouse.down()
@@ -58,6 +65,26 @@ test("keeps a 25000-line crash report editable in a new session", async ({ page
await expectCaretVisible(input)
})
test("a key-up never moves the caret back after later navigation", async ({ page }) => {
const input = await draft(page)
await input.pressSequentially("first line")
await expect(input).toHaveText("first line")
// Navigation can land before the next frame; the cursor a key-up recorded must not overwrite it there.
const offset = await input.evaluate(async (element) => {
const text = document.createTreeWalker(element, NodeFilter.SHOW_TEXT).nextNode()!
const selection = window.getSelection()!
selection.collapse(text, 0)
element.dispatchEvent(new KeyboardEvent("keyup", { key: "Home", bubbles: true }))
selection.collapse(text, text.textContent!.length)
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
return selection.anchorOffset
})
expect(offset).toBe("first line".length)
})
for (const [width, direction] of [
[390, "rtl"],
[1280, "ltr"],
@@ -83,6 +110,7 @@ for (const [width, direction] of [
test("pastes plain text without markup and keeps native undo", async ({ page }) => {
const input = await draft(page)
for (const text of [
"single line <b> &amp;",
"first\nsecond",
@@ -110,13 +138,16 @@ test("replaces only the selected text and leaves the caret after the paste", asy
await page.keyboard.type("before replace after")
await expect(input).toHaveText("before replace after")
await page.evaluate(() => document.fonts.ready)
const word = await input.evaluate((element) => {
const range = document.createRange()
range.setStart(element.firstChild!, 7)
range.setEnd(element.firstChild!, 14)
const rect = range.getBoundingClientRect()
return { x: rect.x, y: rect.y + rect.height / 2, width: rect.width }
})
await page.mouse.move(word.x, word.y)
await page.mouse.down()
await page.mouse.move(word.x + word.width, word.y, { steps: 5 })
@@ -141,9 +172,11 @@ test("shows the dropzone and attaches a dropped file", async ({ page }) => {
})
const surface = page.locator('[data-component="new-session"]')
const dropzone = page.locator('[data-component="session-dropzone"]')
const transfer = await page.evaluateHandle(() => {
const value = new DataTransfer()
value.items.add(new File(["Dropzone fixture"], "dropzone.txt", { type: "text/plain" }))
return value
})
@@ -194,6 +227,7 @@ test("lists slash commands in their built-in order", async ({ page }) => {
// A sent message enables /undo, /compact and /fork.
pageMessages: () => ({ items: [{ id: "msg_slash", type: "user", text: "Hello", time: { created: T0 } }] }),
})
await editor.fill("/")
await expect(page.locator('[data-component="composer-suggestions"] [data-suggestion-id] bdi')).toHaveText([
"/init",
@@ -213,6 +247,7 @@ test("lists slash commands in their built-in order", async ({ page }) => {
})
const followUp = "Add follow-up, / for commands, @ for context…"
for (const row of [
{ state: "an idle", copy: "Ask anything, / for commands, @ for context…" },
{ state: "a running", copy: followUp, sessionStatus: { ses_placeholder: { type: "running" } } },
@@ -238,6 +273,7 @@ for (const row of [
sessionStatus: row.sessionStatus,
inbox: row.inbox,
})
const scroll = page.locator('[data-component="composer-scroll"]')
await expect(scroll).toHaveText(row.copy)
await editor.pressSequentially("!")
@@ -250,6 +286,7 @@ test("shows thinking on hover or a non-default selection while preserving keyboa
name: "ComposerThinking",
provider: provider({ id: "thinking-model", name: "Thinking Model", variants: { high: {} } }),
})
const composer = page.locator('[data-component="composer"]')
const control = composer.getByRole("button", { name: "Choose model variant" })
const option = (name: string) => page.getByRole("menuitemradio", { name, exact: true })
@@ -833,6 +833,32 @@ test("a chosen mode the session stops offering shows as Git, and comes back when
await expect(file).toHaveText("beta.ts")
})
test("names a single change source and keeps the review header rows at 48px", async ({ page }) => {
// Turn changes are offered only for Git, so another VCS offers one source.
await openSession(page, { name: "ReviewHeader", project: { vcs: "hg" }, vcsDiff: [fileDiff("src/alpha.ts")] })
await page.getByRole("button", { name: "Toggle review", exact: true }).click()
const panel = page.locator("#review-panel")
const toggle = panel.getByRole("button", { name: "Toggle file tree" })
const header = panel.locator('[data-slot="session-review-v2-sidebar-header"]')
const toolbar = panel.locator('[data-slot="session-review-v2-toolbar"]')
await expect(header.getByText("Git changes", { exact: true })).toBeVisible()
await expect(panel.getByRole("button", { name: "Git changes" })).toHaveCount(0)
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(header).toHaveCSS("height", "48px")
await expect(toolbar).toHaveCSS("height", "48px")
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await expect(toolbar.getByText("Git changes", { exact: true })).toBeVisible()
await expect(toolbar).toHaveCSS("height", "48px")
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(header).toHaveCSS("height", "48px")
await expect(toolbar).toHaveCSS("height", "48px")
})
for (const direction of ["ltr", "rtl"] as const) {
test(`review toggle stays at the header edge in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 1000, height: 900 })
@@ -29,12 +29,14 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })).toHaveCount(0)
const titleBounds = await header.getByRole("heading").boundingBox()
expect(titleBounds).not.toBeNull()
for (const editing of [false, true]) {
if (editing) {
await header.getByRole("heading").click()
await expect(header.getByRole("textbox")).toHaveValue(fixture.expected.targetTitle)
await expect(header.getByRole("textbox")).toBeFocused()
}
await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-left", "4px")
await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-right", "4px")
await expect
@@ -44,8 +46,11 @@ for (const direction of ["ltr", "rtl"] as const) {
control.boundingBox(),
),
)
if (!icon || !title || !menu || !sidebar || !summary || !titleBounds) return false
if (Math.abs(title.y - titleBounds.y) > 0.5 || Math.abs(title.height - titleBounds.height) > 0.5) return false
return direction === "ltr"
? Math.abs(title.x - icon.x - icon.width - 2) <= 0.5 &&
Math.abs(menu.x - title.x - title.width - 2) <= 0.5 &&
@@ -58,6 +63,7 @@ for (const direction of ["ltr", "rtl"] as const) {
})
.toBe(true)
}
await header.getByRole("textbox").press("Escape")
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
@@ -70,6 +76,7 @@ for (const direction of ["ltr", "rtl"] as const) {
await more.click()
const options = page.getByRole("menu")
await expect(options.getByRole("menuitem")).toHaveText(["Rename", "Export…", "Delete…"])
if (direction === "ltr") {
await expect
.poll(async () => {
@@ -77,20 +84,24 @@ for (const direction of ["ltr", "rtl"] as const) {
header.getByRole("button", { name: "More options", exact: true, includeHidden: true }).boundingBox(),
options.boundingBox(),
])
return button && menu ? Math.abs(button.x - menu.x) : Infinity
})
.toBeLessThanOrEqual(1)
}
await expect
.poll(() =>
options.evaluate((element) => {
const menu = element.getBoundingClientRect()
const rtl = getComputedStyle(element).direction === "rtl"
return Math.min(
...Array.from(element.querySelectorAll('[data-slot="menu-v2-item-content"]'), (label) => {
const range = document.createRange()
range.selectNodeContents(label)
const text = range.getBoundingClientRect()
return rtl ? text.left - menu.left : menu.right - text.right
}),
)
@@ -103,6 +114,7 @@ for (const direction of ["ltr", "rtl"] as const) {
const menu = element.getBoundingClientRect()
const divider = element.querySelector('[data-slot="menu-v2-separator"]')?.getBoundingClientRect()
const rows = Array.from(element.querySelectorAll('[role="menuitem"]'), (row) => row.getBoundingClientRect())
return (
!!divider &&
Math.abs(divider.left - menu.left) <= 0.5 &&
@@ -135,6 +147,7 @@ test.describe("rename", () => {
renames.length = 0
page.on("request", (request) => {
const match = new URL(request.url()).pathname.match(/^\/api\/session\/([^/]+)$/)
if (request.method() === "PATCH" && match) renames.push({ sessionID: match[1]!, body: request.postDataJSON() })
})
await mockStressTimeline(page)
@@ -149,8 +162,11 @@ test.describe("rename", () => {
const input = page.locator('input[data-slot="session-title-child"]')
await expect(input).toBeFocused()
await input.fill("Renamed session")
if (commit === "Enter") await input.press("Enter")
if (commit === "blur") await input.press("Tab")
if (commit === "click outside") await page.locator('[data-component="composer-editor"]').click()
await expect(heading(page, "Renamed session")).toBeVisible()
await expect(tabs(page).filter({ hasText: "Renamed session" })).toBeVisible()
@@ -252,6 +268,7 @@ test.describe("revert", () => {
},
{ id: "msg_second", type: "user", text: "Second prompt", time: { created: 4 } },
] satisfies SessionMessageInfo[]
const workspace = {
name: "SessionMessageRevert",
pageMessages: () => ({ items: messages }),
@@ -260,18 +277,54 @@ test.describe("revert", () => {
test("reverts directly to the selected user message", async ({ page }) => {
const staged: { sessionID: string; messageID: string }[] = []
const { session } = await openSession(page, { ...workspace, onRevertStage: (input) => staged.push(input) })
const settles: string[] = []
page.on("request", (request) => {
const path = new URL(request.url()).pathname
const step = ["/interrupt", "/wait", "/revert/stage"].find((suffix) => path.endsWith(`/${session.id}${suffix}`))
if (step) settles.push(step)
})
const message = page.locator('[data-message-id="msg_second"]')
await message.hover()
const response = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
new URL(response.url()).pathname === `/api/session/${session.id}/revert/stage`,
)
await message.getByRole("button", { name: "Revert message" }).click()
expect((await response).ok()).toBe(true)
await expect(page.getByRole("textbox", { name: "Prompt" })).toHaveText("Second prompt")
expect(staged).toEqual([{ sessionID: session.id, messageID: "msg_second" }])
// Interrupt acknowledges before the run settles, and the server refuses to stage while it is active.
expect(settles).toEqual(["/interrupt", "/wait", "/revert/stage"])
})
test("redo restores every reverted message at once, as in the TUI", async ({ page }) => {
const staged: { sessionID: string; messageID: string }[] = []
const { editor } = await openSession(page, {
...workspace,
sessions: [{ id: "ses_revert_redo", title: "Session message revert", revert: { messageID: "msg_first" } }],
onRevertStage: (input) => staged.push(input),
})
const cleared = page.waitForResponse(
(response) =>
response.request().method() === "DELETE" &&
new URL(response.url()).pathname === "/api/session/ses_revert_redo/revert",
)
await editor.pressSequentially("/redo")
await expect(
page.locator('[data-component="composer-suggestions"] [data-suggestion-id][data-active]'),
).toContainText("/redo")
await editor.press("Enter")
expect((await cleared).ok()).toBe(true)
expect(staged).toEqual([])
})
test("hides revert actions in a child session", async ({ page }) => {
+569 -31
View File
@@ -1,10 +1,26 @@
import { expect, test, type Page } from "@playwright/test"
import type { OpenCodeEvent, SessionInboxInfo, SessionMessageInfo } from "@opencode/client/promise"
import { PromptInput } from "@opencode/schema/prompt-input"
import { SessionInbox } from "@opencode/schema/session-inbox"
import { SessionMessage } from "@opencode/schema/session-message"
import { Schema } from "effect"
import { provider } from "../utils/app"
import type { MockServerConfig } from "../utils/mock-server"
import { openSession } from "../utils/workspace"
const sessionID = "ses_session_queue_regression"
// The session.prompt payload, as the server's endpoint validates it.
const PromptBody = Schema.Struct({
id: SessionMessage.ID.pipe(Schema.optional),
...PromptInput.Prompt.fields,
metadata: SessionInbox.UserPayload.fields.metadata,
delivery: SessionInbox.Delivery.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
})
const decodePromptBody = Schema.decodeUnknownSync(PromptBody)
type InboxRow = {
id: string
sessionID: string
@@ -12,11 +28,26 @@ type InboxRow = {
type: "user"
payload: {
text: string
metadata?: Record<string, unknown>
metadata?: typeof PromptBody.Type.metadata
files?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["files"]
agents?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["agents"]
}
delivery: "steer" | "queue"
delivery: SessionInbox.Delivery
}
function storedFile(file: NonNullable<typeof PromptBody.Type.files>[number]) {
const inline = file.uri.startsWith("data:")
const stored: NonNullable<InboxRow["payload"]["files"]>[number] = {
data: inline ? file.uri.slice(file.uri.indexOf(",") + 1) : "",
mime: inline ? file.uri.slice("data:".length, file.uri.indexOf(";")) : "text/plain",
source: inline ? { type: "inline" } : { type: "uri", uri: file.uri },
name: file.name,
description: file.description,
mention: file.mention,
}
return stored
}
function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
@@ -28,16 +59,20 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
payload: { text },
delivery: "queue",
}))
const events: OpenCodeEvent[] = []
const prompts: Record<string, unknown>[] = []
const prompts: (typeof PromptBody.Type)[] = []
const compactions: SessionInboxInfo[] = []
const changes: { inboxID: string; action: "cancel" | "steer" | "queue" }[] = []
const log: string[] = []
let sequence = 0
const emit = <Type extends OpenCodeEvent["type"]>(
type: Type,
data: Extract<OpenCodeEvent, { type: Type }>["data"],
) => {
sequence += 1
// SAFETY: `type` selects `data` from the same OpenCodeEvent member, so the pair is that member.
events.push({
id: `evt_queue_${sequence}`,
type,
@@ -46,28 +81,37 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
data,
} as OpenCodeEvent)
}
return {
rows,
compactions,
prompts,
changes,
log,
messages,
emit,
events: () => events.splice(0),
onPrompt: (input: { sessionID: string; body: Record<string, unknown> }) => {
prompts.push(input.body)
log.push(`prompt:${String(input.body.delivery ?? "steer")}`)
onPrompt: (input: Parameters<NonNullable<MockServerConfig["onPrompt"]>>[0]) => {
const body = decodePromptBody(input.body)
prompts.push(body)
log.push(`prompt:${body.delivery ?? "steer"}`)
const row: InboxRow = {
id: typeof input.body.id === "string" ? input.body.id : `inb_mock_${sequence}`,
id: body.id ?? `inb_mock_${sequence}`,
sessionID: input.sessionID,
time: { created: Date.now() },
type: "user",
payload: {
text: typeof input.body.text === "string" ? input.body.text : "",
...(input.body.metadata === undefined ? {} : { metadata: input.body.metadata as Record<string, unknown> }),
},
delivery: input.body.delivery === "queue" ? "queue" : "steer",
payload: { text: body.text },
delivery: body.delivery ?? "steer",
}
if (body.metadata !== undefined) row.payload.metadata = body.metadata
// Store attachments the way the server materializes them, so re-admissions round-trip.
if (body.files !== undefined) row.payload.files = body.files.map(storedFile)
if (body.agents !== undefined)
row.payload.agents = body.agents.map((agent) => ({ name: agent.name, mention: agent.mention }))
rows.push(row)
emit("session.inbox.enqueued", {
sessionID: input.sessionID,
@@ -75,17 +119,32 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
item: { type: "user", payload: row.payload, delivery: row.delivery },
})
},
onCompact: (input: Parameters<NonNullable<MockServerConfig["onCompact"]>>[0]) => {
log.push("compact")
compactions.push({
id: Schema.decodeUnknownSync(SessionMessage.ID)(input.body.id),
sessionID: input.sessionID,
time: { created: Date.now() },
type: "compaction",
payload: {},
delivery: "steer",
})
},
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => {
changes.push({ inboxID: input.inboxID, action: input.action })
log.push(`${input.action}:${input.inboxID}`)
const index = rows.findIndex((row) => row.id === input.inboxID)
const row = rows[index]
if (!row) return
if (input.action === "cancel") {
rows.splice(index, 1)
emit("session.inbox.cancelled", { sessionID: input.sessionID, inboxID: input.inboxID })
return
}
row.delivery = input.action
emit("session.inbox.delivery.changed", {
sessionID: input.sessionID,
@@ -96,21 +155,38 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
}
}
async function openQueue(page: Page, mock: ReturnType<typeof createQueueMock>, followUpBehavior?: "queue" | "steer") {
async function openQueue(
page: Page,
mock: ReturnType<typeof createQueueMock>,
followUpBehavior?: "queue" | "steer",
revert?: string,
) {
const model = { id: "queue-model", name: "Queue Model" }
await openSession(page, {
const options: Parameters<typeof openSession>[1] = {
name: "SessionQueueRegression",
sessions: [{ id: sessionID, title: "Session queue regression", model: { id: model.id, providerID: "opencode" } }],
sessions: [
{
id: sessionID,
title: "Session queue regression",
model: { id: model.id, providerID: "opencode" },
revert: revert ? { messageID: revert } : undefined,
},
],
provider: provider(model),
pageMessages: () => ({ items: mock.messages }),
sessionStatus: () => ({ [sessionID]: { type: "running" } }),
inbox: () => mock.rows.map((row) => ({ ...row, payload: { ...row.payload } })),
inbox: () => [...mock.rows.map((row) => ({ ...row, payload: { ...row.payload } })), ...mock.compactions],
onPrompt: mock.onPrompt,
onCompact: mock.onCompact,
onInboxChange: mock.onInboxChange,
events: mock.events,
...(followUpBehavior ? { seed: { settings: { general: { followUpBehavior } } } } : {}),
})
}
if (followUpBehavior) options.seed = { settings: { general: { followUpBehavior } } }
await openSession(page, options)
const composer = page.locator('[data-component="composer"]')
return {
composer,
input: composer.locator('[data-component="composer-editor"]'),
@@ -118,6 +194,20 @@ async function openQueue(page: Page, mock: ReturnType<typeof createQueueMock>, f
}
}
async function runSlash(page: Page, input: Awaited<ReturnType<typeof openQueue>>["input"], name: string) {
await input.pressSequentially(`/${name}`)
await expect(page.locator('[data-component="composer-suggestions"] [data-suggestion-id][data-active]')).toContainText(
`/${name}`,
)
await input.press("Enter")
}
function userRow(page: Page, messageID: string) {
return page.locator(
`[data-timeline-virtual-content] [data-timeline-row="UserMessage"][data-message-id="${messageID}"]`,
)
}
test("follow-up preference controls Enter while Mod+Enter uses the alternate delivery", async ({ page }) => {
const mock = createQueueMock([])
const view = await openQueue(page, mock, "queue")
@@ -135,6 +225,9 @@ test("follow-up preference controls Enter while Mod+Enter uses the alternate del
test("dragging reorders queued prompts", async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"])
mock.rows[0].payload.files = [
{ data: "aGk=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/main.ts" }, name: "main.ts" },
]
const view = await openQueue(page, mock)
await expect(view.rows).toHaveCount(3)
@@ -143,6 +236,7 @@ test("dragging reorders queued prompts", async ({ page }) => {
await first.getByRole("button", { name: "Reorder queued prompt" }).hover()
await page.mouse.down()
const target = await third.boundingBox()
if (!target) throw new Error("The target queue row is not visible")
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 10 })
await page.mouse.up()
@@ -157,6 +251,8 @@ test("dragging reorders queued prompts", async ({ page }) => {
"third queued prompt",
"first queued prompt",
])
// A re-admitted file keeps the URI it came from instead of becoming an inline snapshot.
expect(mock.prompts[2].files).toMatchObject([{ uri: "file:///repo/main.ts", name: "main.ts" }])
expect(mock.changes).toEqual([
{ inboxID: "inb_seed_1", action: "cancel" },
{ inboxID: "inb_seed_2", action: "cancel" },
@@ -164,6 +260,56 @@ test("dragging reorders queued prompts", async ({ page }) => {
])
})
for (const change of ["reorder", "edit"] as const) {
test(`a ${change} that cannot re-admit a prompt leaves the queue unchanged`, async ({ page }) => {
const order = ["first queued prompt", "second queued prompt", "third queued prompt"]
const mock = createQueueMock(order)
const admit = mock.onPrompt
// The last prompt's re-admission fails after the server admitted it, so its response is lost.
mock.onPrompt = (input) => {
admit(input)
if (input.body.text === order[2]) throw new Error("Connection lost after admission")
}
const view = await openQueue(page, mock)
await expect(view.rows).toHaveCount(3)
if (change === "reorder") {
const first = view.rows.filter({ hasText: order[0] })
const second = view.rows.filter({ hasText: order[1] })
await first.getByRole("button", { name: "Reorder queued prompt" }).hover()
await page.mouse.down()
const target = await second.boundingBox()
if (!target) throw new Error("The target queue row is not visible")
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 10 })
await page.mouse.up()
}
if (change === "edit") {
await view.rows.getByText(order[1], { exact: true }).click()
await expect(view.input).toHaveText(order[1])
await expect(view.input).toBeFocused()
await view.input.press("End")
await view.input.pressSequentially(", edited")
await view.input.press("Enter")
}
// The replacements admitted before the failure are withdrawn; the originals stay in place.
await expect.poll(() => mock.log.includes(`cancel:${mock.prompts.at(-1)?.id}`)).toBe(true)
await expect.poll(() => mock.changes.length).toBe(mock.prompts.length)
expect(mock.changes.every((entry) => entry.action === "cancel" && !entry.inboxID.startsWith("inb_seed_"))).toBe(
true,
)
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText(order)
expect(mock.rows.map((row) => row.payload.text)).toEqual(order)
// A failed edit keeps its draft in the composer so nothing typed is lost.
if (change === "edit") await expect(view.input).toHaveText("second queued prompt, edited")
})
}
test("editing restores the existing draft and replaces only the original queue position", async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "tighten the error copy", "third queued prompt"])
const view = await openQueue(page, mock)
@@ -180,7 +326,9 @@ test("editing restores the existing draft and replaces only the original queue p
await original.click()
await expect(view.input).toHaveText("tighten the error copy")
await expect(view.input).toBeFocused()
await view.input.fill("tighten the error copy and add a retry hint")
// fill() on a composer that holds text can append, so extend the loaded text instead.
await view.input.press("End")
await view.input.pressSequentially(" and add a retry hint")
await expect(view.input).toHaveText("tighten the error copy and add a retry hint")
await view.input.press("Enter")
@@ -190,16 +338,123 @@ test("editing restores the existing draft and replaces only the original queue p
"third queued prompt",
])
await expect(view.input).toHaveText("my in-progress draft")
// One rewrite from the edited position: the replacement, then the prompts after it.
expect(mock.prompts.map((prompt) => prompt.text)).toEqual([
"tighten the error copy and add a retry hint",
"tighten the error copy and add a retry hint",
"third queued prompt",
])
expect(mock.prompts.every((prompt) => prompt.delivery === "queue" && prompt.resume === false)).toBe(true)
expect(mock.changes.map((change) => change.action)).toEqual(["cancel", "cancel", "cancel"])
expect(mock.changes).toEqual([
{ inboxID: "inb_seed_2", action: "cancel" },
{ inboxID: "inb_seed_3", action: "cancel" },
])
expect(mock.log[0]).toBe("prompt:queue")
})
for (const delivery of ["queue", "steer"] as const) {
test(`editing a prompt the server delivered meanwhile keeps the edit draft (${delivery})`, async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "second queued prompt"])
const view = await openQueue(page, mock, "queue")
await view.rows.getByText("second queued prompt", { exact: true }).click()
await expect(view.input).toHaveText("second queued prompt")
await expect(view.input).toBeFocused()
await view.input.press("End")
await view.input.pressSequentially(", edited")
// The server delivers the original before the edit lands; this client has not heard yet.
mock.rows.splice(1, 1)
await view.input.press(delivery === "queue" ? "Enter" : "ControlOrMeta+Enter")
await expect(page.getByText("Request failed")).toBeVisible()
await expect(view.input).toHaveText("second queued prompt, edited")
expect(mock.prompts).toEqual([])
expect(mock.changes).toEqual([])
})
}
for (const change of ["reorder", "edit"] as const) {
test(`a staged revert refuses a queue ${change}, whose admission would commit it`, async ({ page }) => {
const order = ["first queued prompt", "second queued prompt"]
const mock = createQueueMock(order, [
{ id: "msg_queue_delivered", type: "user", text: "First prompt", time: { created: 1 } },
])
const view = await openQueue(page, mock, undefined, "msg_queue_delivered")
await expect(view.rows).toHaveCount(2)
if (change === "reorder") {
await view.rows.filter({ hasText: order[0] }).getByRole("button", { name: "Reorder queued prompt" }).hover()
await page.mouse.down()
const target = await view.rows.filter({ hasText: order[1] }).boundingBox()
if (!target) throw new Error("The target queue row is not visible")
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 10 })
await page.mouse.up()
}
if (change === "edit") await view.rows.getByText(order[1], { exact: true }).click()
await expect(page.getByText("Redo the revert before you reorder or edit queued prompts")).toBeVisible()
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText(order)
await expect(view.input).toHaveText("")
expect(mock.prompts).toEqual([])
expect(mock.changes).toEqual([])
})
}
test("editing drops a file whose mention was deleted and keeps unmentioned context", async ({ page }) => {
const mock = createQueueMock(["inspect @main.ts here"])
mock.rows[0].payload.files = [
{
data: "aGk=",
mime: "text/plain",
source: { type: "uri", uri: "file:///repo/main.ts" },
name: "main.ts",
mention: { start: 8, end: 16, text: "@main.ts" },
},
{ data: "bm90ZXM=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/notes.md" }, name: "notes.md" },
]
const view = await openQueue(page, mock)
await view.rows.getByText("inspect @main.ts here", { exact: true }).click()
await expect(view.input).toHaveText("inspect @main.ts here")
// The edit focuses the composer a frame after loading it; keys sent earlier miss it.
await expect(view.input).toBeFocused()
await view.input.press("ControlOrMeta+a")
await view.input.pressSequentially("inspect here")
await view.input.press("Enter")
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.prompts[0].files?.map((file) => file.uri)).toEqual(["file:///repo/notes.md"])
})
test("editing a comment-only prompt keeps its notes once", async ({ page }) => {
const note = "The user made the following comment regarding line 2 of /repo/app.ts: check the guard"
const mock = createQueueMock([note])
mock.rows[0].payload.metadata = {
displayText: "",
comments: [
{
path: "/repo/app.ts",
comment: "check the guard",
selection: { startLine: 2, startChar: 0, endLine: 2, endChar: 0 },
origin: "review",
},
],
}
const view = await openQueue(page, mock)
// With no display text, the editor shows the note itself, so the edit owns it as text.
await view.rows.getByText(note, { exact: true }).click()
await expect(view.input).toHaveText(note)
await view.input.press("End")
await view.input.pressSequentially(", please")
await expect(view.input).toHaveText(`${note}, please`)
await view.input.press("Enter")
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.prompts[0].text).toBe(`${note}, please`)
expect(mock.prompts[0].metadata).toMatchObject({ displayText: `${note}, please`, comments: [] })
})
test("Undo cancels only the selected queued prompt and focuses the restored input", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
const text = "Review the detailed error report and check every step of the retry path ".repeat(4)
@@ -241,6 +496,47 @@ test("Undo appends to an existing draft and restores inline attachments", async
expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }])
})
test("Undo of a comment-only prompt keeps the draft text and restores the comment once", async ({ page }) => {
const comment = "check the guard"
const mock = createQueueMock([`The user made the following comment regarding line 2 of /repo/app.ts: ${comment}`])
mock.rows[0].payload.metadata = {
displayText: "",
comments: [
{
path: "/repo/app.ts",
comment,
selection: { startLine: 2, startChar: 0, endLine: 2, endChar: 0 },
origin: "review",
},
],
}
mock.rows[0].payload.files = [
{
data: "aGk=",
mime: "text/plain",
source: { type: "uri", uri: "file:///repo/app.ts?start=2&end=2" },
name: "app.ts",
},
]
const view = await openQueue(page, mock)
await view.input.fill("my draft")
await view.rows.getByRole("button", { name: "Undo" }).click()
await expect(view.rows).toHaveCount(0)
await expect(view.input).toHaveText("my draft")
const cards = view.composer.locator('[data-component="composer-attachments"] [data-component="attachment-card"]')
await expect(cards).toHaveCount(1)
await expect(cards).toContainText(comment)
// The resubmission carries the draft text with no stray break and the comment's file exactly once.
await view.input.press("Enter")
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.prompts[0].text).toBe(
`my draft\nThe user made the following comment regarding line 2 of /repo/app.ts: ${comment}`,
)
expect(mock.prompts[0].files?.map((file) => file.uri)).toEqual(["file:///repo/app.ts?start=2&end=2"])
})
test("Undo preserves mentioned file and agent references on resubmission", async ({ page }) => {
const mock = createQueueMock(["inspect @main.ts with @build"])
mock.rows[0].payload.files = [
@@ -258,23 +554,256 @@ test("Undo preserves mentioned file and agent references on resubmission", async
await expect(view.input).toHaveText("inspect @main.ts with @build")
await view.input.press("Enter")
await expect.poll(() => mock.prompts.length).toBe(1)
// Like the TUI, a restored mention points at the file it named rather than a snapshot of it.
expect(mock.prompts[0].files).toMatchObject([
{ uri: "data:text/plain;base64,aGk=", mention: { text: "@main.ts", start: 8, end: 16 } },
{ uri: "file:///repo/main.ts", mention: { text: "@main.ts", start: 8, end: 16 } },
])
expect(mock.prompts[0].agents).toMatchObject([{ name: "build", mention: { text: "@build" } }])
})
test("Undo does not discard hidden file context", async ({ page }) => {
const mock = createQueueMock(["inspect this file"])
mock.rows[0].payload.files = [
{ data: "aGk=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/main.ts" }, name: "main.ts" },
for (const delivery of ["queue", "steer"] as const) {
test(`${delivery === "queue" ? "Undo" : "/undo of a pending steer"} keeps unmentioned file context`, async ({
page,
}) => {
// Another client (for example ACP) can attach a file without mentioning it.
const mock = createQueueMock(["inspect this file"])
const inboxID = mock.rows[0].id
mock.rows[0].delivery = delivery
mock.rows[0].payload.files = [
{
data: "aGk=",
mime: "text/plain",
source: { type: "uri", uri: "file:///repo/main.ts" },
name: "main.ts",
description: "the failing version",
},
]
const view = await openQueue(page, mock)
if (delivery === "queue") await view.rows.getByRole("button", { name: "Undo" }).click()
if (delivery === "steer") {
await expect(userRow(page, inboxID)).toContainText("inspect this file")
await runSlash(page, view.input, "undo")
}
// Like the TUI, the file returns attached without a mention, as a chip rather than prompt text.
const chip = view.composer.locator('[data-slot="composer-context-file"]')
await expect(view.input).toHaveText("inspect this file")
await expect(chip).toHaveCount(1)
await expect(chip).toContainText("main.ts")
expect(mock.changes).toEqual([{ inboxID, action: "cancel" }])
// Like the TUI's blank Enter, a chip alone sends nothing: without text the draft reads as blank.
await expect(view.input).toBeFocused()
await view.input.press("ControlOrMeta+a")
await view.input.press("Backspace")
await expect(view.input).toHaveText("")
await expect(view.composer.locator('[data-action="composer-submit"]')).toHaveAttribute("aria-label", "Stop")
await expect(chip).toHaveCount(1)
await view.input.pressSequentially("inspect this file")
await view.input.press("Enter")
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.prompts[0].files).toMatchObject([
{ uri: "file:///repo/main.ts", name: "main.ts", description: "the failing version" },
])
expect(mock.prompts[0].files?.[0]?.mention).toBeUndefined()
await expect(chip).toHaveCount(0)
})
}
test("reverting to another prompt replaces the file chips of an earlier restore", async ({ page }) => {
const mock = createQueueMock(
[],
[
{ id: "msg_queue_first", type: "user", text: "First prompt", time: { created: 1 } },
{
id: "msg_queue_second",
type: "user",
text: "Second prompt",
files: [
{ data: "aGk=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/main.ts" }, name: "main.ts" },
],
time: { created: 2 },
},
],
)
const view = await openQueue(page, mock)
const chip = view.composer.locator('[data-slot="composer-context-file"]')
for (const [id, text, chips] of [
["msg_queue_second", "Second prompt", 1],
["msg_queue_first", "First prompt", 0],
] as const) {
const row = userRow(page, id)
await row.hover()
await row.getByRole("button", { name: "Revert message" }).click()
await expect(view.input).toHaveText(text)
await expect(chip).toHaveCount(chips)
}
})
test("/undo withdraws a pending steer without interrupting the running session", async ({ page }) => {
const mock = createQueueMock([])
const view = await openQueue(page, mock, "steer")
const stops: string[] = []
page.on("request", (request) => {
const path = new URL(request.url()).pathname
if (path.endsWith("/interrupt") || path.endsWith("/wait") || path.endsWith("/revert/stage")) stops.push(path)
})
const text = "U2: Also check the retry path."
await view.input.fill(text)
await view.input.press("Enter")
await expect.poll(() => mock.rows.map((row) => row.delivery)).toEqual(["steer"])
await expect(view.input).toHaveText("")
const inboxID = mock.rows[0].id
const pending = userRow(page, inboxID)
await expect(pending).toContainText(text)
await runSlash(page, view.input, "undo")
await expect(pending).toHaveCount(0)
await expect(view.input).toHaveText(text)
expect(mock.changes).toEqual([{ inboxID, action: "cancel" }])
expect(stops).toEqual([])
})
test("reverting a delivered prompt leaves queued prompts alone, as in the TUI", async ({ page }) => {
const mock = createQueueMock(
["U2: queued follow-up"],
[{ id: "msg_queue_delivered", type: "user", text: "First prompt", time: { created: 1 } }],
)
const view = await openQueue(page, mock)
const delivered = userRow(page, "msg_queue_delivered")
const staged = page.waitForResponse(
(response) => new URL(response.url()).pathname === `/api/session/${sessionID}/revert/stage`,
)
await delivered.hover()
await delivered.getByRole("button", { name: "Revert message" }).click()
expect((await staged).ok()).toBe(true)
await expect(view.input).toHaveText("First prompt")
expect(mock.changes).toEqual([])
await expect(view.rows).toHaveCount(1)
})
test("a shell command cannot wait in the queue, as in the TUI", async ({ page }) => {
const mock = createQueueMock([])
const view = await openQueue(page, mock, "queue")
const shells: string[] = []
page.on("request", (request) => {
if (new URL(request.url()).pathname.endsWith(`/${sessionID}/shell`)) shells.push(request.url())
})
await view.input.pressSequentially("!")
await view.input.pressSequentially("git status")
await view.input.press("Enter")
await expect(page.getByText("This prompt cannot be queued")).toBeVisible()
await expect(view.input).toHaveText("git status")
expect(shells).toEqual([])
expect(mock.prompts).toEqual([])
})
test("/compact runs with the composer model and shows a queued compaction, as in the TUI", async ({ page }) => {
const mock = createQueueMock(
[],
[{ id: "msg_queue_delivered", type: "user", text: "First prompt", time: { created: 1 } }],
)
const view = await openQueue(page, mock)
const models: unknown[] = []
page.on("request", (request) => {
if (new URL(request.url()).pathname !== `/api/session/${sessionID}/model`) return
mock.log.push("model")
models.push(request.postDataJSON())
})
await expect(userRow(page, "msg_queue_delivered")).toContainText("First prompt")
await runSlash(page, view.input, "compact")
await expect(page.locator('[data-timeline-row="CompactionQueued"]')).toHaveText("Session compaction queued")
// The row is optimistic; the model switch still precedes the admission.
await expect.poll(() => mock.log).toEqual(["model", "compact"])
expect(models).toMatchObject([{ model: { id: "queue-model", providerID: "opencode" } }])
})
for (const action of ["Move to queue", "Delete"] as const) {
test(`${action} on a pending steer replaces Revert, as in the TUI`, async ({ page }) => {
const mock = createQueueMock(["U2: Also check the retry path."])
const inboxID = mock.rows[0].id
mock.rows[0].delivery = "steer"
const view = await openQueue(page, mock)
const pending = userRow(page, inboxID)
await expect(pending).toContainText("U2: Also check the retry path.")
await pending.hover()
await expect(pending.getByRole("button", { name: "Revert message" })).toHaveCount(0)
await pending.getByRole("button", { name: action }).click()
await expect(pending).toHaveCount(0)
expect(mock.changes).toEqual([{ inboxID, action: action === "Delete" ? "cancel" : "queue" }])
// Neither action returns the prompt to the composer; a moved steer waits in the queue.
await expect(view.input).toHaveText("")
await expect(view.rows).toHaveCount(action === "Delete" ? 0 : 1)
if (action === "Move to queue") await expect(view.rows).toContainText("U2: Also check the retry path.")
})
}
test("/undo returns a pending steer's review comment to the composer", async ({ page }) => {
const display = "tighten this"
const comment = "check the guard"
const mock = createQueueMock([
`${display}\nThe user made the following comment regarding line 2 of /repo/app.ts: ${comment}`,
])
const row = mock.rows[0]
row.delivery = "steer"
row.payload.metadata = {
displayText: display,
comments: [
{
path: "/repo/app.ts",
comment,
selection: { startLine: 2, startChar: 0, endLine: 2, endChar: 0 },
origin: "review",
},
],
}
// The comment's context file, which a resubmission regenerates from the restored comment.
row.payload.files = [
{
data: "aGk=",
mime: "text/plain",
source: { type: "uri", uri: "file:///repo/app.ts?start=2&end=2" },
name: "app.ts",
},
]
const view = await openQueue(page, mock)
await view.rows.getByRole("button", { name: "Undo" }).click()
await expect(page.getByText("Edit this prompt in the queue to preserve its file context")).toBeVisible()
await expect(view.rows).toHaveCount(1)
await expect(view.input).toHaveText("")
expect(mock.changes).toEqual([])
const pending = userRow(page, row.id)
await expect(pending).toContainText(display)
await runSlash(page, view.input, "undo")
await expect(pending).toHaveCount(0)
await expect(view.input).toHaveText(display)
// The comment card is the only card: its context file is regenerated from it, not restored twice.
const cards = view.composer.locator('[data-component="composer-attachments"] [data-component="attachment-card"]')
await expect(cards).toHaveCount(1)
await expect(cards).toContainText(comment)
expect(mock.changes).toEqual([{ inboxID: row.id, action: "cancel" }])
})
for (const delivery of ["steer", "queue"] as const) {
@@ -285,6 +814,7 @@ for (const delivery of ["steer", "queue"] as const) {
const userID = "msg_queue_initial_user"
const assistantID = "msg_queue_continued_assistant"
const followUp = "U2: Also check the retry path."
const mock = createQueueMock(
[],
[
@@ -300,6 +830,7 @@ for (const delivery of ["steer", "queue"] as const) {
},
],
)
const view = await openQueue(page, mock, delivery)
const transcript = page.locator("[data-timeline-virtual-content]")
const thinking = transcript.locator('[data-timeline-row="Thinking"]')
@@ -313,6 +844,7 @@ for (const delivery of ["steer", "queue"] as const) {
const inboxID = mock.rows[0].id
const pending = transcript.locator(`[data-timeline-row="UserMessage"][data-message-id="${inboxID}"]`)
if (delivery === "queue") {
const queued = view.rows.filter({ hasText: followUp })
await expect(queued).toBeVisible()
@@ -322,6 +854,7 @@ for (const delivery of ["steer", "queue"] as const) {
await queued.getByRole("button", { name: "Steer", exact: true }).click()
await expect.poll(() => mock.changes).toEqual([{ inboxID, action: "steer" }])
}
await expect(view.rows).toHaveCount(0)
await expect(pending).toContainText(followUp)
await expect(thinking).toHaveCount(0)
@@ -334,6 +867,7 @@ for (const delivery of ["steer", "queue"] as const) {
model,
started: Date.now(),
})
for (const tool of [
{ id: "tool_queue_read", name: "read", input: { path: "src/queue.ts" } },
{ id: "tool_queue_grep", name: "grep", input: { pattern: "retry", path: "src" } },
@@ -348,6 +882,7 @@ for (const delivery of ["steer", "queue"] as const) {
executed: true,
})
}
mock.emit("session.step.ended", {
sessionID,
assistantMessageID: assistantID,
@@ -377,6 +912,7 @@ for (const delivery of ["steer", "queue"] as const) {
await expect
.poll(async () => {
const boxes = await Promise.all([tools.boundingBox(), pending.boundingBox()])
return boxes.every((box) => box !== null) && boxes[0]!.y + boxes[0]!.height <= boxes[1]!.y
})
.toBe(true)
@@ -395,9 +931,11 @@ for (const delivery of ["steer", "queue"] as const) {
mock.emit("session.step.started", { ...later, agent: "build", model, started: Date.now() })
mock.emit("session.text.started", { ...later, ordinal: 0 })
mock.emit("session.text.ended", { ...later, ordinal: 0, text: "A3: Now checking the retry path for U2." })
const response = transcript
.locator('[data-timeline-row="AssistantPart"]')
.filter({ hasText: "A3: Now checking the retry path for U2." })
await expect(response).toHaveAttribute("data-message-id", inboxID)
await expect(thinking).toHaveCount(0)
await expect(tools.or(pending).or(response)).toHaveText([
@@ -134,6 +134,27 @@ test("a session in a worktree subfolder names its worktree and lists cached work
list.release()
})
test("a subagent shows its details but cannot move to another worktree", async ({ page }) => {
const root = "C:/OpenCode/SmokeWorktrees"
await mockStressTimeline(page, {
sessions: fixture.sessions.map((item) =>
item.id === fixture.targetID
? { ...item, parentID: fixture.sourceID, directory: `${root}/feature` }
: { ...item },
),
worktrees: [{ directory: fixture.directory }, { directory: `${root}/feature`, strategy: "git" }],
})
await page.goto(sessionHref(fixture.targetID))
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await page.getByRole("button", { name: "Session details", exact: true }).click()
await expect(summary.getByText("feature", { exact: true })).toBeVisible()
await expect(summary.getByRole("button", { name: "feature", exact: true })).toHaveCount(0)
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toBeVisible()
})
test("the details follow the routed session when it moves to another worktree", async ({ page }) => {
const root = "C:/OpenCode/SmokeWorktrees"
+113 -11
View File
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"
import type { SessionMessageInfo } from "@opencode/client/promise"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
import {
NO_PROVIDER,
REMOTE_SERVER,
@@ -240,7 +240,111 @@ test("a remote tab stays busy while a child session runs", async ({ page }) => {
await expect(tabA.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
})
test("inactive tabs load attention, but read transcript and inbox only on selection", async ({ page }) => {
test("inactive tabs stay busy while work waits in their inbox, and pulse on a new prompt, as in the TUI", async ({
page,
}) => {
const workspace = await openSession(page, {
name: "TabInbox",
sessions: [a, b, c],
inbox: [
{
id: "inb_tab_b",
sessionID: b.id,
time: { created: 1 },
type: "user",
payload: { text: "Queued follow-up" },
delivery: "queue",
},
// Parked synthetic context, such as a user shell's output, waits without work.
{
id: "inb_tab_c",
sessionID: c.id,
time: { created: 1 },
type: "synthetic",
payload: { text: "Shell output", description: "Shell finished" },
delivery: "steer",
},
],
})
const tab = (id: string) => page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(id)}"])`)
const progress = '[data-component="session-progress-indicator-v2"]'
await expect(tab(b.id).locator(progress)).toBeVisible()
await expect(tab(c.id).locator("[data-titlebar-tab-title]")).toHaveText(c.title)
await expect(tab(c.id).locator(progress)).toHaveCount(0)
await expect(tab(c.id).locator('[data-slot="tab-prompt-pulse"]')).toHaveCount(0)
// The pulse lasts one animation, so record that it appeared rather than racing its removal.
await tab(c.id).evaluate((element) => {
const observer = new MutationObserver(() => {
if (!element.querySelector('[data-slot="tab-prompt-pulse"]')) return
element.setAttribute("data-test-pulsed", "")
observer.disconnect()
})
observer.observe(element, { childList: true, subtree: true })
})
await workspace.push([
{
id: "evt_tab_c_prompt",
created: 2,
type: "session.inbox.enqueued",
durable: { aggregateID: c.id, seq: 1, version: 1 },
data: {
sessionID: c.id,
inboxID: "inb_tab_c_prompt",
item: { type: "user", payload: { text: "Another client's prompt" }, delivery: "queue" },
},
} satisfies Extract<OpenCodeEvent, { type: "session.inbox.enqueued" }>,
])
await expect(tab(c.id)).toHaveAttribute("data-test-pulsed", "")
await expect(tab(c.id).locator('[data-slot="tab-prompt-pulse"]')).toHaveCount(0)
await expect(tab(c.id).locator(progress)).toBeVisible()
await expect(tab(a.id).locator('[data-slot="tab-prompt-pulse"]')).toHaveCount(0)
})
test("selecting a tab with waiting work waits for its transcript instead of showing only the inbox", async ({
page,
}) => {
await openSession(page, {
name: "TabInboxTranscript",
sessions: [a, b],
pageMessages: (id) => ({
items:
id === b.id ? [{ id: "msg_tab_b_history", type: "user", text: "Earlier prompt", time: { created: 1 } }] : [],
}),
inbox: [
{
id: "inb_tab_b_steer",
sessionID: b.id,
time: { created: 2 },
type: "user",
payload: { text: "Pending steer" },
delivery: "steer",
},
],
})
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
// The inactive tab read its inbox, which materializes the pending steer as a transcript row.
await expect(tabB.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
const transcript = await holdRoute(page, (url) => url.pathname === `/api/session/${b.id}/message`)
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(b.id)}"]`).click()
await transcript.arrived
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(0)
transcript.release()
await expect(page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_tab_b_history"]')).toContainText(
"Earlier prompt",
)
await expect(page.locator('[data-timeline-row="UserMessage"][data-message-id="inb_tab_b_steer"]')).toContainText(
"Pending steer",
)
})
test("inactive tabs load attention and inbox, but read the transcript only on selection", async ({ page }) => {
const reads: string[] = []
const mutations: string[] = []
const errors: string[] = []
@@ -282,7 +386,7 @@ test("inactive tabs load attention, but read transcript and inbox only on select
const attention = Promise.all(
[fixture.targetID, fixture.childID].flatMap((id) =>
["permission", "form"].map((kind) =>
["permission", "form", "inbox"].map((kind) =>
page.waitForResponse((response) => new URL(response.url()).pathname === `/api/session/${id}/${kind}`),
),
),
@@ -307,16 +411,14 @@ test("inactive tabs load attention, but read transcript and inbox only on select
state.text,
)
for (const id of [fixture.sourceID, fixture.targetID]) {
expect(reads.filter((path) => path === `/api/session/${id}/message`)).toHaveLength(1)
// Every tab reads its inbox once, since waiting work keeps a tab busy; selection reuses that read.
for (const id of [fixture.sourceID, fixture.targetID, fixture.childID])
expect(reads.filter((path) => path === `/api/session/${id}/inbox`)).toHaveLength(1)
}
expect(
reads.filter(
(path) => path === `/api/session/${fixture.childID}/message` || path === `/api/session/${fixture.childID}/inbox`,
),
).toEqual([])
for (const id of [fixture.sourceID, fixture.targetID])
expect(reads.filter((path) => path === `/api/session/${id}/message`)).toHaveLength(1)
expect(reads.filter((path) => path === `/api/session/${fixture.childID}/message`)).toEqual([])
expect(mutations).toEqual([])
expect(errors).toEqual([])
})
@@ -28,7 +28,9 @@ import {
} from "../utils/timeline"
const detailed = timelinePresets[2].value
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
const completed = {
id: "msg_assistant",
type: "assistant",
@@ -89,28 +91,34 @@ test.describe("static projection", () => {
],
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
)
const aborted = assistantMessage([{ id: "prt_before_abort", type: "text", text: "Before interruption" }], {
id: "msg_1001_assistant_aborted",
error: { type: "MessageAbortedError", message: "Stopped" },
})
const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
id: "msg_1002_assistant_failed",
error: { type: "APIError", message: "Visible provider failure" },
created: 1700000003000,
})
const nextUser = userMessage([userText("Second turn", { id: "prt_second_user" })], {
id: "msg_2000_second_user",
created: 1700000005000,
})
const nextAssistant = assistantMessage([{ id: "prt_second_text", type: "text", text: "Second response" }], {
id: "msg_2001_second_assistant",
parentID: "msg_2000_second_user",
created: 1700000006000,
})
const timeline = await setupTimeline(page, {
settings: { timelineDetail: detailed },
messages: [firstUser, aborted, failed, nextUser, nextAssistant],
})
await timeline.send(status("idle"))
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0))
@@ -140,6 +148,7 @@ test.describe("static projection", () => {
url: "data:text/plain;base64,bm90ZXM=",
},
])
message.metadata = {
displayText: "what's goin on here",
comments: [
@@ -198,6 +207,7 @@ test.describe("static projection", () => {
toolPart("prt_skill_break", "read", "completed", { path: "src/a.ts" }),
toolPart("prt_skill_last", "skill", "completed", { id: "opencode" }),
]
await setupTimeline(page, {
settings: { timelineDetail: detailed },
messages: [userMessage(), assistantMessage(parts)],
@@ -217,6 +227,7 @@ test.describe("static projection", () => {
toolPart("prt_collapsed_patch", "patch", "completed", { patchText: "Update src/value.ts" }),
toolPart("prt_collapsed_read", "read", "completed", { path: "src/value.ts" }),
]
await setupTimeline(page, {
messages: [userMessage(), assistantMessage(parts)],
settings: { shellToolPartsExpanded: true },
@@ -235,6 +246,7 @@ test.describe("static projection", () => {
test("combines adjacent patch calls and repeated files into one group", async ({ page }) => {
const [first, second] = ["prt_patch_first", "prt_patch_second"]
const timeline = await setupTimeline(page, {
settings: { timelineDetail: { ...detailed, edit: { placement: "separate", details: "collapsed" } } },
messages: [
@@ -258,6 +270,7 @@ test("combines adjacent patch calls and repeated files into one group", async ({
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await initial.evaluate((element) => {
const row = element.closest<HTMLElement>("[data-timeline-key]")
if (row) row.dataset.patchRow = "stable"
})
@@ -292,6 +305,7 @@ test("combines adjacent patch calls and repeated files into one group", async ({
test("keeps a failed patch in Used without losing the surviving file choice", async ({ page }) => {
const failed = "prt_grouped_patch_failed"
const surviving = "prt_grouped_patch_surviving"
const timeline = await setupTimeline(page, {
settings: { timelineDetail: detailed },
messages: [
@@ -321,6 +335,7 @@ test("keeps a failed patch in Used without losing the surviving file choice", as
await expect(file).toHaveAttribute("aria-expanded", "true")
await group.evaluate((element) => {
const row = element.closest<HTMLElement>("[data-timeline-key]")
if (row) row.dataset.groupIdentity = "preserved"
})
@@ -331,9 +346,11 @@ test("keeps a failed patch in Used without losing the surviving file choice", as
)
const failedRow = page.locator("[data-timeline-key]", { has: page.locator(`[data-timeline-part-id="${failed}"]`) })
const survivingRow = page.locator("[data-timeline-key]", {
has: page.locator(`[data-timeline-part-id="${surviving}"]`),
})
await expect(group).toHaveAttribute("data-timeline-part-ids", `${failed},${surviving}`)
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:context:/)
@@ -351,15 +368,21 @@ test("does not remount an edit diff when a sibling part arrives", async ({ page
const attachShadow = Element.prototype.attachShadow
Element.prototype.attachShadow = function (init) {
count += 1
return attachShadow.call(this, init)
}
;(window as Window & { __shadowRoots?: { reset(): void; count(): number } }).__shadowRoots = {
// SAFETY: this init script owns `__shadowRoots`; the probe reads below run only after it installed it.
const probe = window as Window & { __shadowRoots?: { reset(): void; count(): number } }
probe.__shadowRoots = {
reset: () => {
count = 0
},
count: () => count,
}
})
const timeline = await setupTimeline(page, {
settings: { editToolPartsExpanded: true, shellToolPartsExpanded: true, showReasoningSummaries: true },
messages: [
@@ -401,8 +424,10 @@ test("does not remount an edit diff when a sibling part arrives", async ({ page
),
],
})
const tool = page.locator(`[data-timeline-part-id="${editID}"]`)
await expect(tool.locator('[data-component="file"][data-mode="diff"]')).toBeVisible()
const markers = () =>
tool.evaluate((element) => {
const nodes = [
@@ -410,12 +435,15 @@ test("does not remount an edit diff when a sibling part arrives", async ({ page
element.querySelector('[data-component="file"][data-mode="diff"]'),
element.closest("[data-timeline-key]"),
element.closest("[data-timeline-row]"),
] as (HTMLElement | null)[]
]
return {
markers: nodes.map((node) => node?.dataset.timelineProbe),
markers: nodes.map((node) => (node instanceof HTMLElement ? node.dataset.timelineProbe : undefined)),
// SAFETY: the init script installed `__shadowRoots` before the page loaded.
shadowRoots: (window as Window & { __shadowRoots?: { count(): number } }).__shadowRoots!.count(),
}
})
await tool.evaluate((element) => {
;[
element,
@@ -426,7 +454,11 @@ test("does not remount an edit diff when a sibling part arrives", async ({ page
if (!(node instanceof HTMLElement)) throw new Error("missing edit tool, diff, row, or frame")
node.dataset.timelineProbe = "before"
})
;(window as Window & { __shadowRoots?: { reset(): void } }).__shadowRoots!.reset()
// SAFETY: the init script installed `__shadowRoots` before the page loaded.
const probe = window as Window & { __shadowRoots?: { reset(): void } }
probe.__shadowRoots!.reset()
})
await timeline.send(partUpdated(textPart("prt_sibling_text", "Streaming added a later assistant text part.")))
@@ -438,10 +470,12 @@ for (const transition of ["reasoning-end", "idle", "retry"] as const) {
test(`stops active Thinking on ${transition} without a following tool`, async ({ page }) => {
const id = `prt_reasoning_stop_${transition}`
const text = "## Inspecting stability\n\nThe timeline is ready for the next step."
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([reasoningPart(id, text)], { completed: false })],
settings: { timelineDetail: { ...detailed, thinking: { placement: "separate", details: "collapsed" } } },
})
const part = page.locator(`[data-timeline-part-id="${renderedPartID(id)}"]`)
const trigger = part.locator('[data-slot="collapsible-trigger"]')
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
@@ -465,6 +499,7 @@ for (const shellDefault of ["collapsed", "expanded"] as const) {
const shellID = `prt_separate_shell_${shellDefault}`
const output = (count: number) => Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistant],
settings: {
@@ -475,9 +510,11 @@ for (const shellDefault of ["collapsed", "expanded"] as const) {
},
},
})
const thought = page.locator(
`[data-timeline-part-id="${renderedPartID(reasoningID)}"] [data-slot="collapsible-trigger"]`,
)
const shellTrigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
const group = page.locator('[data-component="collapsed-tool-group"]')
// One row opens the thought, the other opens and then closes it again.
@@ -485,6 +522,7 @@ for (const shellDefault of ["collapsed", "expanded"] as const) {
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(thought).toHaveAttribute("aria-expanded", "false")
await thought.click()
if (!thoughtOpen) await thought.click()
await expect(thought).toHaveAttribute("aria-expanded", String(thoughtOpen))
@@ -518,6 +556,7 @@ test.describe("Working", () => {
viewport: { width: 390, height: 900 },
settings: { timelineDetail: { ...detailed, thinking: { placement: "separate", details: "collapsed" } } },
})
const working = page.locator('[data-component="session-working"]')
const shimmer = working.locator('[data-component="text-shimmer"]')
await expect(working).toHaveCount(1)
@@ -551,6 +590,7 @@ test.describe("Working", () => {
],
settings: { timelineDetail: detailed },
})
const working = page.locator('[data-component="session-working"]')
const group = page.locator('[data-component="collapsed-tool-group"]')
await expect(working).toBeVisible()
@@ -571,16 +611,20 @@ test.describe("Working", () => {
timelineDetail: { ...timelinePresets[0].value, shell: { placement: "separate", details: "collapsed" } },
},
})
const working = page.locator('[data-component="session-working"]')
await expect(working).toBeVisible()
const id = `prt_working_${name}`
const input =
name === "shell"
? { command: "printf ready" }
: name === "patch"
? { patchText: "*** Begin Patch\n*** Add File: src/working.ts\n+export const ready = true\n*** End Patch" }
: { agent: "general", description: "Inspect working indicator", prompt: "Inspect the timeline." }
const input = {
shell: { command: "printf ready" },
patch: {
patchText: "*** Begin Patch\n*** Add File: src/working.ts\n+export const ready = true\n*** End Patch",
},
subagent: { agent: "general", description: "Inspect working indicator", prompt: "Inspect the timeline." },
}[name]
await timeline.send(partUpdated(toolPart(id, name, "streaming", input)))
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
await expect(tool).toBeVisible()
@@ -590,9 +634,10 @@ test.describe("Working", () => {
name === "patch"
? { files: [{ ...patchFile("src/working.ts", "added"), patch: "@@ -0,0 +1 @@\n+export const ready = true" }] }
: {}
await timeline.send(partUpdated(toolPart(id, name, "running", input, { metadata })))
await expect(tool).toContainText(
name === "shell" ? "printf ready" : name === "patch" ? "working.ts" : "Inspect working indicator",
{ shell: "printf ready", patch: "working.ts", subagent: "Inspect working indicator" }[name],
)
await expect(working).toHaveCount(0)
@@ -627,14 +672,18 @@ test.describe("Working", () => {
],
})
const working = page.locator('[data-component="session-working"]')
if (!grouped) {
await expect(page.locator('[data-timeline-part-id="prt_background_active"]')).toBeVisible()
await expect(working).toHaveCount(0)
return
}
const trigger = page
.locator('[data-component="collapsed-tool-group"]')
.getByRole("button", { name: "Used 2 Shell", exact: true, includeHidden: true })
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(working).toBeVisible()
await trigger.click()
@@ -666,6 +715,7 @@ test.describe("Working", () => {
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([], { completed: false })],
})
const working = page.locator('[data-component="session-working"]')
await expect(working).toBeVisible()
@@ -708,7 +758,9 @@ test.describe("background shortcut", () => {
await expect
.poll(async () => {
const [cardBox, hintBox] = await Promise.all([card.boundingBox(), hint.boundingBox()])
if (!cardBox || !hintBox) return undefined
return { aligned: Math.abs(cardBox.x - hintBox.x) < 2, ordered: cardBox.y < hintBox.y }
})
.toEqual({ aligned: true, ordered: true })
@@ -729,11 +781,11 @@ test.describe("background shortcut", () => {
"prt_grouped_active",
name,
"running",
name === "shell"
? { command: "sleep 10" }
: name === "subagent"
? { agent: "general", description: "Inspect the timeline", prompt: "Inspect it." }
: { filePath: "src/working.ts" },
{
read: { filePath: "src/working.ts" },
shell: { command: "sleep 10" },
subagent: { agent: "general", description: "Inspect the timeline", prompt: "Inspect it." },
}[name],
),
],
{ completed: false },
@@ -745,19 +797,24 @@ test.describe("background shortcut", () => {
const trigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(working).toBeInViewport()
if (name !== "read") {
await expect(backgroundHint(page)).toBeInViewport()
await expect(page.locator('[data-component="session-background-hint-row"]')).toHaveCSS("height", "24px")
}
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
if (name === "subagent") await expect(group.getByText("Inspect the timeline", { exact: true })).toBeVisible()
if (name !== "subagent")
await expect(group.locator('[data-component="text-shimmer"][data-active="true"]')).toBeVisible()
await expect(working).toBeVisible()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(working).toBeVisible()
if (name !== "read") await expectBackgroundRequest(page)
})
}
@@ -765,6 +822,7 @@ test.describe("background shortcut", () => {
test("separates blocking and already-backgrounded work into two rows", async ({ page }) => {
const backgroundID = "ses_background_existing"
const blockingID = "ses_background_blocking"
const timeline = await setupTimeline(page, {
settings: { timelineDetail: detailed },
sessionMessages: [
@@ -781,7 +839,7 @@ test.describe("background shortcut", () => {
name: "subagent",
state: {
status: "completed",
input: { description: "Background task" },
input: { description: "Background task", agent: "explore" },
content: [{ type: "text", text: "working" }],
metadata: { sessionID: backgroundID, status: "running" },
},
@@ -814,7 +872,7 @@ test.describe("background shortcut", () => {
name: "subagent",
state: {
status: "running",
input: { description: "Foreground task" },
input: { description: "Foreground task", agent: "build" },
metadata: { sessionID: blockingID },
},
time: { created: 4 },
@@ -825,8 +883,8 @@ test.describe("background shortcut", () => {
],
sessions: [
session(),
session({ id: backgroundID, parentID: sessionID, title: "Background task" }),
session({ id: blockingID, parentID: sessionID, title: "Foreground task" }),
session({ id: backgroundID, parentID: sessionID, title: "Background task", agent: "explore" }),
session({ id: blockingID, parentID: sessionID, title: "Foreground task", agent: "build" }),
],
sessionStatus: {
[sessionID]: { type: "busy" },
@@ -855,27 +913,58 @@ test.describe("background shortcut", () => {
})
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
await expect(backgroundHint(page)).toBeVisible()
const used = page
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(used).toHaveText(/^Used\s*3\s*Agent, Shell$/)
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await page.getByRole("button", { name: "3 running", exact: true }).click()
const list = page.getByRole("menu", { name: "3 running", exact: true })
await expect(list.getByRole("menuitem")).toHaveText([
/^Build\s*Foreground task$/,
/^Explore\s*Background task$/,
/^Shell\s*sleep 120$/,
])
await list.getByRole("menuitem", { name: /sleep 120/ }).click()
await expect(list).toHaveCount(0)
await expect(used).toHaveAttribute("aria-expanded", "true")
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "2 background tasks running", exact: true })
await expect(summary).toContainText("2")
await summary.click()
const list = page.locator('[data-component="session-background-list"]')
await expect(list).toContainText("Background task")
await expect(list).toContainText("sleep 120")
await expect(list).not.toContainText("Foreground task")
const shellCall = page.locator('[data-timeline-part-id="call_shell_backgrounded"]')
await expect(shellCall.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
await expect(shellCall).toBeInViewport()
await page.getByRole("button", { name: "3 running", exact: true }).click()
const interrupt = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${blockingID}/interrupt`,
)
const foreground = list.getByRole("menuitem", { name: /Foreground task/ })
await foreground.hover()
await foreground.getByRole("button", { name: "Interrupt subagent", exact: true }).click()
await interrupt
await expect(list).toBeVisible()
const kill = page.waitForRequest(
(request) => request.method() === "DELETE" && new URL(request.url()).pathname === "/api/shell/shell_backgrounded",
)
await list.getByRole("menuitem", { name: /sleep 120/ }).hover()
await page.keyboard.press("Delete")
await kill
await page.keyboard.press("Escape")
await expect(list).toHaveCount(0)
await expect(backgroundCard).toContainText("Background task (background)")
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
await expect(
page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'),
).toHaveAttribute("data-active", "true")
// SAFETY: the transport forwards this payload unchanged, and the client marks a session idle from
// `type` and `data.sessionID` alone; the child session's event needs no durable metadata here.
await timeline.transport.send({
id: "evt_background_succeeded",
created: Date.now(),
@@ -885,6 +974,116 @@ test.describe("background shortcut", () => {
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
await expect(backgroundCard).toContainText("Background task (background)")
})
test("moves between siblings and to the parent's shell from inside a subagent", async ({ page }) => {
const siblings = [
{ id: "ses_sibling_one", agent: "explore", description: "Draft TUI proposal" },
{ id: "ses_sibling_two", agent: "build", description: "Fix context controls" },
]
const timeline = await setupTimeline(page, {
settings: { timelineDetail: detailed },
sessionMessages: [
user,
{
id: "msg_sibling_shell",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_sibling_shell",
name: "shell",
state: {
status: "completed",
input: { command: "sleep 120" },
content: [{ type: "text", text: "working" }],
metadata: { shellID: "shell_sibling", status: "running" },
},
time: { created: 2, completed: 3 },
},
],
time: { created: 2, completed: 3 },
},
{
id: "msg_siblings",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: siblings.map((item) => ({
type: "tool" as const,
id: `call_${item.id}`,
name: "subagent",
state: {
status: "running" as const,
input: { description: item.description, agent: item.agent },
metadata: { sessionID: item.id },
},
time: { created: 2 },
})),
time: { created: 2 },
},
],
sessions: [
session(),
...siblings.map((item) =>
session({ id: item.id, parentID: sessionID, title: item.description, agent: item.agent }),
),
],
sessionStatus: Object.fromEntries(
[sessionID, ...siblings.map((item) => item.id)].map((id) => [id, { type: "busy" }]),
),
})
await timeline.transport.send({
id: "evt_sibling_shell_created",
created: 3,
type: "shell.created",
location: { directory },
data: {
info: {
id: "shell_sibling",
status: "running",
command: "sleep 120",
cwd: directory,
shell: "bash",
file: "/tmp/sibling.out",
metadata: { sessionID },
time: { started: 2 },
},
},
})
const header = page.locator("[data-session-title]")
const trigger = header.getByRole("button", { name: "3 running", exact: true })
const list = page.getByRole("menu", { name: "3 running", exact: true })
await trigger.click()
await list.getByRole("menuitem", { name: /Draft TUI proposal/ }).click()
await expect(page).toHaveURL(/\/session\/ses_sibling_one$/)
await trigger.click()
await expect(list.getByRole("menuitem")).toHaveText([
/^Explore\s*Draft TUI proposal/,
/^Build\s*Fix context controls/,
/^Shell\s*sleep 120/,
])
await expect(list.getByRole("menuitem", { name: /Draft TUI proposal/ })).toHaveAttribute("aria-current", "page")
await expect(list.getByRole("menuitem", { name: /Fix context controls/ })).not.toHaveAttribute("aria-current")
await list.getByRole("menuitem", { name: /Fix context controls/ }).click()
await expect(page).toHaveURL(/\/session\/ses_sibling_two$/)
// The shell call lives in the parent: the row opens it there, expanded.
await trigger.click()
await list.getByRole("menuitem", { name: /sleep 120/ }).click()
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}$`))
const shellCall = page.locator('[data-timeline-part-id="call_sibling_shell"]')
await expect(shellCall.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
await expect(shellCall).toBeInViewport()
})
})
test.describe("compaction", () => {
@@ -916,6 +1115,7 @@ test.describe("compaction", () => {
.poll(async () => {
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
const status = await running.boundingBox()
return !!summary && !!status && status.y >= summary.y + summary.height
})
.toBe(true)
@@ -937,6 +1137,7 @@ test.describe("compaction", () => {
.poll(async () => {
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
const done = await compaction.getByText("Session compacted", { exact: true }).boundingBox()
return !!summary && !!done && done.y >= summary.y + summary.height
})
.toBe(true)
@@ -971,16 +1172,21 @@ test.describe("compaction", () => {
shown: undefined,
},
} as const
const labels = Object.values(outcomes).map((outcome) => outcome.label)
// Failed then cancelled share one history, so each boundary must keep its own outcome.
for (const names of [["failed", "cancelled"], ["interrupted"]] as const) {
test(`ends running compactions as ${names.join(", then ")}`, async ({ page }) => {
const timeline = await setupTimeline(page, {
sessionMessages: [user, completed],
...(names[0] === "interrupted" ? { sessionStatus: { [sessionID]: { type: "busy" as const } } } : {}),
})
const timeline = await setupTimeline(
page,
names[0] === "interrupted"
? { sessionMessages: [user, completed], sessionStatus: { [sessionID]: { type: "busy" } } }
: { sessionMessages: [user, completed] },
)
const compactions = page.locator('[data-component="session-compaction-message"]')
for (const [index, name] of names.entries()) {
const outcome = outcomes[name]
await timeline.send(compactionStarted({ sessionID, reason: outcome.reason, recent: "" }))
@@ -988,36 +1194,45 @@ test.describe("compaction", () => {
await expect(compactions).toHaveCount(index + 1)
const compaction = compactions.nth(index)
await expect(compaction).toContainText(outcome.partial)
if (name === "interrupted") {
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
const request = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/interrupt`,
)
await page.getByRole("button", { name: "Stop", exact: true }).click()
await request
}
await timeline.send(compactionFailed({ sessionID, reason: outcome.reason, error: outcome.error }))
await expect(compaction.getByText(outcome.label, { exact: true })).toBeVisible()
}
await expect(compactions).toHaveCount(names.length)
for (const [index, name] of names.entries()) {
const outcome = outcomes[name]
const compaction = compactions.nth(index)
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeVisible()
await expect(compaction.getByText(outcome.label, { exact: true })).toBeVisible()
for (const other of labels.filter((label) => label !== outcome.label))
await expect(compaction.getByText(other, { exact: true })).toHaveCount(0)
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
await expect(compaction.getByRole("status")).toHaveCount(0)
await expect(compaction).not.toContainText(outcome.partial)
if (outcome.shown) await expect(compaction.getByText(outcome.shown, { exact: true })).toBeVisible()
if (!outcome.shown) await expect(compaction).not.toContainText(outcome.error.message)
}
})
}
})
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
const textID = "prt_event_order_text"
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
@@ -1048,11 +1263,13 @@ test("changes timeline presets and saves custom thinking details", async ({ page
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
await expect(slider).toBeEnabled()
await slider.press("Home")
for (const [index, name] of ["Messages only", "Quiet", "Compact", "Detailed", "Everything"].entries()) {
if (index) await slider.press("ArrowRight")
await expect(slider).toHaveValue(String(index))
await expect(slider).toHaveAttribute("aria-valuetext", name)
}
await slider.press("End")
await settings.getByRole("button", { name: "Advanced", exact: true }).click()
const grouped = settings.getByRole("switch", { name: "Thinking grouped", exact: true })
@@ -1132,6 +1349,7 @@ test.describe("shell completion", () => {
],
time: { created: 2, completed: 3 },
}
const state = { finished: false, requests: 0 }
// Larger than one server page (65,536 bytes), so the final output is read in two pages. The card shows its most
// recent 64 KiB.
@@ -1141,9 +1359,11 @@ test.describe("shell completion", () => {
const reads: number[] = []
page.on("request", (request) => {
const url = new URL(request.url())
if (url.pathname === `/api/shell/${background.id}/output`)
reads.push(Number(url.searchParams.get("cursor") ?? 0))
})
const timeline = await setupTimeline(page, {
viewport: { width: grouped ? 390 : 1400, height: 900 },
settings: { shellToolPartsExpanded: !grouped },
@@ -1156,15 +1376,19 @@ test.describe("shell completion", () => {
shellOutput: ({ id }) => {
if (id !== background.id) return "Checking project\n"
state.requests++
return state.finished ? finished : "Checking project\n"
},
})
await page.clock.install()
await page.reload()
await timeline.transport.waitForConnection()
const groupTrigger = page
.locator('[data-component="collapsed-tool-group"]')
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
if (grouped) {
await expect(page.locator('[data-component="collapsed-tool-group"]')).toHaveAttribute(
"data-timeline-part-ids",
@@ -1173,11 +1397,13 @@ test.describe("shell completion", () => {
await expect(groupTrigger).toHaveAttribute("aria-expanded", "false")
await groupTrigger.click()
}
const card = page.locator(`[data-timeline-part-id="call_${background.id}"]`)
const shimmer = card.locator('[data-component="text-shimmer"]')
const other = page.locator('[data-timeline-part-id="call_sh_other"] [data-component="text-shimmer"]')
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect(other).toHaveAttribute("data-active", "true")
if (grouped) await card.locator('[data-slot="collapsible-trigger"]').click()
await expect(card.locator('[data-slot="bash-result"]')).toHaveText("Checking project")
@@ -1202,9 +1428,11 @@ test.describe("shell completion", () => {
reads.length = 0
await page.reload()
if (grouped) await groupTrigger.click()
await expect(shimmer).toHaveAttribute("data-active", "false")
await expect(other).toHaveAttribute("data-active", "true")
// An expanded card reads the exited shell's final output from the start, again in two pages.
if (grouped) return
await expect(card.locator('[data-slot="bash-result"]')).toHaveText(tail)
@@ -1240,6 +1468,7 @@ test.describe("shell completion", () => {
],
shellOutput: ({ id }) => (id === background.id ? "Checking project\n" : undefined),
})
const card = page.locator('[data-timeline-part-id="call_foreground"]')
const shimmer = card.locator('[data-component="text-shimmer"]')
await expect(shimmer).toHaveAttribute("data-active", "true")
@@ -1290,6 +1519,7 @@ async function expectBackgroundRequest(page: Page) {
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
)
await page.keyboard.press("Control+b")
await request
}
@@ -1298,12 +1528,11 @@ function patchFile(file: string, status: "added" | "modified" | "deleted") {
return {
file,
status,
patch:
status === "added"
? "@@ -0,0 +1 @@\n+export const after = true"
: status === "deleted"
? "@@ -1 +0,0 @@\n-export const before = true"
: "@@ -1 +1 @@\n-export const before = true\n+export const after = true",
patch: {
added: "@@ -0,0 +1 @@\n+export const after = true",
deleted: "@@ -1 +0,0 @@\n-export const before = true",
modified: "@@ -1 +1 @@\n-export const before = true\n+export const after = true",
}[status],
additions: status === "deleted" ? 0 : 1,
deletions: status === "added" ? 0 : 1,
}
+13
View File
@@ -219,6 +219,12 @@ const Group = HttpApiGroup.make("mock")
error: MockShellNotFound.pipe(HttpApiSchema.status(404)),
}),
)
.add(
HttpApiEndpoint.delete("shellRemove", "/api/shell/:id", {
params: { id: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("ptyList", "/api/pty", {
success: Json,
@@ -327,6 +333,13 @@ const Group = HttpApiGroup.make("mock")
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionCompact", "/api/session/:sessionID/compact", {
params: SessionParams,
payload: JsonPayload,
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionCommand", "/api/session/:sessionID/command", {
params: SessionParams,
+26 -1
View File
@@ -125,6 +125,7 @@ export interface MockServerConfig {
sessionStatus?: Resolvable<Record<string, { type: string }>>
inbox?: unknown[] | (() => unknown[])
onPrompt?: (input: { sessionID: string; body: Schema.JsonObject }) => void
onCompact?: (input: { sessionID: string; body: Schema.JsonObject }) => void
generate?: (input: { sessionID: string; prompt: string }) => { text: string } | Promise<{ text: string }>
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => void
// Serves `/api/pty*` and mock PTY WebSockets. Created IDs are the first unused `${prefix}<n>` (prefix must start with "pty").
@@ -1032,6 +1033,8 @@ function mockHandlers(
},
})
}),
// Like the server, which also answers 204 for a shell that already ended.
shellRemove: () => noContent,
ptyList: (ctx) =>
ptyEnabled.pipe(
Effect.map(() => {
@@ -1155,7 +1158,12 @@ function mockHandlers(
sessionFormReply: () => noContent,
sessionFormCancel: () => noContent,
sessionBackground: () => noContent,
sessionInbox: () => Effect.sync(() => ({ data: resolve(config.inbox ?? []) })),
sessionInbox: (ctx) =>
Effect.sync(() => ({
data: resolve(config.inbox ?? []).filter(
(item) => Predicate.isObject(item) && item.sessionID === ctx.params.sessionID,
),
})),
sessionPrompt: (ctx) =>
Effect.sync(() => {
const body = Option.getOrElse(decodeJsonObject(ctx.payload), () => ({}))
@@ -1174,6 +1182,23 @@ function mockHandlers(
},
}
}),
// Like the server, a compaction is admitted as a steered inbox item under the proposed ID.
sessionCompact: (ctx) =>
Effect.sync(() => {
const body = Option.getOrElse(decodeJsonObject(ctx.payload), (): Schema.JsonObject => ({}))
config.onCompact?.({ sessionID: ctx.params.sessionID, body })
return {
data: {
id: Predicate.isString(body.id) ? body.id : `inb_mock_${Date.now()}`,
sessionID: ctx.params.sessionID,
time: { created: Date.now() },
type: "compaction",
payload: {},
delivery: "steer",
},
}
}),
sessionGenerate: (ctx) =>
Effect.promise(async () => ({
data: (await config.generate?.({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt })) ?? {
+43 -1
View File
@@ -29,6 +29,7 @@ import { ProgressCircle } from "@opencode/ui/progress-circle"
import type { Upload } from "../attachments/uploads"
import { CommentCard } from "@opencode/session-ui/comment-card"
import { typeLabel } from "@opencode/session-ui/message-file"
import { getFilename } from "@opencode/util/path"
import { Skill } from "@opencode/schema/skill"
import type {
ComposerAgentPart,
@@ -196,6 +197,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
attachments={props.controller.attachments()}
uploads={props.controller.uploads()}
comments={props.controller.comments()}
files={props.controller.files()}
activeCommentID={state.activeContextID}
removeLabel={i18n.t("ui.promptInput.removeAttachment")}
onAttachmentClick={props.controller.openAttachment}
@@ -203,6 +205,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
onUploadCancel={(upload) => props.controller.cancelUpload(upload.id)}
onCommentClick={(comment) => props.controller.toggleContext(comment.key)}
onCommentRemove={(comment) => props.controller.removeContext(comment.key)}
onFileRemove={(file) => props.controller.removeContext(file.key)}
/>
</Show>
@@ -568,6 +571,8 @@ export function ComposerAttachments(props: {
attachments: ComposerAttachment[]
uploads?: Upload[]
comments?: ComposerComment[]
// Files attached without a mention, such as one restored from a prompt another client sent.
files?: Extract<ComposerComment, { type: "file" }>[]
activeCommentID?: string
removeLabel: string
onAttachmentClick?: (attachment: ComposerAttachment) => void
@@ -575,12 +580,20 @@ export function ComposerAttachments(props: {
onUploadCancel?: (upload: Upload) => void
onCommentClick?: (comment: ComposerComment) => void
onCommentRemove?: (comment: ComposerComment) => void
onFileRemove?: (file: Extract<ComposerComment, { type: "file" }>) => void
}) {
const i18n = useI18n()
const percent = (upload: Upload) => (upload.size === 0 ? 100 : Math.floor((upload.loaded / upload.size) * 100))
return (
<Show when={props.attachments.length > 0 || (props.uploads?.length ?? 0) > 0 || (props.comments?.length ?? 0) > 0}>
<Show
when={
props.attachments.length > 0 ||
(props.uploads?.length ?? 0) > 0 ||
(props.comments?.length ?? 0) > 0 ||
(props.files?.length ?? 0) > 0
}
>
<div data-component="composer-attachments" data-slot="composer-attachments" class="relative">
<div
data-slot="composer-attachments-scroll"
@@ -617,6 +630,35 @@ export function ComposerAttachments(props: {
</div>
)}
</For>
<For each={props.files ?? []}>
{(file) => (
<div class="relative group shrink-0" data-slot="composer-context-file">
<Tooltip value={file.path} placement="top" contentClass="break-all">
<AttachmentCard title={file.name ?? getFilename(file.path)} surface="base">
<FileIcon node={{ path: file.path, type: "file" }} />
<span>
{typeLabel(file.name ?? file.path, "text/plain", i18n.t("ui.common.file"))}
<Show when={file.selection}>
{(selection) =>
selection().startLine === selection().endLine
? `:${selection().startLine}`
: `:${selection().startLine}-${selection().endLine}`
}
</Show>
</span>
</AttachmentCard>
</Tooltip>
<button
type="button"
onClick={() => props.onFileRemove?.(file)}
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center hover-reveal group-hover:opacity-100"
aria-label={props.removeLabel}
>
<Icon name="outline-xmark" class="text-v2-icon-icon-contrast" />
</button>
</div>
)}
</For>
<For each={props.attachments}>
{(attachment) => (
<div class="relative group shrink-0">
+102 -5
View File
@@ -1,3 +1,4 @@
import { Predicate } from "effect"
import { createEffect, type Accessor } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useFilteredList } from "@opencode/ui/hooks"
@@ -74,15 +75,21 @@ export function createComposerEditor(input: {
let fileInput: HTMLInputElement | undefined
const draft = createComposerEditorActions(input.store)
const [state, setState] = input.state ?? createComposerEditorState(draft.state.mode)
function addPart(part: ComposerPersistedState["prompt"][number]) {
if (isAttachment(part)) return false
if (part.type === "file" || part.type === "agent") {
draft.addMention(part)
return true
}
draft.addText(part.content)
return true
}
const attachments = input.attachments
? createComposerAttachments({
...input.attachments,
@@ -97,24 +104,30 @@ export function createComposerEditor(input: {
setDraggingType: (type) => dispatch({ type: type ? "drag.enter" : "drag.leave" }),
})
: undefined
const attach = () => {
if (!attachments) {
input.view.add?.onAttach()
return
}
// The add menu leaves focus on its trigger, so return it to the editor once files are picked.
attachments.pick(
() => fileInput?.click(),
() => restoreFocus(),
)
}
const contextList = useFilteredList<ComposerSuggestion>({
items: async (query) => {
const fixed = input.context().filter((item) => item.kind !== "file")
const recent = input.context().filter((item) => item.kind === "file" && item.recent)
if (!query.trim()) return [...fixed, ...recent]
const seen = new Set(recent.map((item) => item.id))
const files = (await input.searchContextFiles(query)).filter((item) => !seen.has(item.id))
return [...fixed, ...recent, ...files]
},
key: (item) => item.id,
@@ -122,47 +135,66 @@ export function createComposerEditor(input: {
skipFilter: (item) => item.kind === "file" && !item.recent,
groupBy: (item) => {
if (item.kind === "reference") return "reference"
if (item.kind === "skill") return "skill"
if (item.kind === "agent") return "agent"
if (item.kind === "resource") return "resource"
if (item.recent) return "recent"
return "file"
},
sortGroupsBy: (a, b) => {
const order = ["reference", "skill", "agent", "resource", "recent", "file"]
return order.indexOf(a.category) - order.indexOf(b.category)
},
})
const commandList = useFilteredList<ComposerSuggestion>({
items: () => input.commands(),
key: (item) => item.id,
filterKeys: ["trigger", "title"],
})
const list = () => (state.popover.type === "context" ? contextList : commandList)
const suggestions = () => list().flat()
const execute = (command: ComposerInteractionCommand) => {
if (command.type === "draft.setText") {
draft.setText(command.value)
return
}
if (command.type === "draft.addText") {
draft.addText(command.value)
return
}
if (command.type === "mention.add") {
if (command.item.mention) draft.addMention(command.item.mention, command.range)
return
}
if (command.type === "popover.filter") {
;(command.popover === "command" ? commandList : contextList).onInput(command.query)
return
}
if (command.type === "suggestion.select") {
const item = suggestions().find((entry) => entry.id === command.id)
if (item) dispatch({ type: "popover.select", item })
return
}
if (command.type === "focus.editor") requestAnimationFrame(() => editor?.focus())
}
@@ -170,26 +202,32 @@ export function createComposerEditor(input: {
const mode = state.mode
const result = transitionComposer(state, event, draft.state)
const action = event.type === "popover.select" ? input.onSuggestionSelect?.(event.item) : undefined
if (event.type === "popover.select") {
if (!action || state.popover.type !== "command-menu") result.commands.forEach(execute)
if (action && event.item.kind === "command" && state.popover.type !== "command-menu") {
draft.setPrompt(
draft.state.prompt.filter(isAttachment),
0,
)
draft.setPrompt(draft.state.prompt.filter(isAttachment), 0)
}
}
setState(reconcile(result.state))
if (mode !== result.state.mode) draft.setMode(result.state.mode)
if (event.type !== "popover.select") result.commands.forEach(execute)
if (mode !== result.state.mode) {
if (result.state.mode === "shell") input.view.shell?.onOpen()
if (result.state.mode === "normal") input.view.shell?.onClose()
}
if (event.type === "popover.select") {
if (!action) return result.handled
action()
}
return result.handled
}
@@ -203,8 +241,10 @@ export function createComposerEditor(input: {
) {
event.preventDefault()
attach()
return true
}
const handled = dispatch({
type: "key.down",
key: event.key,
@@ -213,28 +253,37 @@ export function createComposerEditor(input: {
ids: suggestions().map((item) => item.id),
empty: draft.state.prompt.every((part) => !("content" in part) || part.content.length === 0),
})
if (handled) event.preventDefault()
if (handled && event.key !== "Enter" && event.key !== "Tab" && state.popover.type !== "closed") {
const activeID = state.popover.activeID ?? ""
requestAnimationFrame(() =>
document.querySelector(`[data-suggestion-id="${CSS.escape(activeID)}"]`)?.scrollIntoView({ block: "nearest" }),
)
}
if (handled) return true
if (event.key === "Escape" && input.view.submit.queue?.editing()) {
event.preventDefault()
input.view.submit.queue.cancelEdit()
return true
}
const stop =
input.view.submit.working?.() &&
((event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "g") ||
event.key === "Escape")
if (stop) {
event.preventDefault()
input.view.submit.onStop()
return true
}
if (
!event.altKey &&
!event.ctrlKey &&
@@ -243,14 +292,17 @@ export function createComposerEditor(input: {
navigateHistory(event.key === "ArrowUp" ? "up" : "down")
) {
event.preventDefault()
return true
}
return event.defaultPrevented
}
createEffect(() => {
if (state.popover.type === "closed") return
const ids = suggestions().map((item) => item.id)
if (state.popover.activeID ? ids.includes(state.popover.activeID) : ids.length === 0) return
dispatch({ type: "popover.results", ids })
})
@@ -268,36 +320,48 @@ export function createComposerEditor(input: {
draft.setPrompt(clonePrompt(entry.prompt), cursor)
restoreFocus(cursor)
}
const navigateHistory = (direction: "up" | "down") => {
if (!input.history || !editor) return false
const selection = window.getSelection()
if (!selection?.isCollapsed || !editor.contains(selection.anchorNode)) return false
const text = draft.state.prompt.map((part) => ("content" in part ? part.content : "")).join("")
if (!canNavigateHistory(direction, text, editorCursor(editor), state.historyIndex >= 0)) return false
const entries = input.history.entries(state.mode)
if (direction === "up") {
if (entries.length === 0 || state.historyIndex >= entries.length - 1) return false
if (state.historyIndex === -1) {
setState("savedHistory", {
prompt: clonePrompt(draft.state.prompt),
metadata: input.history.capture?.(),
})
}
const index = state.historyIndex + 1
setState("historyIndex", index)
applyHistory(entries[index]!, "start")
return true
}
if (state.historyIndex < 0) return false
if (state.historyIndex > 0) {
const index = state.historyIndex - 1
setState("historyIndex", index)
applyHistory(entries[index]!, "end")
return true
}
const saved = state.savedHistory ?? { prompt: [{ type: "text", content: "", start: 0, end: 0 }] }
setState({ historyIndex: -1, savedHistory: undefined })
applyHistory(saved, "end")
return true
}
@@ -319,6 +383,9 @@ export function createComposerEditor(input: {
comments() {
return draft.state.context.items.filter((item) => !!item.comment?.trim())
},
files() {
return draft.state.context.items.flatMap((item) => (item.type === "file" && !item.comment?.trim() ? [item] : []))
},
attachments(): ComposerAttachment[] {
return draft.state.prompt.filter(isAttachment)
},
@@ -334,8 +401,10 @@ export function createComposerEditor(input: {
},
removeContext(id: string) {
const item = draft.state.context.items.find((entry) => entry.key === id)
if (item) input.onContextRemove?.(item)
draft.removeContext(id)
if (state.activeContextID === id) dispatch({ type: "context.active", id })
},
openAttachment(attachment: ComposerAttachment) {
@@ -346,14 +415,20 @@ export function createComposerEditor(input: {
},
canSubmit() {
if (input.view.submit.available?.() === false) return false
if (input.view.draftOnly) return false
if (attachments?.pending().length) return false
const persisted = draft.state
if (state.mode === "shell") {
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
}
if (persisted.prompt.some(isAttachment)) return true
if (persisted.context.items.some((item) => !!item.comment?.trim())) return true
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
},
setEditor(element: HTMLElement) {
@@ -363,6 +438,7 @@ export function createComposerEditor(input: {
restoreFocus,
onInput(value: string, prompt?: ComposerPersistedState["prompt"], cursor?: number) {
if (prompt) draft.setPrompt(prompt, cursor)
if (input.view.draftOnly) return
dispatch({ type: "input.changed", value, persist: !prompt })
},
@@ -380,7 +456,9 @@ export function createComposerEditor(input: {
},
submit(options?: { alternate?: boolean }) {
if (input.view.submit.available?.() === false) return
if (input.view.draftOnly) return
if (attachments?.pending().length) return
input.view.submit.onSubmit(options)
dispatch({ type: "popover.close" })
@@ -398,26 +476,32 @@ export function createComposerEditor(input: {
onPaste(event: ClipboardEvent) {
const clipboard = event.clipboardData
const text = clipboard?.getData("text/plain")
if (attachments && shouldHandlePasteAsAttachment(clipboard, !!input.attachments?.readClipboardImage)) {
void attachments.handlePaste(event)
return
}
if (!text) return
event.preventDefault()
// insertText emits input events per line, repeatedly parsing and saving the draft.
// Escaped HTML inserts multiline text once and preserves native selection and undo.
const normalized = text.replace(/\r\n?/g, "\n")
const multiline = normalized.includes("\n")
const value = multiline
? normalized.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
: normalized
if (
typeof document.execCommand === "function" &&
Predicate.isFunction(document.execCommand) &&
document.execCommand(multiline ? "insertHTML" : "insertText", false, value)
)
return
const target = event.currentTarget
const selection = window.getSelection()
if (!(target instanceof HTMLElement) || !selection?.rangeCount || !target.contains(selection.anchorNode)) return
const range = selection.getRangeAt(0)
range.deleteContents()
@@ -442,9 +526,11 @@ export function createComposerEditor(input: {
onDrop(event: DragEvent) {
event.preventDefault()
dispatch({ type: "drag.leave" })
if (attachments) {
event.stopPropagation()
void attachments.handleDrop(event)
return
}
},
@@ -467,23 +553,30 @@ export type ComposerEditorModel = ReturnType<typeof createComposerEditor>
export function shouldHandlePasteAsAttachment(clipboard: DataTransfer | null, readClipboardImage: boolean) {
if (Array.from(clipboard?.items ?? []).some((item) => item.kind === "file")) return true
if (Array.from(clipboard?.types ?? []).some((type) => type.startsWith("text/"))) return false
return readClipboardImage
}
function canNavigateHistory(direction: "up" | "down", text: string, cursor: number, inHistory: boolean) {
const position = Math.max(0, Math.min(cursor, text.length))
if (inHistory) return position === 0 || position === text.length
if (direction === "up") return position === 0 && text.length === 0
return position === text.length
}
function editorCursor(editor: HTMLElement) {
const selection = window.getSelection()
if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0
const range = selection.getRangeAt(0).cloneRange()
range.selectNodeContents(editor)
range.setEnd(selection.anchorNode!, selection.anchorOffset)
return range.toString().length
}
@@ -492,8 +585,10 @@ function setEditorCursor(editor: HTMLElement | undefined, cursor: number) {
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT)
let remaining = cursor
let node = walker.nextNode()
while (node) {
const length = node.textContent?.length ?? 0
if (remaining <= length) {
const range = document.createRange()
range.setStart(node, remaining)
@@ -501,8 +596,10 @@ function setEditorCursor(editor: HTMLElement | undefined, cursor: number) {
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
return
}
remaining -= length
node = walker.nextNode()
}
+1
View File
@@ -280,6 +280,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
title: language.t("prompt.toast.modelAgentRequired.title"),
description: language.t("prompt.toast.modelAgentRequired.description"),
}),
unqueueable: () => showToast({ title: language.t("prompt.toast.unqueueable.title") }),
failed: (kind, error) =>
showToast({
title: language.t(sendFailedTitle[kind]),
+98 -4
View File
@@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageUser } from "@opencode/client/promise"
import { extractPromptComments, extractPromptFromMessage } from "./prompt"
import { extractPromptContext, extractPromptFromMessage } from "./prompt"
import { buildPromptRequest } from "./request"
import { contextItemKey } from "./schema"
describe("extractPromptFromMessage", () => {
test("restores uploaded attachments in order, optimistic data URLs, and review comments", () => {
@@ -41,9 +43,10 @@ describe("extractPromptFromMessage", () => {
blob: { id: url, url },
})),
])
expect(extractPromptComments(message)).toMatchObject([
{ path: "src/app.ts", comment: "check this", origin: "review" },
])
expect(extractPromptContext(message)).toMatchObject({
comments: [{ type: "file", path: "src/app.ts", comment: "check this", commentOrigin: "review" }],
files: [],
})
})
test("keeps the directory of a file mention without an at-sign", () => {
@@ -81,6 +84,97 @@ describe("extractPromptFromMessage", () => {
expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "model text" })
})
test("restores every input another client sent without duplicating review comment files", () => {
const message = {
id: "msg_1",
type: "user",
text: "model text",
metadata: {
displayText: "日本 @main.ts",
comments: [
{
path: "/repo/app.ts",
comment: "check this",
selection: { startLine: 2, startChar: 0, endLine: 2, endChar: 0 },
},
],
attachments: [{ name: "report.zip", mime: "application/zip", path: "/repo/report.zip" }],
},
files: [
// Display-width offsets, as the TUI records them after wide characters.
{
data: "",
mime: "text/plain",
source: { type: "uri", uri: "file:///repo/main.ts" },
name: "main.ts",
mention: { text: "@main.ts", start: 5, end: 13 },
},
{
data: "aGk=",
mime: "text/plain",
source: { type: "uri", uri: "file:///repo/app.ts?start=2&end=2" },
name: "app.ts",
},
{
data: "bm90ZXM=",
mime: "text/markdown",
source: { type: "uri", uri: "file:///repo/notes.md" },
name: "notes.md",
description: "the failing version",
},
{
data: "c3JjLw==",
mime: "application/x-directory",
source: { type: "uri", uri: "file:///repo/src" },
name: "src",
},
// The workspace root has no relative path.
{ data: "", mime: "application/x-directory", source: { type: "uri", uri: "file:///repo" }, name: "repo" },
// An empty file is stored with empty data, not missing data.
{ data: "", mime: "text/plain", source: { type: "inline" }, name: "empty.txt" },
],
agents: [{ name: "plan" }],
skills: [{ id: "review", name: "Review" }],
time: { created: 1 },
} satisfies SessionMessageUser
expect(extractPromptFromMessage(message, { directory: "/repo" })).toMatchObject([
{ type: "text", content: "日本 " },
{ type: "file", content: "@main.ts", url: "file:///repo/main.ts" },
{ type: "text", content: " " },
{ type: "agent", content: "@plan", name: "plan" },
{ type: "text", content: " " },
{ type: "skill", content: "@review", id: "review" },
{ type: "image", filename: "empty.txt", mime: "text/plain", blob: { url: "data:text/plain;base64," } },
{ type: "path", filename: "report.zip", path: "/repo/report.zip" },
])
// Unmentioned file references return as context chips, not mention text, as the TUI keeps them.
const context = extractPromptContext(message, { directory: "/repo" })
expect(context.files).toEqual([
{ type: "file", path: "notes.md", name: "notes.md", description: "the failing version" },
// A directory keeps its URI rather than turning into a snapshot of its listing.
{ type: "file", path: "src", name: "src" },
{ type: "file", path: "/repo", name: "repo" },
])
// Resubmitting the restored context sends each file once, under the URI, name, and description it had.
expect(
buildPromptRequest({
prompt: [],
context: [...context.comments, ...context.files].map((item) => ({ ...item, key: contextItemKey(item) })),
images: [],
text: "",
sessionDirectory: "/repo",
}).files.map((file) => [file.uri, file.name, file.description]),
).toEqual([
["file:///repo/app.ts?start=2&end=2", "app.ts", undefined],
["file:///repo/notes.md", "notes.md", "the failing version"],
["file:///repo/src", "src", undefined],
["file:///repo", "repo", undefined],
])
})
test("restores skill mentions as structured Composer parts", () => {
const message = {
id: "msg_1",
+162 -80
View File
@@ -1,7 +1,10 @@
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "@/composer/state"
import type { FileAttachmentPart, ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
import { createLegacyBlobReference } from "@/runtime/persistence/drafts"
import type { SessionMessageUser } from "@opencode/client/promise"
import { readPromptPresentation } from "./comment-note"
import { commentContextItem, readPromptPresentation } from "./comment-note"
import { buildPromptRequest } from "./request"
import { contextItemKey, type FileContextItem } from "./schema"
import { createPathHelpers, decodeFilePath, stripFileProtocol, stripQueryAndHash } from "@/workspaces/files/path"
import { Skill } from "@opencode/schema/skill"
type Inline =
@@ -17,8 +20,10 @@ type Inline =
startChar: number
endChar: number
}
url?: string
mime?: string
filename?: string
description?: string
}
| {
type: "agent"
@@ -38,11 +43,14 @@ type Inline =
function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["selection"] {
const queryIndex = url.indexOf("?")
if (queryIndex === -1) return undefined
const params = new URLSearchParams(url.slice(queryIndex + 1))
const startLine = Number(params.get("start"))
const endLine = Number(params.get("end"))
if (!Number.isFinite(startLine) || !Number.isFinite(endLine)) return undefined
return {
startLine,
endLine,
@@ -51,24 +59,38 @@ function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["s
}
}
// A user message or a pending inbox item: the materialized transcript row shares the inbox payload's shape.
export type PromptSource = Pick<SessionMessageUser, "id" | "text" | "files" | "agents" | "skills" | "metadata">
// Restores the composer content that produced a prompt, losing nothing it carried. File references
// without a mention return as context through extractPromptContext; unmentioned agents and skills
// return as trailing mentions, and inline files as attachments.
export function extractPromptFromMessage(
message: SessionMessageUser,
message: PromptSource,
opts?: { directory?: string; attachmentName?: string },
): Prompt {
const text = readPromptPresentation(message.metadata)?.displayText ?? message.text
const presentation = readPromptPresentation(message.metadata)
const text = presentation?.displayText ?? message.text
const directory = opts?.directory
const attachmentName = opts?.attachmentName ?? "attachment"
const toRelative = (path: string) => {
if (!directory) return path
const prefix = directory.endsWith("/") ? directory : directory + "/"
if (path.startsWith(prefix)) return path.slice(prefix.length)
return path
}
const inline: Inline[] = []
const images: ImageAttachmentPart[] = []
const trailing: Inline[] = []
const attachments: (ImageAttachmentPart | PathAttachmentPart)[] = []
for (const file of message.files ?? []) {
const mention = file.mention
const uri = file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`
if (mention) {
inline.push({
type: "file",
@@ -77,63 +99,130 @@ export function extractPromptFromMessage(
value: mention.text,
path: toRelative(mention.text.startsWith("@") ? mention.text.slice(1) : mention.text),
selection: selectionFromFileUrl(uri),
url: uri,
mime: file.mime,
filename: file.name,
description: file.description,
})
continue
}
// A file reference returns as a context chip through extractPromptContext.
if (file.source.type === "uri" && file.source.uri.startsWith("file:")) continue
// Stored files carry their bytes, and an empty file has empty data; only a local handoff row
// leaves data empty because its bytes live at a blob URL.
const stored = file.source.type === "inline" || !!file.data
const dataUrl =
file.source.type === "uri" && file.source.uri.startsWith("data:")
? file.source.uri
: file.data
: stored
? `data:${file.mime};base64,${file.data}`
: undefined
if (!dataUrl) continue
images.push({
attachments.push({
type: "image",
id: `${message.id}:file:${images.length}`,
id: `${message.id}:file:${attachments.length}`,
filename: file.name ?? attachmentName,
mime: file.mime,
blob: createLegacyBlobReference(dataUrl),
})
}
for (const agent of message.agents ?? []) {
const mention = agent.mention
if (!mention) continue
inline.push({
type: "agent",
start: mention.start,
end: mention.end,
value: mention.text,
name: agent.name,
})
const part = { type: "agent" as const, name: agent.name }
if (!mention) trailing.push({ ...part, start: -1, end: -1, value: `@${agent.name}` })
if (mention) inline.push({ ...part, start: mention.start, end: mention.end, value: mention.text })
}
for (const attached of message.skills ?? []) {
const mention = attached.mention
if (!mention) continue
inline.push({
type: "skill",
start: mention.start,
end: mention.end,
value: mention.text,
id: Skill.ID.make(attached.id),
name: Skill.Name.make(attached.name),
})
const part = { type: "skill" as const, id: Skill.ID.make(attached.id), name: Skill.Name.make(attached.name) }
if (!mention) trailing.push({ ...part, start: -1, end: -1, value: `@${attached.id}` })
if (mention) inline.push({ ...part, start: mention.start, end: mention.end, value: mention.text })
}
return buildPrompt(text, inline, images)
attachments.push(
...(presentation?.attachments ?? []).map(
(file, index): PathAttachmentPart => ({
type: "path",
id: `${message.id}:path:${index}`,
filename: file.name,
mime: file.mime,
path: file.path,
}),
),
)
return buildPrompt(text, inline, trailing, attachments)
}
export function extractPromptComments(message: SessionMessageUser) {
return readPromptPresentation(message.metadata)?.comments ?? []
/**
* The composer context a sent prompt restores: its comments, and the files it attached without a mention. Like the
* TUI, an unmentioned file keeps its URI rather than becoming text; comment files are regenerated from the comments.
*/
export function extractPromptContext(message: PromptSource, opts?: { directory?: string }) {
const comments = (readPromptPresentation(message.metadata)?.comments ?? []).map(commentContextItem)
const directory = opts?.directory
const regenerated = new Set(
buildPromptRequest({
prompt: [],
context: comments.map((item) => ({ ...item, key: contextItemKey(item) })),
images: [],
text: "",
sessionDirectory: directory ?? "",
}).files.map((file) => file.uri),
)
const files = (message.files ?? []).flatMap((file): FileContextItem[] => {
if (file.mention || file.source.type !== "uri" || !file.source.uri.startsWith("file:")) return []
if (regenerated.has(file.source.uri)) return []
const absolute = decodeFilePath(stripQueryAndHash(stripFileProtocol(file.source.uri))).replace(
/^\/([A-Za-z]:)/,
"$1",
)
return [
{
type: "file",
// The workspace root itself has no relative path, so it keeps its absolute one.
path: (directory && createPathHelpers(() => directory).normalize(file.source.uri)) || absolute,
selection: selectionFromFileUrl(file.source.uri),
name: file.name,
description: file.description,
},
]
})
return { comments, files }
}
function buildPrompt(text: string, inline: Inline[], images: ImageAttachmentPart[]): Prompt {
function buildPrompt(
text: string,
inline: Inline[],
trailing: Inline[],
attachments: (ImageAttachmentPart | PathAttachmentPart)[],
): Prompt {
inline.sort((a, b) => {
if (a.start !== b.start) return a.start - b.start
return a.end - b.end
})
const result: Prompt = []
let position = 0
let cursor = 0
let tail = ""
const pushText = (content: string) => {
if (!content) return
@@ -144,77 +233,70 @@ function buildPrompt(text: string, inline: Inline[], images: ImageAttachmentPart
end: position + content.length,
})
position += content.length
tail = content
}
const pushFile = (item: Extract<Inline, { type: "file" }>) => {
const pushPart = (item: Inline) => {
const content = item.value
const attachment: FileAttachmentPart = {
const span = { content, start: position, end: position + content.length }
position += content.length
tail = content
if (item.type === "agent") {
result.push({ type: "agent", name: item.name, ...span })
return
}
if (item.type === "skill") {
result.push({ type: "skill", id: item.id, name: item.name, ...span })
return
}
result.push({
type: "file",
path: item.path,
content,
start: position,
end: position + content.length,
selection: item.selection,
url: item.url,
mime: item.mime,
filename: item.filename,
}
result.push(attachment)
position += content.length
description: item.description,
...span,
} satisfies FileAttachmentPart)
}
const pushAgent = (item: Extract<Inline, { type: "agent" }>) => {
const content = item.value
const mention: AgentPart = {
type: "agent",
name: item.name,
content,
start: position,
end: position + content.length,
}
result.push(mention)
position += content.length
}
// A mention whose recorded offsets no longer index the text (another client may count
// display width) is found by its text; one missing from the text returns at the end.
const unplaced = inline.flatMap((item) => {
if (!item.value) return []
const pushSkill = (item: Extract<Inline, { type: "skill" }>) => {
const content = item.value
const skill: SkillPart = {
type: "skill",
id: item.id,
name: item.name,
content,
start: position,
end: position + content.length,
}
result.push(skill)
position += content.length
}
const mismatch =
item.start < cursor ||
item.end < item.start ||
item.end > text.length ||
text.slice(item.start, item.end) !== item.value
for (const item of inline) {
if (item.start < 0 || item.end < item.start) continue
const expected = item.value
if (!expected) continue
const mismatch = item.end > text.length || item.start < cursor || text.slice(item.start, item.end) !== expected
const start = mismatch ? text.indexOf(expected, cursor) : item.start
if (start === -1) continue
const end = mismatch ? start + expected.length : item.end
const start = mismatch ? text.indexOf(item.value, cursor) : item.start
if (start === -1) return [item]
pushText(text.slice(cursor, start))
pushPart(item)
cursor = start + item.value.length
if (item.type === "file") pushFile(item)
if (item.type === "agent") pushAgent(item)
if (item.type === "skill") pushSkill(item)
cursor = end
}
return []
})
pushText(text.slice(cursor))
for (const item of [...unplaced, ...trailing]) {
if (tail && !/\s$/.test(tail)) pushText(" ")
pushPart(item)
}
if (result.length === 0) {
result.push({ type: "text", content: "", start: 0, end: 0 })
}
if (images.length === 0) return result
return [...result, ...images]
return [...result, ...attachments]
}
+51 -12
View File
@@ -5,6 +5,8 @@ import type {
ContextItem,
FileAttachmentPart,
ImageAttachmentPart,
NoteComment,
NoteContextItem,
PathAttachmentPart,
Prompt,
SkillPart,
@@ -21,7 +23,13 @@ import {
type PromptRequest = {
text: string
displayText: string
files: { uri: string; mime: string; name?: string; mention?: { start: number; end: number; text: string } }[]
files: {
uri: string
mime: string
name?: string
description?: string
mention?: { start: number; end: number; text: string }
}[]
agents: { name: string; mention?: { start: number; end: number; text: string } }[]
skills: { id: string; name: string; mention?: { start: number; end: number; text: string } }[]
comments: PromptComment[]
@@ -38,8 +46,11 @@ type BuildPromptRequestInput = {
const absolute = (directory: string, path: string) => {
if (path.startsWith("/")) return path
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
if (path.startsWith("\\\\") || path.startsWith("//")) return path
return `${directory.replace(/[\\/]+$/, "")}/${path}`
}
@@ -51,28 +62,54 @@ const mention = /(^|[\s([{"'])@(\S+)/g
const parseCommentMentions = (comment: string) => {
return Array.from(comment.matchAll(mention)).flatMap((match) => {
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
if (!path) return []
return [path]
})
}
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
const isSkillAttachment = (part: Prompt[number]): part is SkillPart => part.type === "skill"
const isPathAttachment = (part: Prompt[number]): part is PathAttachmentPart => part.type === "path"
/** The sent form of a note: its optional link and live subject travel only when present. */
export function noteComment(item: NoteContextItem, comment: string) {
const note: NoteComment = {
type: "note",
origin: item.origin,
label: item.label,
icon: item.icon,
subject: item.subject,
comment,
}
if (item.href) note.href = item.href
if (item.live) note.live = { ...item.live }
return note
}
export function buildPromptRequest(input: BuildPromptRequestInput): PromptRequest {
const skills = input.prompt.filter(isSkillAttachment).map((attachment) => ({
id: attachment.id,
name: attachment.name,
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
}))
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
const path = absolute(input.sessionDirectory, attachment.path)
return {
uri: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
mime: attachment.mime ?? "text/plain",
name: attachment.filename ?? getFilename(attachment.path),
description: attachment.description,
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
}
})
@@ -84,36 +121,36 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
const used = new Set(files.map((file) => file.uri))
const comments: PromptComment[] = []
const mentioned = (comment: string) =>
parseCommentMentions(comment).flatMap((path) => {
const uri = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
if (used.has(uri)) return []
used.add(uri)
return [{ uri, mime: "text/plain", name: getFilename(path) }]
})
const context = input.context.flatMap((item) => {
if (item.type === "note") {
const comment = item.comment.trim()
if (!comment) return []
comments.push({
type: "note",
origin: item.origin,
label: item.label,
icon: item.icon,
subject: item.subject,
...(item.href ? { href: item.href } : {}),
...(item.live ? { live: { ...item.live } } : {}),
comment,
})
comments.push(noteComment(item, comment))
return mentioned(comment)
}
const path = absolute(input.sessionDirectory, item.path)
const uri = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
const comment = item.comment?.trim()
if (!comment && used.has(uri)) return []
used.add(uri)
const file = { uri, mime: "text/plain", name: getFilename(item.path) }
const file = { uri, mime: "text/plain", name: item.name ?? getFilename(item.path), description: item.description }
if (!comment) return [file]
comments.push({
@@ -123,6 +160,7 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
preview: item.preview,
origin: item.commentOrigin,
})
return [file, ...mentioned(comment)]
})
@@ -131,6 +169,7 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
mime: attachment.mime,
name: attachment.sourcePath ?? attachment.filename,
}))
// Like comments, path references reach the model as text and the message UI through metadata.
const attachments = input.prompt
.filter(isPathAttachment)
+41
View File
@@ -12,7 +12,9 @@ const PartBase = {
}
const SourceText = Schema.Struct({ value: Schema.String, start: Schema.Number, end: Schema.Number })
const Position = Schema.Struct({ line: Schema.Number, character: Schema.Number })
const FilePartSource = Schema.Union([
Schema.Struct({ type: Schema.Literal("file"), text: SourceText, path: Schema.String }),
Schema.Struct({
@@ -27,6 +29,7 @@ const FilePartSource = Schema.Union([
])
export const TextPart = Persistence.struct({ type: Schema.Literal("text"), ...PartBase })
export type TextPart = typeof TextPart.Type
export const FileAttachmentPart = Persistence.struct({
@@ -36,12 +39,15 @@ export const FileAttachmentPart = Persistence.struct({
selection: Persistence.optional(FileSelection),
mime: Persistence.optional(Schema.String),
filename: Persistence.optional(Schema.String),
description: Persistence.optional(Schema.String),
url: Persistence.optional(Schema.String),
source: Persistence.optional(FilePartSource),
})
export type FileAttachmentPart = typeof FileAttachmentPart.Type
export const AgentPart = Persistence.struct({ type: Schema.Literal("agent"), ...PartBase, name: Schema.String })
export type AgentPart = typeof AgentPart.Type
export const SkillPart = Persistence.struct({
@@ -50,6 +56,7 @@ export const SkillPart = Persistence.struct({
id: Skill.ID,
name: Skill.Name,
})
export type SkillPart = typeof SkillPart.Type
const ImageFields = {
@@ -59,6 +66,7 @@ const ImageFields = {
sourcePath: Persistence.optional(Schema.String),
mime: Schema.String,
}
const Image = Persistence.struct({
...ImageFields,
// An empty URL is an image whose bytes are still in the draft store; see `resolveBlobUrl`.
@@ -78,6 +86,7 @@ export const ImageAttachmentPart = Schema.Struct({
decode: SchemaGetter.transform((value) => {
const id = value.blob?.id ?? value.dataUrl ?? ""
const url = value.blob?.url
return {
type: value.type,
id: value.id,
@@ -93,6 +102,7 @@ export const ImageAttachmentPart = Schema.Struct({
encode: SchemaGetter.transform((value) => value),
}),
)
export type ImageAttachmentPart = typeof ImageAttachmentPart.Type
// A file the model receives as a path on the server: its bytes never enter the draft store.
@@ -103,6 +113,7 @@ export const PathAttachmentPart = Persistence.struct({
mime: Schema.String,
path: Schema.String,
})
export type PathAttachmentPart = typeof PathAttachmentPart.Type
export const ContentPart = Schema.Union([
@@ -113,8 +124,11 @@ export const ContentPart = Schema.Union([
ImageAttachmentPart,
PathAttachmentPart,
])
export type ContentPart = typeof ContentPart.Type
export const Prompt = Persistence.array(ContentPart)
export type Prompt = typeof Prompt.Type
export const PromptModel = Persistence.struct({
@@ -122,6 +136,7 @@ export const PromptModel = Persistence.struct({
modelID: Schema.String,
variant: Persistence.optional(Schema.NullOr(Schema.String)),
})
export type PromptModel = typeof PromptModel.Type
export const FileContextItem = Persistence.struct({
@@ -132,7 +147,11 @@ export const FileContextItem = Persistence.struct({
commentID: Persistence.optional(Schema.String),
commentOrigin: Persistence.optional(Schema.Literals(["review", "file"])),
preview: Persistence.optional(Schema.String),
// A file restored from a sent prompt keeps the name and description it was sent with.
name: Persistence.optional(Schema.String),
description: Persistence.optional(Schema.String),
})
export type FileContextItem = typeof FileContextItem.Type
const NoteFields = {
@@ -145,11 +164,16 @@ const NoteFields = {
live: Persistence.optional(Persistence.struct({ subject: Schema.String, href: Persistence.optional(Schema.String) })),
comment: Schema.String,
}
/** An extension's comment on something other than workspace lines, as sent in message metadata. */
export const NoteComment = Persistence.struct(NoteFields)
export type NoteComment = typeof NoteComment.Type
export const NoteContextItem = Persistence.struct({ ...NoteFields, commentID: Schema.String })
export type NoteContextItem = typeof NoteContextItem.Type
export type ContextItem = FileContextItem | NoteContextItem
/** A note's live part names state inside the app process that attached it; anything that may outlive it drops it. */
@@ -174,6 +198,7 @@ const LegacyBrowserComment = Persistence.struct({
}),
comment: Schema.String,
})
export const LegacyBrowserNote = LegacyBrowserComment.pipe(
Schema.decodeTo(Schema.toType(NoteComment), {
decode: SchemaGetter.transform(legacyBrowserNote),
@@ -184,6 +209,7 @@ export const LegacyBrowserNote = LegacyBrowserComment.pipe(
// The subject those builds sent for an element whose ref no longer applies.
function legacyBrowserNote(item: typeof LegacyBrowserComment.Type): NoteComment {
const element = item.element
const details = [
element.role ? `role ${element.role}` : undefined,
element.name ? `accessible name ${JSON.stringify(element.name)}` : undefined,
@@ -192,6 +218,7 @@ function legacyBrowserNote(item: typeof LegacyBrowserComment.Type): NoteComment
? `selector ${JSON.stringify(element.selector)}${element.selector.includes(" >>> ") ? ' (">>>" enters a shadow root)' : ""}`
: undefined,
].filter((detail) => detail !== undefined)
return {
type: "note",
origin: "browser",
@@ -206,10 +233,13 @@ function legacyBrowserNote(item: typeof LegacyBrowserComment.Type): NoteComment
export function contextItemKey(item: ContextItem) {
if (item.type === "note") return `note:${item.origin}:c=${item.commentID}`
const key = `${item.type}:${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
if (item.commentID) return `${key}:c=${item.commentID}`
const comment = item.comment?.trim()
if (!comment) return key
const digest = checksum(comment) ?? comment
return `${key}:c=${digest.slice(0, 8)}`
}
@@ -219,6 +249,7 @@ const FileContextEntry = Schema.Struct({ ...FileContextItem.fields, key: Persist
encode: SchemaGetter.transform((item) => item),
}),
)
const NoteContextEntry = Schema.Struct({
...NoteContextItem.fields,
key: Persistence.optional(Schema.String),
@@ -229,6 +260,7 @@ const NoteContextEntry = Schema.Struct({
encode: SchemaGetter.transform((item) => item),
}),
)
const LegacyBrowserContextEntry = Schema.Struct({
...LegacyBrowserComment.fields,
commentID: Schema.String,
@@ -237,11 +269,13 @@ const LegacyBrowserContextEntry = Schema.Struct({
Schema.decodeTo(Persistence.struct({ ...NoteContextItem.fields, key: Schema.String }).pipe(Schema.toType), {
decode: SchemaGetter.transform((item) => {
const note = { ...legacyBrowserNote(item), commentID: item.commentID }
return { ...note, key: contextItemKey(note) }
}),
encode: SchemaGetter.forbidden(() => "Legacy browser comments are read-only"),
}),
)
const ContextEntry = Schema.Union([FileContextEntry, NoteContextEntry, LegacyBrowserContextEntry])
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
@@ -276,6 +310,7 @@ export const ComposerStore = Persistence.struct({
),
context: Persistence.struct({ items: Persistence.array(ContextEntry) }),
})
export type ComposerStore = typeof ComposerStore.Type
export const LineComment = Persistence.struct({
@@ -285,11 +320,13 @@ export const LineComment = Persistence.struct({
comment: Schema.String,
time: Schema.Number,
})
export type LineComment = typeof LineComment.Type
export const CommentStore = Persistence.struct({
comments: Schema.Record(Schema.String, Schema.mutableKey(Persistence.array(LineComment))),
})
export type CommentStore = typeof CommentStore.Type
export const PromptHistoryComment = Persistence.struct({
@@ -301,6 +338,7 @@ export const PromptHistoryComment = Persistence.struct({
origin: Persistence.optional(Schema.Literals(["review", "file"])),
preview: Persistence.optional(Schema.String),
})
export type PromptHistoryComment = typeof PromptHistoryComment.Type
// History entries require a prompt array; only its individual parts recover.
@@ -310,13 +348,16 @@ const HistoryPrompt = Schema.Array(Persistence.fallback(Schema.UndefinedOr(Conte
encode: SchemaGetter.transform((parts) => parts),
}),
)
const HistoryEntry = Schema.Struct({ prompt: HistoryPrompt, comments: Persistence.array(PromptHistoryComment) })
export const PromptHistoryEntry = Schema.Union([HistoryEntry, HistoryPrompt]).pipe(
Schema.decodeTo(Schema.toType(HistoryEntry), {
decode: SchemaGetter.transform((entry) => ("prompt" in entry ? entry : { prompt: entry, comments: [] })),
encode: SchemaGetter.transform((entry) => entry),
}),
)
export type PromptHistoryEntry = typeof PromptHistoryEntry.Type
export const PromptHistoryState = Persistence.struct({ entries: Persistence.array(PromptHistoryEntry) })
+32 -11
View File
@@ -14,6 +14,7 @@ import {
} from "./schema"
export { DEFAULT_PROMPT } from "./schema"
export type {
AgentPart,
ComposerStore,
@@ -45,13 +46,12 @@ export function isCommentItem(item: ContextItem | (ContextItem & { key: string }
function createComposerActions(setStore: SetStoreFunction<ComposerStore>) {
return {
set(prompt: Prompt, cursorPosition?: number) {
batch(() =>
setStore({
prompt: clonePrompt(prompt),
...(cursorPosition !== undefined ? { cursor: cursorPosition } : {}),
retry: undefined,
}),
)
batch(() => {
setStore({ prompt: clonePrompt(prompt), retry: undefined })
// An omitted cursor keeps the current one.
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => setStore({ prompt: clonePrompt(DEFAULT_PROMPT), cursor: 0, retry: undefined }))
@@ -64,14 +64,14 @@ function composerTarget(serverScope: ServerScope, scope: PromptScope) {
? Persist.prompt(Persist.draft(scope.draftID, "prompt"))
: Persist.prompt({
...Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt"),
...(serverScope === ServerScope.local
? { previousKey: `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` }
: {}),
previousKey:
serverScope === ServerScope.local ? `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` : undefined,
})
}
function initialComposerStore(initial?: InitialPrompt): ComposerStore {
const text = initial?.prompt
return {
prompt:
text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }],
@@ -85,11 +85,13 @@ function initialComposerStore(initial?: InitialPrompt): ComposerStore {
function createComposerStateValue(store: ComposerStore, setStore: SetStoreFunction<ComposerStore>) {
const actions = createComposerActions(setStore)
const clearRetry = () => {
if (untrack(() => store.retry) !== undefined) setStore("retry", undefined)
}
const value = {
store: [() => store, setStore] as [Accessor<ComposerStore>, SetStoreFunction<ComposerStore>],
store: [() => store, setStore] satisfies [Accessor<ComposerStore>, SetStoreFunction<ComposerStore>],
current: () => store.prompt,
cursor: () => store.cursor,
model: {
@@ -114,6 +116,7 @@ function createComposerStateValue(store: ComposerStore, setStore: SetStoreFuncti
items: () => store.context.items,
add(item: ContextItem) {
const key = contextItemKey(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
clearRetry()
@@ -132,13 +135,27 @@ function createComposerStateValue(store: ComposerStore, setStore: SetStoreFuncti
setStore("context", "items", (items) =>
items.map((item) => {
if (item.commentID !== commentID) return item
const value =
item.type === "file" ? { ...item, ...next } : { ...item, comment: next.comment ?? item.comment }
return { ...value, key: contextItemKey(value) }
}),
)
clearRetry()
},
/** Replaces every item, as restoring a sent prompt replaces the whole draft. */
replace(items: ContextItem[]) {
setStore(
"context",
"items",
[...new Map(items.map((item) => [contextItemKey(item), item] as const)).entries()].map(([key, item]) => ({
...item,
key,
})),
)
clearRetry()
},
replaceComments(items: ContextItem[]) {
setStore("context", "items", (current) => [
...current.filter((item) => !isCommentItem(item)),
@@ -151,6 +168,7 @@ function createComposerStateValue(store: ComposerStore, setStore: SetStoreFuncti
reset: () => actions.reset(),
capture: () => value,
}
return value
}
@@ -160,6 +178,7 @@ function createPersistedComposer(
platform?: Platform,
) {
const [store, setStore, _, ready] = persisted(target, ComposerStore, initialComposerStore(initial), platform)
return { ready, ...createComposerStateValue(store, setStore) }
}
@@ -179,6 +198,7 @@ export function createDraftComposerState(draftID: string, initial?: InitialPromp
export type ComposerState = ReturnType<typeof createComposerState>
export function createComposerReady(session: Accessor<ComposerState>) {
// SAFETY: defineProperty returns the same function, now carrying the promise getter defined here.
return Object.defineProperty(() => session().ready(), "promise", {
get: () => session().ready.promise,
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
@@ -187,6 +207,7 @@ export function createComposerReady(session: Accessor<ComposerState>) {
export function createMemoryComposerState(initial?: InitialPrompt) {
const [store, setStore] = createStore<ComposerStore>(initialComposerStore(initial))
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
return {
ready,
...createComposerStateValue(store, setStore),
+49 -6
View File
@@ -7,6 +7,7 @@ import { createMemoryComposerState, type Prompt } from "./state"
import { createComposerSubmit } from "./submit"
import type { ComposerStateTarget } from "./submission-state"
// SAFETY: submission reads only the selected model's id, name, and provider id.
const selectedModel = {
id: "model-1",
name: "Model 1",
@@ -65,7 +66,11 @@ function fresh(state: ComposerStateTarget, start: NewSessionComposerAdapter["sta
function submitInput(
adapter: ActiveComposerAdapter | NewSessionComposerAdapter,
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
notify: Parameters<typeof createComposerSubmit>[0]["notify"] = {
missingSelection() {},
unqueueable() {},
failed() {},
},
mode: "normal" | "shell" = "normal",
commands: () => readonly { name: string }[] | undefined = () => [],
history: string[] = [],
@@ -97,7 +102,7 @@ function session(input: {
statuses?: ("idle" | "running")[]
current?: ComposerSession["current"]
admitted?: (messageID: string) => boolean
shell?: () => Promise<unknown>
shell?: ComposerSession["api"]["shell"]
command?: ComposerSession["api"]["command"]
switchAgent?: ComposerSession["api"]["switchAgent"]
switchModel?: ComposerSession["api"]["switchModel"]
@@ -142,6 +147,7 @@ function session(input: {
describe("Composer submission", () => {
test("runs a client argument command without admitting it to the session", async () => {
const state = createMemoryComposerState().capture()
const image = {
type: "image" as const,
id: "attachment",
@@ -149,6 +155,7 @@ describe("Composer submission", () => {
mime: "image/png",
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
}
state.set([{ type: "text", content: "/btw why this approach?", start: 0, end: 23 }, image])
state.context.add({ type: "file", path: "src/retry.ts" })
const calls: string[] = []
@@ -156,6 +163,7 @@ describe("Composer submission", () => {
await submitInput(active(state, session({ calls })), undefined, "normal", undefined, history, (text) => {
expect(text).toBe("/btw why this approach?")
return () => {
calls.push("btw")
}
@@ -176,6 +184,7 @@ describe("Composer submission", () => {
},
])("applies the selection before sending one captured value: $calls", async (row) => {
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
state.context.add({ type: "file", path: "notes.md", description: "the failing version" })
const calls: string[] = []
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const target = session({ calls, current: () => row.current, prompt: async (value) => admitted.resolve(value) })
@@ -192,7 +201,11 @@ describe("Composer submission", () => {
agent: "build",
model: { providerID: "provider-1", modelID: "model-1", variant: "balanced" },
})
// A file chip goes with this prompt only, like the TUI's mentionless files.
expect(request.files).toMatchObject([{ name: "notes.md", description: "the failing version" }])
expect(request.files?.[0]?.mention).toBeUndefined()
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
expect(state.context.items()).toEqual([])
})
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
@@ -203,6 +216,7 @@ describe("Composer submission", () => {
const started = Promise.withResolvers<void>()
const committed = Promise.withResolvers<void>()
const completed = Promise.withResolvers<void>()
const target = session({
calls,
switchAgent: async (request) => {
@@ -224,6 +238,7 @@ describe("Composer submission", () => {
completed.resolve()
},
})
selected.model.selection = {
...selection,
trackSessionCommit: (_id, value) => {
@@ -233,6 +248,7 @@ describe("Composer submission", () => {
variant: "balanced",
})
calls.push("track")
return () => calls.push("cancel")
},
}
@@ -258,6 +274,7 @@ describe("Composer submission", () => {
const selected = controls()
const failed = Promise.withResolvers<unknown>()
const error = new Error("model unavailable")
const target = session({
calls,
switchModel: async () => {
@@ -267,10 +284,12 @@ describe("Composer submission", () => {
calls.push("command")
},
})
selected.model.selection = {
...selection,
trackSessionCommit: () => {
calls.push("track")
return () => {
calls.push("cancel")
}
@@ -278,7 +297,7 @@ describe("Composer submission", () => {
}
await submitInput(
active(state, target, () => selected),
{ missingSelection() {}, failed: (_kind, error) => failed.resolve(error) },
{ missingSelection() {}, unqueueable() {}, failed: (_kind, error) => failed.resolve(error) },
"normal",
() => [{ name: "review" }],
).submit(new Event("submit"))
@@ -289,6 +308,7 @@ describe("Composer submission", () => {
test("restores and retries an unacknowledged admission", async () => {
const state = createMemoryComposerState().capture()
const prompt: Prompt = [
{ type: "text", content: "retry ", start: 0, end: 6 },
{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 6, end: 17 },
@@ -300,11 +320,14 @@ describe("Composer submission", () => {
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
},
]
state.set(prompt)
state.context.add({ type: "file", path: "notes.md", description: "the failing version" })
const attempts: string[] = []
const statuses: ("idle" | "running")[] = []
const first = Promise.withResolvers<void>()
const second = Promise.withResolvers<void>()
const target = session({
calls: [],
statuses,
@@ -313,10 +336,13 @@ describe("Composer submission", () => {
throw new Error("network unavailable")
},
})
const notify = {
missingSelection() {},
unqueueable() {},
failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()),
}
const history: string[] = []
const submission = submitInput(active(state, target), notify, "normal", () => [], history)
@@ -329,6 +355,9 @@ describe("Composer submission", () => {
expect(new Set(attempts).size).toBe(1)
expect(statuses).toEqual(["running", "idle", "running", "idle"])
expect(state.current()).toEqual(prompt)
expect(state.context.items()).toMatchObject([
{ type: "file", path: "notes.md", description: "the failing version" },
])
// The caret returns after the mention text; attachments take no caret positions.
expect(state.cursor()).toBe(17)
// The restored prompt is the draft again, so history does not also keep it (and its attachments).
@@ -351,14 +380,18 @@ describe("Composer submission", () => {
const promoted = createMemoryComposerState().capture()
const failed = Promise.withResolvers<void>()
const target = session({ calls: [], shell: async () => Promise.reject(new Error("send failed")) })
const adapter = fresh(draft, async (_selection, submission) => {
submission.retarget(promoted)
return { session: target, cleanupReady: Promise.resolve() }
})
await submitInput(adapter, { missingSelection() {}, failed: () => failed.resolve() }, "shell").submit(
new Event("submit"),
)
await submitInput(
adapter,
{ missingSelection() {}, unqueueable() {}, failed: () => failed.resolve() },
"shell",
).submit(new Event("submit"))
await failed.promise
expect(promoted.current()).toMatchObject([{ type: "text", content: "first prompt" }])
@@ -370,10 +403,12 @@ describe("Composer submission", () => {
const state = createMemoryComposerState({ prompt: "admitted prompt" }).capture()
const checked = Promise.withResolvers<void>()
const attempts: string[] = []
const target = session({
calls: [],
admitted: () => {
checked.resolve()
return true
},
prompt: async (value) => {
@@ -392,6 +427,7 @@ describe("Composer submission", () => {
test("hands off image-only first prompts and admits them before cleanup is ready", async () => {
const draft = createMemoryComposerState().capture()
const prompt: Prompt = [
{ type: "text", content: "", start: 0, end: 0 },
{
@@ -402,10 +438,12 @@ describe("Composer submission", () => {
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
},
]
draft.set(prompt)
const handedOff = Promise.withResolvers<SessionMessageUser>()
const admitted = Promise.withResolvers<void>()
const cleanup = Promise.withResolvers<void>()
const target = session({
calls: [],
handoff: { set: handedOff.resolve, clear() {} },
@@ -415,6 +453,7 @@ describe("Composer submission", () => {
const submitted = submitInput(
fresh(draft, async () => ({ session: target, cleanupReady: cleanup.promise })),
).submit(new Event("submit"))
await admitted.promise
expect(draft.current()).toEqual(prompt)
cleanup.resolve()
@@ -471,6 +510,7 @@ describe("Composer submission", () => {
const catalog = [{ name: "review" }]
const sent = Promise.withResolvers<"prompt" | "command">()
const requests: Parameters<ComposerSession["api"]["command"]>[0][] = []
const target = session({
calls: [],
prompt: async () => sent.resolve("prompt"),
@@ -479,11 +519,14 @@ describe("Composer submission", () => {
sent.resolve("command")
},
})
target.directory = "C:/new-worktree"
target.data.location.command.list = () => undefined
const adapter = fresh(state, async () => {
// The destination catalog has not loaded, and the source composer is leaving.
catalog.splice(0)
return { session: target, cleanupReady: Promise.resolve() }
})
+91 -30
View File
@@ -7,7 +7,7 @@ import type { ImageAttachmentPart, Prompt } from "./state"
import { clonePrompt, promptLength } from "./prompt-parts"
import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSession } from "./adapter"
import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
import { buildPromptRequest, noteComment } from "./request"
import { setCursorPosition } from "./editor/dom"
import { blobDataUrl, resolveBlobUrl } from "@/runtime/persistence/drafts"
import { isAttachment } from "./prompt-parts"
@@ -42,6 +42,8 @@ type ComposerSubmitInput = {
clientCommand?: (text: string) => (() => void | Promise<void>) | undefined
notify: {
missingSelection: () => void
unqueueable: () => void
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- a rejected send is opaque; the notifier formats it
failed: (kind: "shell" | "command" | "prompt", error: unknown) => void
}
comments: {
@@ -58,9 +60,11 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const prompt = clonePrompt(input.adapter.state.current())
const text = submissionText(prompt)
const clientCommand = input.mode() === "normal" ? input.clientCommand?.(text) : undefined
if (clientCommand) {
if (submitting.has(input.adapter.state)) return
submitting.add(input.adapter.state)
try {
clearClientCommand(input, prompt)
await clientCommand()
@@ -69,8 +73,10 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
} finally {
submitting.delete(input.adapter.state)
}
return
}
const submission = createComposerSubmission({
target: input.adapter.state,
prompt,
@@ -82,12 +88,24 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
: { ...item, selection: item.selection ? { ...item.selection } : undefined },
),
})
const read = readSubmission(input, submission.prompt, submission.context, text, options?.alternate ?? false)
if (!read) {
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
return
}
// Like the TUI, a shell command runs now or not at all; it cannot wait in the queue.
if (read.mode === "shell" && read.delivery === "queue") {
input.notify.unqueueable()
return
}
if (submitting.has(input.adapter.state)) return
// Images restored from a draft or history carry ids only; the optimistic message shows their URLs.
const value = {
...read,
@@ -98,6 +116,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
})),
),
}
submitting.add(input.adapter.state)
const comments = input.comments.capture()
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
@@ -108,6 +127,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
input.adapter.kind === "active-session"
? { session: input.adapter.session(), cleanupReady: Promise.resolve() }
: await input.adapter.start(value.selection, submission, handoffMessage(value))
if (!started) return
const session = started.session
@@ -118,8 +138,10 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (value.mode === "normal" && !command) {
session.handoff?.set(handoffMessage(value))
const optimisticBusy = !input.adapter.working()
if (optimisticBusy && input.adapter.kind === "new-session")
session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => {
if (optimisticBusy && input.adapter.kind === "active-session")
session.data.session.setStatus(session.id, "running")
@@ -127,12 +149,12 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
)
await started.cleanupReady
await started.complete?.()
input.adapter.submitted()
submission.context
.filter((item) => !!item.comment?.trim())
.forEach((item) => submission.target().context.remove(item.key))
// Like the TUI, sent context goes with the prompt rather than riding along on the next one.
submission.context.forEach((item) => submission.target().context.remove(item.key))
input.comments.clear()
clearSubmission(input, submission)
void sending.then((result) => {
@@ -141,6 +163,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (optimisticBusy) session.data.session.setStatus(session.id, "idle")
})
})
return
}
@@ -151,6 +174,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (value.mode === "shell") {
clearSubmission(input, submission)
void sendShell(session, value).catch((error) => failSubmission(input, session, "shell", error, restore))
return
}
@@ -159,6 +183,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
(error) => failSubmission(input, session, "command", error, restore, value.id),
)
return
}
} finally {
@@ -179,6 +204,14 @@ function clearClientCommand(input: ComposerSubmitInput, prompt: Prompt) {
input.closePopover()
}
function selectionModel(selection: ComposerSelection) {
const model: ComposerSelection["model"] & { variant?: string } = { ...selection.model }
if (selection.variant) model.variant = selection.variant
return model
}
function submissionText(prompt: Prompt) {
return prompt.map((part) => ("content" in part ? part.content : "")).join("")
}
@@ -201,35 +234,23 @@ function handoffMessage(value: ComposerSubmission): SessionMessageUser {
),
comments: value.context.flatMap((item): PromptComment[] => {
const comment = item.comment?.trim()
if (!comment) return []
if (item.type === "note")
return [
{
type: "note",
origin: item.origin,
label: item.label,
icon: item.icon,
subject: item.subject,
...(item.href ? { href: item.href } : {}),
...(item.live ? { live: { ...item.live } } : {}),
comment,
},
]
if (item.type === "note") return [noteComment(item, comment)]
return [
{
path: item.path,
comment,
...(item.selection ? { selection: { ...item.selection } } : {}),
...(item.preview !== undefined ? { preview: item.preview } : {}),
...(item.commentOrigin ? { origin: item.commentOrigin } : {}),
selection: item.selection && { ...item.selection },
preview: item.preview,
origin: item.commentOrigin,
},
]
}),
agent: value.selection.agent,
model: {
...value.selection.model,
...(value.selection.variant ? { variant: value.selection.variant } : {}),
},
model: selectionModel(value.selection),
},
time: { created: Date.now() },
}
@@ -243,20 +264,27 @@ function readSubmission(
alternate: boolean,
): ComposerSubmission | undefined {
const mode = input.mode()
if (mode === "shell" && !text.trim()) return
const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
// Like the TUI's blank Enter, file chips alone send nothing; they wait for text.
const comments = context.filter((item) => !!item.comment?.trim()).length
if (!text.trim() && !prompt.some(isAttachment) && comments === 0) return
const controls = input.adapter.controls()
const model = controls.model.selection.current()
const agent = controls.agents.current
if (!model || !agent) {
input.notify.missingSelection()
return
}
const variant = controls.model.selection.variant.current()
const retry = input.adapter.state.retry.current()
const retryID =
retry &&
retry.agent === agent &&
@@ -296,6 +324,7 @@ function restoreSubmission(
comments: PromptHistoryComment[],
) {
const restored = submission.restore()
if (!restored) return false
// The prompt is back in the composer; its history entry would only keep attachments referenced.
input.removeFromHistory(value.prompt, value.mode, comments)
@@ -328,6 +357,17 @@ function restoreSubmission(
},
),
)
restored.context.forEach((item) => {
if (item.type === "file" && !item.comment?.trim())
restored.target.context.add({
type: "file",
path: item.path,
selection: item.selection,
name: item.name,
description: item.description,
})
})
// A recovered follow-up changes the payload, so it must use a new admission ID.
if (value.mode === "normal" && restored.prompt === submission.prompt) {
restored.target.retry.set({
@@ -338,6 +378,7 @@ function restoreSubmission(
variant: value.selection.variant,
})
}
if (!submission.current(input.adapter.state)) return true
input.comments.restore(comments)
@@ -345,11 +386,13 @@ function restoreSubmission(
input.closePopover()
requestAnimationFrame(() => {
const editor = input.editor()
if (!editor) return
editor.focus()
setCursorPosition(editor, promptLength(value.prompt))
input.queueScroll()
})
return true
}
@@ -359,7 +402,9 @@ async function sendShell(session: ComposerSession, value: ComposerSubmission) {
function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text: string) {
const parsed = parseSlashCommand(text)
if (!parsed || !commands?.some((item) => item.name === parsed.name)) return
return { command: parsed.name, arguments: parsed.input }
}
@@ -370,13 +415,19 @@ async function sendCommand(
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
// Like queued prompts, queued commands must not apply the composer's selection to active work.
if (value.delivery === "steer") await applySelection(session, value.selection, track)
await session.api.command({
sessionID: session.id,
name: command.command,
text: command.arguments,
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
files: request.files.map((file) => ({
uri: file.uri,
name: file.name,
description: file.description,
mention: file.mention,
})),
agents: request.agents,
skills: request.skills,
delivery: value.delivery,
@@ -389,11 +440,14 @@ async function applySelection(
track?: ModelSelection["trackSessionCommit"],
) {
const cancel = track?.(session.id, selection)
try {
const current = session.current()
if (current?.agent !== selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: selection.agent })
}
// The server deduplicates unchanged selections; cached SSE state may still be behind an earlier switch.
await session.api.switchModel({
sessionID: session.id,
@@ -412,6 +466,7 @@ async function sendPrompt(
onAdmit: () => void,
) {
const request = await buildSubmissionRequest(session, value)
// Switching agent or model reconfigures the session immediately, and with it
// the remainder of a running turn. A steer targets that turn, so its
// selection applies now; a queued follow-up must not reconfigure the turn it
@@ -426,7 +481,12 @@ async function sendPrompt(
sessionID: session.id,
delivery: value.delivery,
text: request.text,
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
files: request.files.map((file) => ({
uri: file.uri,
name: file.name,
description: file.description,
mention: file.mention,
})),
agents: request.agents,
skills: request.skills,
metadata: {
@@ -434,12 +494,10 @@ async function sendPrompt(
comments: request.comments,
attachments: request.attachments,
agent: value.selection.agent,
model: {
...value.selection.model,
...(value.selection.variant ? { variant: value.selection.variant } : {}),
},
model: selectionModel(value.selection),
},
}
const sending = session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
onAdmit()
await sending
@@ -452,6 +510,7 @@ async function buildSubmissionRequest(session: ComposerSession, value: ComposerS
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
})),
)
return buildPromptRequest({
prompt: value.prompt,
context: value.context,
@@ -465,12 +524,14 @@ function failSubmission(
input: ComposerSubmitInput,
session: ComposerSession,
kind: "shell" | "command" | "prompt",
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- a rejected send is opaque; the notifier formats it
error: unknown,
restore: () => boolean,
messageID?: string,
rollback?: () => void,
) {
if (messageID && session.admitted(messageID)) return
if (messageID) session.handoff?.clear(messageID)
rollback?.()
restore()
+21 -5
View File
@@ -3,7 +3,7 @@ import { useSettingsSurface } from "@/settings/surface"
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
import { useSettingsServers } from "@/settings/servers/inventory"
import { useSearchParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, untrack } from "solid-js"
import { createEffect, createMemo, createResource, on, untrack } from "solid-js"
import { createComposerModel } from "@/composer/model"
import { useComposerCommands } from "@/composer/commands"
import { createNewSessionComposerAdapter } from "./composer-adapter"
@@ -18,17 +18,23 @@ export default function NewSessionPage(props: { draftId: string }) {
const tabs = useTabs()
const servers = useSettingsServers()
const settingsSurface = useSettingsSurface()
const draftTab = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
)
const openWorkspaces = () => {
const draft = draftTab()
if (servers().length > 1 && draft) {
settingsSurface.openServer(draft.server, "workspaces")
return
}
settingsSurface.open("workspaces")
}
const workspace = createNewSessionWorkspaceController({
selectedWorktree: () => draftTab()?.worktree,
selectedBranch: () => draftTab()?.branch,
@@ -40,7 +46,9 @@ export default function NewSessionPage(props: { draftId: string }) {
},
onViewAll: openWorkspaces,
})
const mcp = createDraftMcpControls({ draftID: props.draftId, worktree: workspace.selection.value })
const composer = createNewSessionComposerAdapter({
draftID: props.draftId,
worktree: workspace.selection.value,
@@ -48,12 +56,15 @@ export default function NewSessionPage(props: { draftId: string }) {
submitted: workspace.selection.remember,
mcp,
})
const model = createComposerModel(composer.adapter)
useComposerCommands({ model: composer.model })
const project = createPromptProjectController({
controls: composer.project,
onDone: model.restoreFocus,
})
useNewSessionCommands({
restoreFocus: model.restoreFocus,
project: {
@@ -65,20 +76,25 @@ export default function NewSessionPage(props: { draftId: string }) {
cycle: workspace.selection.cycle,
},
})
createEffect(() => {
if (!composer.ready()) return
model.restoreFocus()
})
// Focus once the draft loads. restoreFocus reads the stored cursor, so tracking it here would reapply
// a stale cursor a frame after every key-up and yank the caret from a key pressed in that frame.
createEffect(
on(composer.ready, (ready) => {
if (ready) model.restoreFocus()
}),
)
createEffect(() => {
if (!composer.ready()) return
untrack(() => {
const text = search.prompt
if (!text) return
composer.adapter.state.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
setSearch({ ...search, prompt: undefined })
})
})
const ready = Promise.resolve()
const [suspendUntilPromptReady] = createResource(
() => composer.ready.promise ?? ready,
(promise) => promise.then(() => true),
@@ -23,17 +23,20 @@ const ModelKeySchema = Schema.Struct({
modelID: Schema.String,
variant: Schema.optional(Schema.String),
})
export type ModelKey = typeof ModelKeySchema.Type
const ChoiceSchema = Schema.Struct({
model: Persistence.optional(ModelKeySchema),
variant: Persistence.optional(Schema.NullOr(Schema.String)),
})
const StateSchema = Schema.Struct({
...ChoiceSchema.fields,
agent: Persistence.optional(Schema.String),
choices: Persistence.optional(Schema.Record(Schema.String, ChoiceSchema)),
})
type State = typeof StateSchema.Type
const SessionsSchema = Schema.Record(
@@ -61,12 +64,14 @@ export const ModelSelectionSchema = Persistence.migrate(
)
const WORKSPACE_KEY = "__workspace__"
const handoff = new Map<string, State>()
const handoffKey = (scope: ServerScope, dir: string, id: string) => ScopedKey.from(scope, dir, id)
const clone = (value: State | undefined) => {
if (!value) return
return {
...value,
model: value.model ? { ...value.model } : undefined,
@@ -86,11 +91,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const configuredModel = useConfiguredModel()
const id = createMemo(() => params.id || undefined)
const list = createMemo(() =>
normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []).filter(
(item) => item.mode !== "subagent" && !item.hidden,
),
)
const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list()))
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
@@ -111,13 +118,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const validModel = (model: ModelKey) => {
const provider = providers.all().get(model.providerID)
return !!provider?.models[model.modelID] && connected().has(model.providerID)
}
const firstModel = (...items: Array<() => ModelKey | undefined>) => {
for (const item of items) {
const model = item()
if (!model) continue
if (validModel(model)) return model
}
}
@@ -128,30 +138,39 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
createEffect(() => {
const items = list()
if (items.length === 0) {
if (store.current !== undefined) setStore("current", undefined)
return
}
if (items.some((item) => item.name === store.current)) return
setStore("current", items[0]?.name)
})
const scope = createMemo<State | undefined>(() => {
const session = id()
if (!session) return store.draft ?? store.promoting
return saved.session[session] ?? handoff.get(handoffKey(serverSDK.scope, sdk().directory, session))
})
createEffect(() => {
const session = id()
if (!session) return
const key = handoffKey(serverSDK.scope, sdk().directory, session)
const next = handoff.get(key)
if (!next) return
if (saved.session[session] !== undefined) {
handoff.delete(key)
setStore("promoting", undefined)
return
}
@@ -169,15 +188,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const defaultModel = () => {
for (const provider of providers.connected()) {
const first = Object.values(provider.models)[0]
if (!first) continue
const model = { providerID: provider.id, modelID: first.id }
if (validModel(model)) return model
}
}
const fallback = createMemo(() => firstModel(configuredModel, recentModel, defaultModel))
const durable = () => {
const session = id()
return session ? data.session.get(session) : undefined
}
@@ -189,43 +212,55 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
set(name: string | undefined) {
const item = pickAgent(name)
if (!item) {
setStore("current", undefined)
return
}
batch(() => {
const previous = snapshot()
if (previous.agent === item.name) return
const prev = scope()
const choices = {
...prev?.choices,
...(previous.agent ? { [previous.agent]: { model: previous.model, variant: previous.variant } } : {}),
}
const choices = { ...prev?.choices }
if (previous.agent) choices[previous.agent] = { model: previous.model, variant: previous.variant }
setStore("current", item.name)
const next = {
agent: item.name,
model: choices[item.name]?.model,
variant: choices[item.name]?.variant,
choices,
} satisfies State
write(next)
// Pin both choices while the agent and model acknowledgments arrive separately.
const selected = current()
if (selected) model.set({ providerID: selected.provider.id, modelID: selected.id })
})
},
move(direction: 1 | -1) {
const items = list()
if (items.length === 0) {
setStore("current", undefined)
return
}
let next = items.findIndex((item) => item.name === agent.current()?.name) + direction
if (next < 0) next = items.length - 1
if (next >= items.length) next = 0
const item = items[next]
if (!item) return
agent.set(item.name)
},
@@ -233,26 +268,33 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const current = createMemo(() => {
if (!configuredModel.ready()) return
const item = firstModel(
() => scope()?.model,
() => {
const session = durable()
if (session?.agent && session.agent !== agent.current()?.name) return
const model = session?.model
return model && { providerID: model.providerID, modelID: model.id }
},
() => agent.current()?.model,
fallback,
)
if (!item) return
return models.find(item)
})
const configured = () => {
const item = agent.current()
const model = current()
if (!item || !model) return
const global = configuredModel()
return (
getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
@@ -267,15 +309,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const selected = () => {
const draft = scope()
if (draft?.model && validModel(draft.model)) return draft.variant
const session = durable()
if (session?.agent && session.agent !== agent.current()?.name) return
const value = session?.model
if (value && validModel({ providerID: value.providerID, modelID: value.id })) return value.variant ?? null
}
const snapshot = () => {
const selected = current()
return {
agent: agent.current()?.name,
model: selected ? { providerID: selected.provider.id, modelID: selected.id } : undefined,
@@ -291,15 +337,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} satisfies State
const session = id()
if (session) {
setSaved("session", session, state)
return
}
setStore("draft", state)
}
const recent = createMemo(() => models.recent.list().filter(validModel).map(models.find).filter(Boolean))
const pending = new Map<string, State>()
const sameSelection = (a: State, b: State) =>
a.agent === b.agent &&
a.model?.providerID === b.model?.providerID &&
@@ -309,7 +359,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const reconcile = (sessionID: string) => {
const expected = pending.get(sessionID)
const session = data.session.get(sessionID)
if (!expected || !session?.model) return
if (
!sameSelection(expected, {
agent: session.agent,
@@ -320,9 +372,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return
pending.delete(sessionID)
const draft = saved.session[sessionID]
if (id() !== sessionID || !draft || !sameSelection(draft, expected)) return
setSaved("session", sessionID, { agent: undefined, model: undefined, variant: undefined })
}
onCleanup(serverSDK.event.on("session.model.selected", (event) => reconcile(event.data.sessionID)))
onCleanup(serverSDK.event.on("session.agent.selected", (event) => reconcile(event.data.sessionID)))
onCleanup(
@@ -340,6 +394,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
trackSessionCommit(sessionID: string, selection: { agent: string; model: ModelKey; variant?: string }) {
pending.set(sessionID, selection)
reconcile(sessionID)
return () => {
if (pending.get(sessionID) === selection) pending.delete(sessionID)
}
@@ -347,14 +402,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
cycle(direction: 1 | -1) {
const items = recent()
const item = current()
if (!item) return
const index = items.findIndex((entry) => entry?.provider.id === item.provider.id && entry?.id === item.id)
let next = index === -1 ? (direction === 1 ? 0 : items.length - 1) : index + direction
if (next < 0) next = items.length - 1
if (next >= items.length) next = 0
const entry = items[next]
if (!entry) return
model.set({ providerID: entry.provider.id, modelID: entry.id })
},
@@ -364,10 +423,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const previous = current()
const same = item && previous?.provider.id === item.providerID && previous.id === item.modelID
write({ model: item, variant: same ? (model.variant.current() ?? null) : undefined })
if (!item) return
// A session draft owns its variant even when preferences change in another session.
if (id() && !same) write({ variant: model.variant.current() ?? null })
models.setVisibility(item, true)
if (!options?.recent) return
models.recent.push(item)
})
@@ -383,6 +445,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
selected,
current() {
const model = current()
return resolveModelVariant({
variants: this.list(),
selected: this.selected(),
@@ -392,12 +455,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
list() {
const item = current()
if (!item?.variants) return []
return Object.keys(item.variants)
},
set(value: string | undefined) {
batch(() => {
const model = current()
if (!model) return
write({ model: { providerID: model.provider.id, modelID: model.id }, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
@@ -405,6 +471,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
cycle() {
const items = this.list()
if (items.length === 0) return
this.set(
cycleModelVariant({
@@ -428,11 +495,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
promote(dir: string, session: string, state?: State) {
const next = clone(state ?? snapshot())
if (!next) return
// Creation already owns the active selection; keep only agent memory once it is in the read model.
// Otherwise a first-message command's configured overrides would stay hidden behind this handoff.
const created = data.session.get(session)
const selection = created?.model
const committed =
selection &&
sameSelection(next, {
@@ -442,6 +511,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
? { choices: next.choices }
: next
const key = handoffKey(serverSDK.scope, dir, session)
handoff.set(key, committed)
@@ -454,10 +524,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
const session = id()
if (!session) return
if (msg.sessionID !== session) return
if (durable()?.model) return
if (saved.session[session] !== undefined) return
if (handoff.has(handoffKey(serverSDK.scope, sdk().directory, session))) return
setSaved("session", session, {
@@ -468,6 +543,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
},
}
return result
},
})
@@ -14,7 +14,6 @@ import { Mark } from "@opencode/ui/logo"
import { Keybind } from "@opencode/ui/keybind"
import { Tooltip } from "@opencode/ui/tooltip"
import { Menu } from "@opencode/ui/menu"
import { SessionReviewV2SidebarToggle } from "@opencode/session-ui/v2/session-review-v2"
import {
MenuItem,
type MountedSession,
@@ -160,13 +159,6 @@ export function SideRegion(props: {
onCleanup(createTabStripScroll({ el, lead: props.region.lead }))
}}
>
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky start-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
<SessionReviewV2SidebarToggle
opened={props.sidebar.opened()}
disabled={props.region.selected()?.tab.sidebar === "locked"}
onToggle={props.sidebar.toggle}
/>
</div>
<For each={props.region.keys()}>
{(key) => (
<Show when={props.region.entry(key)}>
+8 -1
View File
@@ -295,6 +295,7 @@ export const dict = {
"prompt.toast.uploadFailed.title": "Upload failed",
"prompt.toast.modelAgentRequired.title": "Select an agent and model",
"prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
"prompt.toast.unqueueable.title": "This prompt cannot be queued",
"prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",
"prompt.toast.sessionCreateFailed.title": "Failed to create session",
"prompt.toast.shellSendFailed.title": "Failed to send shell command",
@@ -503,11 +504,17 @@ export const dict = {
"session.queue.remove": "Remove",
"session.queue.undo": "Undo",
"session.queue.undoShell": "Leave shell mode before undoing a queued prompt",
"session.queue.undoUnavailable": "Edit this prompt in the queue to preserve its file context",
"session.queue.reorder": "Reorder queued prompt",
"session.queue.reverted": "Redo the revert before you reorder or edit queued prompts",
"session.queue.attachments.one": "{{count}} attachment",
"session.queue.attachments.other": "{{count}} attachments",
"session.timeline.working": "Working",
"session.running.working.one": "{{count}} working…",
"session.running.working.other": "{{count}} working…",
"session.running.running.one": "{{count}} running",
"session.running.running.other": "{{count}} running",
"session.running.stop.subagent": "Interrupt subagent",
"session.running.stop.shell": "Kill shell command",
"session.files.selectToOpen": "Select a file to open",
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema, SchemaGetter } from "effect"
import { Effect, Predicate, Schema, SchemaGetter } from "effect"
import { Persistence } from "./schema"
describe("persistence schemas", () => {
@@ -12,6 +12,7 @@ describe("persistence schemas", () => {
}),
{ enabled: true, appearance: { width: 240, font: "default" }, variant: "high" },
)
const decode = Schema.decodeUnknownSync(schema)
expect(decode({ appearance: { width: 300 } })).toEqual({
enabled: true,
@@ -32,6 +33,7 @@ describe("persistence schemas", () => {
test("legacy migration observes missing fields before initial defaults are applied", () => {
const current = Persistence.struct({ mode: Schema.Literals(["compact", "full"]), enabled: Schema.Boolean })
const stored = Schema.Struct({
mode: Schema.optional(Schema.Unknown),
expanded: Schema.optional(Schema.Boolean),
@@ -45,6 +47,7 @@ describe("persistence schemas", () => {
encode: SchemaGetter.passthrough(),
}),
)
const schema = Persistence.withInitial(Persistence.migrate(current, stored), { mode: "compact", enabled: true })
const decode = Schema.decodeUnknownSync(schema)
expect(decode({ expanded: true, enabled: false })).toEqual({ mode: "full", enabled: false })
@@ -58,6 +61,7 @@ describe("persistence schemas", () => {
amount: Schema.NumberFromString.check(Schema.isFinite()),
items: Schema.mutable(Schema.Array(Schema.String)),
})
const schema = Persistence.withInitial(current, { amount: 7, items: ["initial"] })
const decode = Schema.decodeUnknownSync(schema)
expect(decode({ amount: "12", items: [] })).toEqual({ amount: 12, items: [] })
@@ -69,6 +73,7 @@ describe("persistence schemas", () => {
const schema = Persistence.struct({
enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
})
const decode = Schema.decodeUnknownSync(schema)
expect(decode({})).toEqual({ enabled: true })
expect(decode({ enabled: undefined })).toEqual({ enabled: true })
@@ -84,6 +89,7 @@ describe("persistence schemas", () => {
label: Persistence.fallback(Schema.String, () => "default"),
amount: Persistence.fallback(Schema.NumberFromString.check(Schema.isFinite()), () => 7),
})
const decode = Schema.decodeUnknownSync(schema)
expect(decode({})).toEqual({ enabled: true, label: "default", amount: 7 })
expect(decode({ enabled: "false", label: "saved", amount: "invalid" })).toEqual({
@@ -142,14 +148,16 @@ describe("persistence schemas", () => {
test("recovers and migrates individual array entries", () => {
const current = Schema.Struct({ name: Schema.String })
const schema = Persistence.array(
Schema.Union([current, Schema.String]).pipe(
Schema.decodeTo(current, {
decode: SchemaGetter.transform((value) => (typeof value === "string" ? { name: value } : value)),
decode: SchemaGetter.transform((value) => (Predicate.isString(value) ? { name: value } : value)),
encode: SchemaGetter.passthrough(),
}),
),
)
const decode = Schema.decodeUnknownSync(schema)
const value = decode(["old", { name: "new" }, null, { name: false }])
expect(value).toEqual([{ name: "old" }, { name: "new" }])
+20 -1
View File
@@ -23,10 +23,13 @@ export function withInitial<S extends Schema.ConstraintCodec<object, unknown>>(
initial: NoInfer<S["Type"]>,
) {
const schema = isMigrated(definition) ? definition.current : definition
const read = isMigrated(definition)
? SchemaParser.decodeUnknownResult(definition.read, { onExcessProperty: "preserve" })
: Result.succeed<unknown>
const encode = Schema.encodeUnknownSync(schema)
return Schema.Unknown.pipe(
Schema.decode<Schema.Unknown>({
decode: SchemaGetter.transformOrFail((value) =>
@@ -39,25 +42,37 @@ export function withInitial<S extends Schema.ConstraintCodec<object, unknown>>(
}
// Object-level codecs own their recovery. Plain structs can recover fields independently.
// SAFETY: stored values and their defaults are untyped trees walked against `ast`; `withInitial` decodes the
// recovered and merged result with the current schema before anyone reads it.
// oxlint-disable-next-line anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns -- see SAFETY above
function recover(ast: SchemaAST.AST, value: unknown, initial: unknown): unknown {
if (value === undefined) return initial
if (ast._tag === "Objects" && !ast.encoding && ast.indexSignatures.length === 0 && Predicate.isObject(value)) {
if (SchemaAST.isObjects(ast) && !ast.encoding && ast.indexSignatures.length === 0 && Predicate.isObject(value)) {
return Object.fromEntries(
ast.propertySignatures.flatMap((field) => {
const defaults = Predicate.isObject(initial) ? initial[field.name] : undefined
const next = recover(field.type, value[field.name], defaults)
if (next === undefined && !Object.hasOwn(value, field.name) && defaults === undefined) return []
return [[field.name, next]]
}),
)
}
const decoded = Schema.decodeUnknownOption(Schema.make<Schema.Codec<unknown, unknown>>(ast))(value)
return Option.isSome(decoded) ? decoded.value : initial
}
// SAFETY: like `recover`, merges untyped trees that `withInitial` decodes with the current schema afterwards.
// oxlint-disable-next-line anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns -- see SAFETY above
function merge(initial: unknown, value: unknown): unknown {
if (value === undefined) return initial
if (!Predicate.isObject(initial) || !Predicate.isObject(value)) return value
return Object.fromEntries(
[...new Set([...Object.keys(initial), ...Object.keys(value)])].map((key) => [key, merge(initial[key], value[key])]),
)
@@ -66,11 +81,13 @@ function merge(initial: unknown, value: unknown): unknown {
// Unlike a decoding default, a fallback also replaces invalid persisted values.
export function fallback<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S, value: () => S["Type"]) {
const defaulted = Schema.withDecodingDefaultType<S>(Effect.sync(value))(schema)
return Schema.catchDecoding<typeof defaulted>(() => Effect.sync(() => Option.some(value())))(defaulted)
}
export function optional<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
const field = Schema.optional(schema)
return Schema.catchDecoding<typeof field>(() => Effect.succeed(Option.none()))(field)
}
@@ -80,6 +97,7 @@ export function struct<const Fields extends Schema.Struct.Fields>(fields: Fields
export function record<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
const entries = Schema.Record(Schema.String, Schema.mutableKey(schema))
return fallback(entries, () => Schema.decodeUnknownSync(entries)({}))
}
@@ -87,6 +105,7 @@ export function record<S extends Schema.ConstraintCodec<unknown, unknown>>(schem
export function array<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
const decode = Schema.decodeUnknownOption(schema)
const encode = Schema.encodeSync(schema)
return fallback(
Schema.Array(Schema.Unknown).pipe(
Schema.decodeTo(Schema.mutable(Schema.Array(Schema.toType(schema))), {
+13 -2
View File
@@ -1,4 +1,4 @@
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { Effect, Option, Predicate, Schema, SchemaGetter } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
export const ServerKey = Schema.String.pipe(Schema.brand("ServerConnection.Key"))
@@ -19,8 +19,10 @@ export const ServerHttp = Persistence.struct({
const StoredServer = Schema.Union([ServerHttp, ServerHttpBase, Schema.String]).pipe(
Schema.decodeTo(ServerHttp, {
decode: SchemaGetter.transform((value) => {
if (typeof value === "string") return { type: "http", http: { url: value } }
if (Predicate.isString(value)) return { type: "http", http: { url: value } }
if ("http" in value) return value
return { type: "http", http: value }
}),
encode: SchemaGetter.transform((value) => value),
@@ -33,7 +35,9 @@ const ProjectList = Persistence.array(
expanded: Persistence.fallback(Schema.Boolean, () => true),
}),
)
const Projects = Persistence.record(ProjectList)
const LastProject = Persistence.record(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none()))))
const State = Persistence.struct({
@@ -57,12 +61,15 @@ export function serverState(canonicalLocalServer: () => string | undefined = ()
Schema.decode({
decode: SchemaGetter.transform((value) => {
const canonical = canonicalLocalServer()
if (!canonical || canonical === "local") return value
const previous = value.projects[canonical]
const last = value.lastProject[canonical]
if (!previous && last === undefined) return value
const projects = { ...value.projects }
if (previous) {
const local = projects.local ?? []
const worktrees = new Set(local.map((project) => project.worktree))
@@ -71,16 +78,20 @@ export function serverState(canonicalLocalServer: () => string | undefined = ()
...previous.filter((project) => {
if (worktrees.has(project.worktree)) return false
worktrees.add(project.worktree)
return true
}),
]
delete projects[canonical]
}
const lastProject = { ...value.lastProject }
if (last !== undefined) {
lastProject.local ??= last
delete lastProject[canonical]
}
return { ...value, projects, lastProject }
}),
encode: SchemaGetter.transform((value) => value),
@@ -9,8 +9,7 @@ import { showToast } from "@/shell/notifications/toast"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { base64Encode } from "@opencode/util/encode"
import { commentContextItem } from "@/composer/comment-note"
import { extractPromptComments, extractPromptFromMessage } from "@/composer/prompt"
import { extractPromptContext, extractPromptFromMessage } from "@/composer/prompt"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServer } from "@/runtime/server/current"
import { sessionHref } from "@/shell/routes/session"
@@ -38,6 +37,7 @@ export const DialogFork: Component = () => {
const messages = createMemo((): ForkableMessage[] => {
const sessionID = params.id
if (!sessionID) return []
const msgs = data.session.message.list(sessionID)
@@ -60,13 +60,18 @@ export const DialogFork: Component = () => {
if (!item) return
const sessionID = params.id
if (!sessionID) return
const message = data.session.message.get(sessionID, item.id)
if (message?.type !== "user") return
const restored = extractPromptFromMessage(message, {
directory: location().directory,
attachmentName: language.t("common.attachment"),
})
const context = extractPromptContext(message, { directory: location().directory })
const dir = base64Encode(location().directory)
serverSDK.api.session
@@ -76,11 +81,11 @@ export const DialogFork: Component = () => {
dialog.close()
const target = prompt.capture({ dir, id: forked.id })
target.set(restored)
target.context.replaceComments(extractPromptComments(message).map(commentContextItem))
target.context.replace([...context.comments, ...context.files])
navigate(sessionHref(server.key, forked.id))
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
.catch((cause: unknown) => {
const message = cause instanceof Error ? cause.message : String(cause)
showToast({ title: language.t("common.requestFailed"), description: message })
})
}
@@ -6,6 +6,8 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { useComposerState } from "@/composer/persistence"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { formatServerError } from "@/runtime/server/errors"
import { useSettings } from "@/settings/model"
import { showToast } from "@/shell/notifications/toast"
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/session/commands/export"
@@ -34,6 +36,8 @@ export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
revert: Pick<SessionRevert, "undo" | "redo">
focusInput: () => void
// The composer's model, which a compaction runs with.
model: () => { id: string; providerID: string; variant?: string } | undefined
}
const withCategory = (category: string) => {
@@ -50,6 +54,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const language = useLanguage()
const prompt = useComposerState()
const serverSDK = useServerSDK()
const data = useData()
const settings = useSettings()
const platform = usePlatform()
const layout = useLayout()
@@ -243,7 +248,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (!sessionID) return
await serverSDK.api.session.compact({ sessionID })
await data.session.compact({ sessionID, model: actions.model() }).catch((cause: unknown) => {
showToast({ title: formatServerError(cause, language.t, language.t("common.requestFailed")) })
})
}
const fork = () => {
+193 -139
View File
@@ -1,16 +1,17 @@
import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import { Option, Schema } from "effect"
import type { SessionInboxInfo } from "@opencode/client/promise"
import { SessionMessage } from "@opencode/schema/session-message"
import { Skill } from "@opencode/schema/skill"
import type { ComposerDelivery } from "@/composer/adapter"
import type { ComposerStateTarget } from "@/composer/submission-state"
import type { ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
import type { ContextItem, ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
import { appendPrompt, clonePrompt, isAttachment, promptLength } from "@/composer/prompt-parts"
import { buildPromptRequest } from "@/composer/request"
import { blobDataUrl, createLegacyBlobReference } from "@/runtime/persistence/drafts"
import { readPromptPresentation } from "@/composer/comment-note"
import { extractPromptContext, extractPromptFromMessage } from "@/composer/prompt"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -39,11 +40,12 @@ export function createSessionQueue(input: {
const language = useLanguage()
const [state, setState] = createStore<{ editing?: { id: string; stash: EditStash } }>({})
const notify = () => showToast({ title: language.t("common.requestFailed") })
const mutation = useMutation(() => ({
mutationFn: async (
change:
| { type: "reorder"; inboxIDs: string[] }
| { type: "undo"; item: QueuedPrompt; prompt: Prompt }
| { type: "undo"; item: QueuedPrompt; prompt: Prompt; context: ContextItem[] }
| {
type: "edit"
inboxIDs: string[]
@@ -56,16 +58,25 @@ export function createSessionQueue(input: {
},
) => {
if (change.type === "reorder") return rewrite(change.inboxIDs)
if (change.type === "undo") {
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.item.id })
const draft = input.draft.current()
const prompt = promptLength(draft)
? appendPrompt(draft, change.prompt)
: [...change.prompt, ...draft.filter(isAttachment)]
// A prompt of only comments or attachments adds no text, so it needs no paragraph break.
const prompt = !promptLength(draft)
? [...change.prompt, ...draft.filter(isAttachment)]
: promptLength(change.prompt)
? appendPrompt(draft, change.prompt)
: [...clonePrompt(draft), ...change.prompt.filter(isAttachment)]
input.draft.set(prompt, promptLength(prompt))
change.context.forEach((item) => input.draft.context.add(item))
input.restoreFocus(promptLength(prompt))
return
}
const replacement = await editedPromptInput(
input.sessionID,
location().directory,
@@ -73,17 +84,26 @@ export function createSessionQueue(input: {
change.prompt,
change.text,
)
// Admit before cancelling so a failed replacement never discards the original.
const admitted = await data.session.prompt({
...replacement,
id: change.replacement,
delivery: change.delivery,
...(change.delivery === "queue" ? { resume: false } : {}),
})
// Admit before cancelling so a failed replacement never discards the original. A queued edit
// rebuilds its position in one rewrite, which leaves the queue unchanged if any admission fails.
const admission = { ...replacement, id: change.replacement, delivery: change.delivery }
if (change.delivery === "queue") {
await rewrite(change.inboxIDs, { original: change.original, admission: { ...admission, resume: false } })
cancelEdit()
return
}
// Like a queued edit, an original the server delivered meanwhile keeps the edit draft instead of sending twice.
const pending = await server.api.session.inbox.list({ sessionID: input.sessionID })
if (!pending.some((item) => item.id === change.original && item.type === "user" && item.delivery === "queue"))
throw new Error("Queued prompt was delivered before the edit")
await data.session.prompt(admission)
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.original })
cancelEdit()
if (change.delivery === "queue")
await rewrite(change.inboxIDs.map((id) => (id === change.original ? admitted.id : id)))
},
onError: notify,
onSettled: () => data.session.pending.sync(input.sessionID).catch(() => undefined),
@@ -94,8 +114,10 @@ export function createSessionQueue(input: {
.list(input.sessionID)
.filter((item): item is QueuedPrompt => item.type === "user" && item.delivery === "queue"),
)
const rows = createMemo(() => {
const replacement = mutation.isPending ? mutation.variables : undefined
return queuedPromptRows(
queued(),
replacement?.type === "edit" && replacement.delivery === "queue" ? replacement : undefined,
@@ -104,78 +126,137 @@ export function createSessionQueue(input: {
createEffect(() => {
const editing = state.editing
if (!editing || mutation.isPending || queued().some((item) => item.id === editing.id)) return
setState("editing", undefined)
})
onCleanup(() => cancelEdit())
const rewrite = async (inboxIDs: string[]) => {
// `replace` substitutes an edited prompt for the original at its position in the same rewrite.
const rewrite = async (
inboxIDs: string[],
replace?: { original: string; admission: Parameters<typeof data.session.prompt>[0] },
) => {
const pending = await server.api.session.inbox.list({ sessionID: input.sessionID })
if (pending.some((item) => item.delivery === "queue" && item.type !== "user"))
throw new Error("Queued control items block reordering")
const current = pending.filter((item): item is QueuedPrompt => item.type === "user" && item.delivery === "queue")
const ordered = inboxIDs.flatMap((id) => current.filter((item) => item.id === id))
if (ordered.length !== current.length) throw new Error("Queued prompts changed before reordering")
const changed = ordered.findIndex((item, index) => item.id !== current[index]?.id)
// An edited prompt delivered meanwhile is no longer queued; fail so the edit draft stays.
if (ordered.length !== current.length || (replace && !current.some((item) => item.id === replace.original)))
throw new Error("Queued prompts changed before reordering")
const changed = ordered.findIndex((item, index) => item.id !== current[index]?.id || item.id === replace?.original)
if (changed < 0) return
// Existing inbox APIs cannot reorder rows, so replace only the changed suffix.
// Existing inbox APIs cannot reorder rows, so replace only the changed suffix. A replacement
// can fail (its file may be gone), so withdraw the ones already sent to keep the queue intact.
// Each ID is recorded before sending: the server can admit a prompt whose response then fails.
const replacements: string[] = []
for (const item of ordered.slice(changed)) {
await data.session.prompt({
sessionID: input.sessionID,
text: item.payload.text,
files: item.payload.files?.map((file) => ({
uri: `data:${file.mime};base64,${file.data}`,
name: file.name,
description: file.description,
mention: file.mention,
})),
agents: item.payload.agents,
skills: item.payload.skills,
metadata: item.payload.metadata,
delivery: "queue",
resume: false,
const admission =
item.id === replace?.original
? replace.admission
: {
id: SessionMessage.ID.create(),
sessionID: input.sessionID,
text: item.payload.text,
files: item.payload.files?.map((file) => ({
uri: storedFileUri(file),
name: file.name,
description: file.description,
mention: file.mention,
})),
agents: item.payload.agents,
skills: item.payload.skills,
metadata: item.payload.metadata,
delivery: "queue" as const,
resume: false,
}
const id = admission.id ?? SessionMessage.ID.create()
replacements.push(id)
await data.session.prompt({ ...admission, id }).catch(async (error) => {
await Promise.all(
replacements.map((inboxID) =>
server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID }).catch(() => undefined),
),
)
throw error
})
}
for (const item of current.slice(changed)) {
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: item.id })
}
}
const steer = (id: string) => {
if (state.editing?.id === id) cancelEdit()
return server.api.session.inbox
.update({ sessionID: input.sessionID, inboxID: id, delivery: "steer" })
.catch(() => notify())
}
const remove = (id: string) => {
if (state.editing?.id === id) cancelEdit()
return server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
}
const undo = (id: string) => {
if (mutation.isPending || state.editing) return
const item = queued().find((entry) => entry.id === id)
if (!item) return
if (input.draft.mode.current() !== "normal") {
showToast({ title: language.t("session.queue.undoShell") })
return
}
const prompt = queuedPromptUndoDraft(item)
if (!prompt) {
showToast({ title: language.t("session.queue.undoUnavailable") })
return
}
mutation.mutate({ type: "undo", item, prompt })
const source = { id: item.id, ...item.payload }
const context = extractPromptContext(source, { directory: location().directory })
mutation.mutate({
type: "undo",
item,
prompt: extractPromptFromMessage(source, {
directory: location().directory,
attachmentName: language.t("common.attachment"),
}),
context: [...context.comments, ...context.files],
})
}
// Re-admitting a queued prompt commits a staged revert, which drops every prompt queued after its boundary.
const refuseReverted = () => {
if (!data.session.get(input.sessionID)?.revert) return false
showToast({ title: language.t("session.queue.reverted") })
return true
}
const reorder = (inboxIDs: string[]) => {
if (mutation.isPending) return Promise.resolve()
if (mutation.isPending || refuseReverted()) return Promise.resolve()
return mutation.mutateAsync({ type: "reorder", inboxIDs }).catch(() => undefined)
}
const edit = (id: string) => {
if (mutation.isPending) return false
if (mutation.isPending || refuseReverted()) return false
if (state.editing?.id === id) return true
const item = queued().find((entry) => entry.id === id)
if (!item) return false
if (state.editing) cancelEdit()
const draft = input.draft.current()
setState("editing", {
@@ -194,33 +275,42 @@ export function createSessionQueue(input: {
text.length,
)
input.restoreFocus(text.length)
return true
}
const cancelEdit = () => {
const editing = state.editing
if (!editing) return
setState("editing", undefined)
// Mode first, then prompt, then retry: mode and prompt writes both clear
// the retry marker.
input.draft.mode.set(editing.stash.mode)
input.draft.set(editing.stash.prompt, editing.stash.cursor)
if (editing.stash.retry) input.draft.retry.set(editing.stash.retry)
input.restoreFocus(editing.stash.cursor)
}
const confirmEdit = (delivery: ComposerDelivery) => {
const editing = state.editing
if (!editing || mutation.isPending) return
if (!editing || mutation.isPending || refuseReverted()) return
const prompt = clonePrompt(input.draft.current())
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
const attachments = prompt.filter(isAttachment)
if (!text.trim() && !attachments.length) return cancelEdit()
const item = queued().find((entry) => entry.id === editing.id)
const original = item ? queuedPromptAttachments(item) : []
const pristine =
item &&
text.trim() === queuedPromptText(item) &&
attachments.length === original.length &&
attachments.every((attachment, index) => attachment.id === original[index].id)
if (pristine && delivery === "queue") return cancelEdit()
mutation.mutate({
type: "edit",
@@ -233,9 +323,12 @@ export function createSessionQueue(input: {
delivery,
})
}
const editFirst = () => {
const first = queued()[0]
if (!first) return false
return edit(first.id)
}
@@ -244,7 +337,9 @@ export function createSessionQueue(input: {
delivery: () => (input.working() ? input.behavior() : "steer"),
alternate: () => {
if (state.editing) return "steer"
if (!input.working()) return undefined
return input.behavior() === "queue" ? "steer" : "queue"
},
editing: () => state.editing?.id,
@@ -273,19 +368,29 @@ export type SessionQueueView = Pick<
export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original: string; replacement: string }) {
const replaced = replacement && items.some((item) => item.id === replacement.replacement)
return items
.filter((item) => !replaced || item.id !== replacement.original)
.map((item) => ({
id: item.id,
text: queuedPromptText(item),
attachments:
(item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
}))
return items.flatMap((item) =>
replaced && item.id === replacement.original
? []
: [
{
id: item.id,
text: queuedPromptText(item),
attachments:
(item.payload.files?.length ?? 0) +
(readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
},
],
)
}
const decodeDisplayText = Schema.decodeUnknownOption(Schema.Struct({ displayText: Schema.NonEmptyString }))
export function queuedPromptText(item: QueuedPrompt) {
const display = item.payload.metadata?.["displayText"]
return typeof display === "string" && display.length > 0 ? display : item.payload.text
return Option.match(decodeDisplayText(item.payload.metadata), {
onNone: () => item.payload.text,
onSome: (metadata) => metadata.displayText,
})
}
// Inline attachments are the files the composer added itself, so they return
@@ -317,86 +422,10 @@ export function queuedPromptAttachments(item: QueuedPrompt): (ImageAttachmentPar
]
}
// Use the full model-visible text so comment notes and path references remain
// in the draft. Convert mentioned files, agents, and skills back into editor
// parts; a detached draft cannot represent non-mentioned file context.
function queuedPromptUndoDraft(item: QueuedPrompt): Prompt | undefined {
if (
item.payload.files?.some((file) => !isComposerAttachment(file) && !file.mention) ||
item.payload.agents?.some((agent) => !agent.mention) ||
item.payload.skills?.some((skill) => !skill.mention)
)
return
const text = item.payload.text
const references = [
...(item.payload.files ?? []).flatMap((file) =>
file.mention
? [
{
type: "file" as const,
content: file.mention.text,
start: file.mention.start,
end: file.mention.end,
path: file.name ?? file.mention.text.replace(/^@/, ""),
filename: file.name,
mime: file.mime,
url: `data:${file.mime};base64,${file.data}`,
},
]
: [],
),
...(item.payload.agents ?? []).flatMap((agent) =>
agent.mention
? [
{
type: "agent" as const,
content: agent.mention.text,
start: agent.mention.start,
end: agent.mention.end,
name: agent.name,
},
]
: [],
),
...(item.payload.skills ?? []).flatMap((skill) =>
skill.mention
? [
{
type: "skill" as const,
content: skill.mention.text,
start: skill.mention.start,
end: skill.mention.end,
id: Skill.ID.make(skill.id),
name: Skill.Name.make(skill.name),
},
]
: [],
),
].sort((left, right) => left.start - right.start)
if (
references.some(
(part, index) =>
part.start < (references[index - 1]?.end ?? 0) || text.slice(part.start, part.end) !== part.content,
)
)
return
const parts: Prompt = references.flatMap((part, index) => {
const start = references[index - 1]?.end ?? 0
return [
...(part.start > start
? [{ type: "text" as const, content: text.slice(start, part.start), start, end: part.start }]
: []),
part,
]
})
const start = references.at(-1)?.end ?? 0
return [
...parts,
...(text.length > start || !parts.length
? [{ type: "text" as const, content: text.slice(start), start, end: text.length }]
: []),
...queuedPromptAttachments(item).filter((part) => part.type === "image"),
]
// A stored file re-admits by the URI it came from, as the TUI does, so a file:// reference keeps its
// provenance (review comment files stay recognizable) instead of turning into an inline snapshot.
function storedFileUri(file: NonNullable<QueuedPrompt["payload"]["files"]>[number]) {
return file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`
}
function isComposerAttachment(file: NonNullable<QueuedPrompt["payload"]["files"]>[number]) {
@@ -422,51 +451,76 @@ async function editedPromptInput(
.filter((part): part is ImageAttachmentPart => part.type === "image")
.map(async (part) => ({ ...part, dataUrl: await blobDataUrl(part.blob, part.mime) })),
)
const request = buildPromptRequest({ prompt, context: [], images, text, sessionDirectory: directory })
const payload = item?.payload
const display = item ? queuedPromptText(item) : ""
const notes = payload && display && payload.text.startsWith(display) ? payload.text.slice(display.length) : ""
const mention = (value: { start: number; end: number; text: string } | undefined) => {
if (!value) return undefined
const start = text.indexOf(value.text)
if (start < 0) return undefined
return { text: value.text, start, end: start + value.text.length }
}
// Structured mentions degrade to plain text in the editor, so an original
// agent or skill reference survives the edit as long as its mention text
// still appears; newly typed structured mentions come from the request.
const agents = [
...(payload?.agents?.filter(
(agent) =>
agent.mention &&
text.includes(agent.mention.text) &&
(!agent.mention || text.includes(agent.mention.text)) &&
!request.agents.some((entry) => entry.name === agent.name),
) ?? []),
...request.agents,
]
const skills = [
...(payload?.skills?.filter(
(skill) =>
skill.mention && text.includes(skill.mention.text) && !request.skills.some((entry) => entry.id === skill.id),
(!skill.mention || text.includes(skill.mention.text)) && !request.skills.some((entry) => entry.id === skill.id),
) ?? []),
...request.skills,
]
return {
sessionID,
text: request.text + notes,
files: [
// A stored file whose mention the edit deleted is dropped; unmentioned context stays.
...(payload?.files
?.filter((file) => !isComposerAttachment(file))
?.filter(
(file) =>
!isComposerAttachment(file) &&
(!file.mention || text.includes(file.mention.text)) &&
!request.files.some((entry) => entry.uri === storedFileUri(file)),
)
.map((file) => ({
uri: `data:${file.mime};base64,${file.data}`,
uri: storedFileUri(file),
name: file.name,
description: file.description,
mention: mention(file.mention),
})) ?? []),
...request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
...request.files.map((file) => ({
uri: file.uri,
name: file.name,
description: file.description,
mention: file.mention,
})),
],
agents: agents.map((agent) => ({ name: agent.name, mention: mention(agent.mention) })),
skills: skills.map((skill) => ({ id: skill.id, mention: mention(skill.mention) })),
metadata: { ...payload?.metadata, displayText: request.displayText, attachments: request.attachments },
// Presentation metadata reads only with a comments list, which prompts from other clients lack.
// Comments survive while their notes stay out of the edited text; when the editor showed the
// notes as text (a prompt with no display text), the edit owns them and the comments go.
metadata: {
...payload?.metadata,
comments: notes ? (payload?.metadata?.["comments"] ?? []) : [],
displayText: request.displayText,
attachments: request.attachments,
},
}
}
@@ -12,6 +12,9 @@ import { setCursorPosition } from "@/composer/editor/dom"
import { promptLength } from "@/composer/prompt-parts"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { showToast } from "@/shell/notifications/toast"
import { useLocal } from "@/providers/models/selection"
import { usePlatform } from "@/runtime/platform/platform"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -38,6 +41,8 @@ export function createActiveSessionRegion(input: {
visible: Accessor<boolean>
}) {
const command = useCommand()
const data = useData()
const server = useServerSDK()
const dialog = useDialog()
const language = useLanguage()
const local = useLocal()
@@ -56,6 +61,7 @@ export function createActiveSessionRegion(input: {
const message = input.timeline.lastUserMessage()
const info = input.session.data.info()
const selection = resolveSessionComposerSelection(info, message?.metadata)
if (info && selection.agent && selection.model) {
local.session.restore({ sessionID: info.id, agent: selection.agent, model: selection.model })
}
@@ -64,6 +70,7 @@ export function createActiveSessionRegion(input: {
)
createEffect(() => {
const id = input.session.identity.params.id
if (!id || !prompt.ready() || !local.session.ready()) return
// Prompt model is a submission mirror. Local drafts and durable session state own selection.
syncPromptModel(local, prompt)
@@ -73,6 +80,7 @@ export function createActiveSessionRegion(input: {
() => ({ directory: location().directory, id: input.session.identity.params.id }),
(next, previous) => {
if (!previous || (next.directory === previous.directory && next.id === previous.id)) return
if (previous.id && !next.id) local.session.reset()
},
{ defer: true },
@@ -81,42 +89,55 @@ export function createActiveSessionRegion(input: {
const openAttachment: NonNullable<SessionUserActions["openAttachment"]> = (file) => {
const url = file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`
const download = () => {
const anchor = document.createElement("a")
anchor.href = url
anchor.download = getFilename(file.name) || "attachment"
anchor.click()
}
const path = file.name ?? ""
const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path)
if (!platform.revealPath || !absolute) return download()
void platform.revealPath(path).then((revealed) => {
if (!revealed) download()
}, download)
}
const focus = () => {
if (!input.session.data.isChild()) promptRef?.focus()
}
const openParent = () => {
const id = input.session.data.parentID()
if (id) navigate(sessionHref(requireServerKey(input.session.identity.params.serverKey), id))
}
const editable = (target: EventTarget | null | undefined) => {
if (!(target instanceof HTMLElement)) return false
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
}
const activeElement = () => {
let current: Element | null = document.activeElement
while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
current = current.shadowRoot.activeElement
}
return current instanceof HTMLElement ? current : undefined
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return
const path = event.composedPath()
const target = path.find((item): item is HTMLElement => item instanceof HTMLElement)
const active = activeElement()
if (
path.some((item) => item instanceof HTMLElement && item.closest("[data-prevent-autofocus]") !== null) ||
editable(target) ||
@@ -125,34 +146,71 @@ export function createActiveSessionRegion(input: {
) {
return
}
if (event.key === "Escape" && input.session.data.isChild()) {
event.preventDefault()
openParent()
return
}
if (active === promptRef) {
if (event.key === "Escape") promptRef?.blur()
return
}
const key = scrollKey(event)
if (key) {
const scroller = input.timeline.scroller()
if (!scroller || !isScrollKeyTarget(target ?? null, key)) return
if (scrollKeyOwner(scroller, target ?? null, key) !== scroller) return
input.timeline.view.markUserScroll(scroller)
return
}
if (event.key.length !== 1 || event.key === "Unidentified" || event.ctrlKey || event.metaKey) return
if (state.blocked() || input.session.data.isChild() || !promptRef) return
promptRef.focus()
setCursorPosition(promptRef, prompt.cursor() ?? promptLength(prompt.current()))
}
onMount(() => makeEventListener(document, "keydown", handleKeyDown))
const revert = createSessionRevert({
session: input.session,
setActiveMessage: input.timeline.actions.setActiveMessage,
})
const revertMessage: NonNullable<SessionUserActions["revert"]> = ({ messageID }) => revert.to(messageID)
const notifyFailed = () => {
showToast({ title: language.t("common.requestFailed") })
}
const pendingSteer: NonNullable<SessionUserActions["pending"]> = {
steer: (messageID) => {
const sessionID = input.session.identity.params.id
if (!sessionID) return false
return data.session.pending
.list(sessionID)
.some((item) => item.id === messageID && item.type === "user" && item.delivery === "steer")
},
queue: ({ sessionID, messageID }) =>
server.api.session.inbox
.update({ sessionID, inboxID: messageID, delivery: "queue" })
.then(() => undefined, notifyFailed),
remove: ({ sessionID, messageID }) =>
server.api.session.inbox.cancel({ sessionID, inboxID: messageID }).then(() => undefined, notifyFailed),
}
useComposerCommands()
useSessionCommands({
session: input.session,
@@ -164,6 +222,12 @@ export function createActiveSessionRegion(input: {
navigateMessageByOffset: input.timeline.actions.navigateMessage,
revert,
focusInput: focus,
model: () => {
const selection = controls().model.selection
const model = selection.current()
return model ? { id: model.id, providerID: model.provider.id, variant: selection.variant.current() } : undefined
},
})
command.register("session-palette", () => [
{
@@ -185,6 +249,7 @@ export function createActiveSessionRegion(input: {
},
setDockRef: input.timeline.view.setDockRef,
}
const active = createMemo(
on(
() => (input.visible() ? input.session.identity.sessionID() : undefined),
@@ -202,8 +267,14 @@ export function createActiveSessionRegion(input: {
timeline: {
get revert() {
if (input.session.data.isChild()) return
return revertMessage
},
get pending() {
if (input.session.data.isChild()) return
return pendingSteer
},
openAttachment,
} satisfies SessionUserActions,
},
@@ -0,0 +1,197 @@
import type { BackgroundTask } from "@opencode/gui-extensions/sdk"
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
import { TextShimmer } from "@opencode/ui/text-shimmer"
import { createMemo, For, Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { useServer } from "@/runtime/server/current"
import { useOpenSessionRoute } from "@/session/session-identity-header"
import { errorMessage } from "@/shell/layout/helpers"
import { showToast } from "@/shell/notifications/toast"
const neutral = "light-dark(var(--v2-text-text-base), #ffffff)"
type RunningItem = {
key: string
type: "subagent" | "shell"
label?: string
agent?: string
sessionID?: string
target: string
}
export function SessionRunningMenu(props: {
sessionID?: string
// The session whose running work is listed: this one, or its parent inside a subagent.
owner?: string
blocking: readonly { type: "shell" | "subagent"; partID: string; id?: string; label?: string }[]
tasks: readonly BackgroundTask[]
onReveal: (target: string) => void
}) {
const language = useLanguage()
const openRoute = useOpenSessionRoute()
const server = useServer()
const sdk = useServerSDK()
const sessionAgent = (id: string | undefined) => (id ? server.ctx.data.session.get(id)?.agent : undefined)
// Foreground shells stay out: the timeline already shows them at the bottom.
const items = createMemo<RunningItem[]>(() => [
...props.blocking.flatMap((task) =>
task.type === "subagent"
? [
{
...task,
key: task.id ?? task.partID,
agent: sessionAgent(task.id),
sessionID: task.id,
target: task.partID,
},
]
: [],
),
...props.tasks.flatMap((task) =>
task.type === "subagent"
? [{ ...task, key: task.id, agent: task.agent ?? sessionAgent(task.id), sessionID: task.id, target: task.id }]
: [],
),
...props.tasks.flatMap((task) => (task.type === "shell" ? [{ ...task, key: task.id, target: task.id }] : [])),
])
const label = createMemo(() => {
const count = items().length
if (items().some((item) => item.type === "shell")) return language.plural("session.running.running", count)
return language.plural("session.running.working", count)
})
// Inside a subagent, its own row is listed among its siblings.
const viewing = (item: RunningItem) => !!item.sessionID && item.sessionID === props.sessionID
const open = (item: RunningItem) => {
const current = props.sessionID
if (!current || viewing(item)) return
if (item.sessionID) return openRoute(current, item.sessionID)
// Shell calls and starting subagent calls live in the owner's timeline, which reveals them once open.
if (props.owner && props.owner !== current) return openRoute(current, props.owner, item.target)
props.onReveal(item.target)
}
// A subagent can only be interrupted once its child session exists.
const stoppable = (item: RunningItem) => item.type === "shell" || !!item.sessionID
const stop = (item: RunningItem) => {
const request = item.sessionID
? sdk.api.session.interrupt({ sessionID: item.sessionID })
: sdk.api.shell.remove({
id: item.target,
location: { directory: server.ctx.data.shell.get(item.target)?.location.directory },
})
void request.catch((error) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(error, language.t("common.requestFailed")),
}),
)
}
return (
<Show when={items().length > 0}>
<Menu gutter={6} placement="bottom-start">
<Menu.Trigger
as="button"
type="button"
aria-label={label()}
// 8px from the ··· menu, or from the padded title inside a subagent, which has no menu.
class={`${props.owner === props.sessionID ? "ms-1.5" : "ms-0.5"} flex h-7 shrink-0 items-center rounded-[6px] px-2 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] whitespace-nowrap text-v2-text-text-base outline-none hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-v2-border-border-focus data-[expanded]:bg-v2-overlay-simple-overlay-hover`}
>
<TextShimmer text={label()} active />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="w-60" aria-label={label()}>
<For each={items()}>
{(item) => (
<Menu.Item
class="group/running-item"
classList={{ "!bg-v2-overlay-simple-overlay-hover": viewing(item) }}
aria-current={viewing(item) ? "page" : undefined}
onSelect={() => open(item)}
onKeyDown={(event) => {
if ((event.key !== "Delete" && event.key !== "Backspace") || !stoppable(item)) return
event.preventDefault()
stop(item)
}}
>
<Show
when={item.type === "subagent"}
fallback={<Icon name="console" class="shrink-0 text-v2-icon-icon-muted" />}
>
{/* Built-in agents have theme tokens; any other agent keeps the neutral color. */}
<SessionProgressIndicatorV2
class="shrink-0"
style={{
color: item.agent ? `var(--v2-agent-${item.agent.toLowerCase()}-solid, ${neutral})` : neutral,
}}
/>
</Show>
<span class="shrink-0 font-[530]">
{item.type === "shell"
? language.t("ui.tool.shell")
: item.agent
? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}`
: language.t("ui.tool.agent.default")}
</span>
{/* Menu rows end 6px in for trailing controls; text alone ends 12px in, like the leading edge. */}
<span
dir="auto"
class="me-1.5 min-w-0 flex-1 truncate text-v2-text-text-muted"
classList={{
"group-hover/running-item:me-0 group-data-[highlighted]/running-item:me-0 [@media(hover:none)]:me-0":
stoppable(item),
}}
>
{item.label}
</span>
<Show when={stoppable(item)}>
{/* The button's own display rule outranks utilities, so this wrapper shows and hides it. */}
<span class="hidden shrink-0 group-hover/running-item:flex group-data-[highlighted]/running-item:flex [@media(hover:none)]:flex">
{/* The row selects on press, so the stop button keeps its pointer events to itself. */}
<IconButton
type="button"
size="small"
variant="ghost-muted"
tabIndex={-1}
icon={<Icon name="outline-xmark" />}
aria-label={language.t(
item.type === "shell" ? "session.running.stop.shell" : "session.running.stop.subagent",
)}
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onPointerUp={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation()
stop(item)
}}
/>
</span>
</Show>
</Menu.Item>
)}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
)
}
+46 -14
View File
@@ -21,14 +21,15 @@ export function createSessionRequestModel() {
const settings = useSettings()
createEffect(() => {
const id = params.id
if (!id || serverSDK.connection.status() !== "connected") return
void Promise.all([
data.shell.sync({ directory: sdk().directory }),
data.session.permission.sync(id),
]).catch(() => undefined)
void Promise.all([data.shell.sync({ directory: sdk().directory }), data.session.permission.sync(id)]).catch(
() => undefined,
)
})
createEffect(() => {
const id = params.id
if (!id || serverSDK.connection.status() !== "connected") return
void Promise.all(
sessionTreeIDs(data.session.list(), id).map((sessionID) => data.session.form.sync(sessionID)),
@@ -38,54 +39,80 @@ export function createSessionRequestModel() {
const formRequest = createMemo((): FormInfo | undefined => {
return sessionFormRequest(data.session.list(), data.session.form.list, params.id)
})
const websearch = createWebSearchRequest({
owner: () => params.id,
connected: () => serverSDK.connection.status() === "connected",
request: () => {
const form = formRequest()
return form?.metadata?.kind === "websearch.provider" ? form : undefined
},
providers: async (sessionID) => {
const session = data.session.get(sessionID) ?? (await serverSDK.api.session.get({ sessionID }))
const result = await serverSDK.api.websearch.providers({
location: { directory: session.location.directory },
})
return result.data.map((provider) => ({ value: provider.id, label: provider.name }))
},
reply: (input) => data.session.form.reply(input),
events: serverSDK.event,
})
const questionRequest = createMemo(() => {
if (websearch.request()) return
const form = formRequest()
return form?.metadata?.kind === "question" ? form : undefined
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
if (settings.permissions.autoApprove()) return undefined
return sessionPermissionRequest(data.session.list(), data.session.permission.list, params.id)
})
const blocked = createMemo(() => {
const id = params.id
if (!id) return false
return !!permissionRequest() || !!questionRequest() || !!websearch.request()
})
const primary = () => {
const id = params.id
return !!id && !data.session.get(id)?.parentID
}
const background = createSessionBackground({
sessionID: () => (primary() ? params.id : undefined),
// Inside a subagent, running work is the parent's, so the header lists its siblings.
const owner = createMemo(() => {
const id = params.id
if (!id) return
return data.session.get(id)?.parentID ?? id
})
const running = createSessionBackground({
sessionID: owner,
messages: data.session.message.list,
sessions: data.session.list,
status: data.session.status,
shells: () => data.shell.list({ directory: sdk().directory }),
})
// Moving to the background and the extensions' background list stay with the primary session's own work.
const blocking = createMemo(() => (primary() ? running.blocking() : []))
const tasks = createMemo(() => (primary() ? running.tasks() : []))
const moveToBackground = async () => {
if (!primary()) return
const sessionID = params.id
if (!sessionID) return
await serverSDK.api.session.background({ sessionID }).catch((error) => {
showToast({
@@ -95,27 +122,31 @@ export function createSessionRequestModel() {
})
}
const [store, setStore] = createStore({
responding: undefined as string | undefined,
})
const [store, setStore] = createStore<{ responding: string | undefined }>({ responding: undefined })
const permissionResponding = createMemo(() => {
const perm = permissionRequest()
if (!perm) return false
return store.responding === perm.id
})
const decide = (response: "once" | "always" | "reject") => {
const perm = permissionRequest()
if (!perm) return
if (store.responding === perm.id) return
setStore("responding", perm.id)
serverSDK.api.permission
.reply({ sessionID: perm.sessionID, requestID: perm.id, decision: response })
.catch((err: unknown) => {
const description = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description })
.catch((error) => {
showToast({
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
})
})
.finally(() => {
setStore("responding", (id) => (id === perm.id ? undefined : id))
@@ -129,8 +160,9 @@ export function createSessionRequestModel() {
permissionRequest,
permissionResponding,
background: {
blocking: background.blocking,
tasks: background.tasks,
blocking,
tasks,
running: { sessionID: owner, blocking: running.blocking, tasks: running.tasks },
move: moveToBackground,
},
decide,
+50 -45
View File
@@ -4,8 +4,9 @@ import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useLanguage } from "@/runtime/i18n/language"
import { commentContextItem } from "@/composer/comment-note"
import { extractPromptComments, extractPromptFromMessage } from "@/composer/prompt"
import { extractPromptContext, extractPromptFromMessage } from "@/composer/prompt"
import { promptLength } from "@/composer/prompt-parts"
import { showToast } from "@/shell/notifications/toast"
import type { SessionModel } from "./model"
@@ -19,7 +20,7 @@ export function createSessionRevert(input: {
const location = useWorkspaceLocation()
const language = useLanguage()
const request = async (action: () => Promise<unknown>) =>
const request = async <A>(action: () => Promise<A>) =>
action()
.then(() => true)
.catch((error) => {
@@ -27,48 +28,50 @@ export function createSessionRevert(input: {
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
})
return false
})
const restore = (target: ReturnType<typeof prompt.capture>, message: SessionMessageUser) => {
target.set(
extractPromptFromMessage(message, {
directory: location().directory,
}),
)
target.context.replaceComments(extractPromptComments(message).map(commentContextItem))
const restored = extractPromptFromMessage(message, {
directory: location().directory,
attachmentName: language.t("common.attachment"),
})
const context = extractPromptContext(message, { directory: location().directory })
target.set(restored, promptLength(restored))
// The restored prompt replaces the draft, so chips from an earlier restore do not ride along.
target.context.replace([...context.comments, ...context.files])
}
const stage = async (message: SessionMessageUser, previous: SessionMessageUser | undefined) => {
const sessionID = input.session.identity.params.id
if (!sessionID) return
const owner = input.session.ownership.capture()
const target = prompt.capture()
if (data.session.status(sessionID) === "running") {
await server.api.session.interrupt({ sessionID }).catch(() => undefined)
// An undelivered prompt has no history to rewind. Withdraw it like the TUI
// instead of interrupting the work it is waiting behind.
if (data.session.input.has(sessionID, message.id)) {
if (!(await request(() => server.api.session.inbox.cancel({ sessionID, inboxID: message.id })))) return
restore(target, message)
owner.run(() => input.setActiveMessage(previous))
return
}
// Interrupt acknowledges before the execution settles, and staging a busy Session fails. The
// local status can lag the server either way, so always settle first; both are idle no-ops.
// Like the TUI, stop at the first failure instead of waiting on work that was never interrupted.
if (!(await request(() => server.api.session.interrupt({ sessionID })))) return
if (!(await request(() => server.api.session.wait({ sessionID })))) return
if (!(await request(() => server.api.session.revert.stage({ sessionID, messageID: message.id })))) return
// Reverting to a previous prompt discards the pending queue (and pending
// steers): they were written against the history being rewound. Cancel
// the authoritative inbox merged with the local snapshot, fire-and-forget
// so a slow request cannot delay restoring the composer. The cutoff keeps
// the asynchronous sweep away from prompts admitted after the revert; an
// old admission still in flight when the list is fetched can survive it,
// and fully closing that race needs a server-side revert-discards-inbox
// rule.
const cutoff = Date.now()
const local = data.session.pending
.list(sessionID)
.filter((item) => item.type === "user")
.map((item) => item.id)
void server.api.session.inbox
.list({ sessionID })
.then((rows) => rows.filter((row) => row.type === "user" && row.time.created <= cutoff).map((row) => row.id))
.catch(() => [])
.then((authoritative) => {
new Set([...local, ...authoritative]).forEach(
(inboxID) => void server.api.session.inbox.cancel({ sessionID, inboxID }).catch(() => undefined),
)
})
// Like the TUI, pending inputs are left alone: the revert hides them, committing it drops them,
// and redo delivers them.
restore(target, message)
owner.run(() => input.setActiveMessage(previous))
}
@@ -77,6 +80,7 @@ export function createSessionRevert(input: {
const messages = input.session.history.userMessages()
const index = messages.findIndex((message) => message.id === messageID)
const message = messages[index]
if (!message) return
await stage(message, messages[index - 1])
}
@@ -85,29 +89,30 @@ export function createSessionRevert(input: {
const messages = input.session.history.userMessages()
const reverted = input.session.data.revertMessageID()
const boundary = reverted ? messages.findIndex((message) => message.id === reverted) : messages.length
if (boundary <= 0) return
const message = messages[boundary - 1]
if (message) await stage(message, messages[boundary - 2])
}
const redo = async () => {
const sessionID = input.session.identity.params.id
const reverted = input.session.data.revertMessageID()
if (!sessionID || !reverted) return
const messages = input.session.history.userMessages()
const boundary = messages.findIndex((message) => message.id === reverted)
if (boundary < 0) return
const next = messages[boundary + 1]
if (next) {
await stage(next, messages[boundary])
return
}
const owner = input.session.ownership.capture()
const target = prompt.capture()
// Like the TUI, redo restores every reverted message at once and leaves the composer alone.
if (!(await request(() => server.api.session.revert.clear({ sessionID })))) return
target.reset()
target.context.replaceComments([])
owner.run(() => input.setActiveMessage(messages.at(-1)))
owner.run(() =>
input.setActiveMessage(
input.session.history
.userMessages()
.filter((message) => !data.session.input.has(sessionID, message.id))
.at(-1),
),
)
}
return { to, undo, redo }
+1
View File
@@ -260,6 +260,7 @@ function SessionScreenContent(props: {
setContentRef={timeline.view.setContentRef}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
reveal={timeline.view.reveal}
setScrollToEnd={timeline.view.setScrollToEnd}
search={
<Show when={active()}>
@@ -55,15 +55,19 @@ export function SessionProjectMenu(props: {
const layout = useLayout()
const settingsSurface = useSettingsSurface()
const navigate = useNavigate()
const [state, setState] = createStore({
open: false,
projectTruncated: false,
pathTruncated: false,
pathFocused: false,
})
const projectName = createMemo(() => displayName(props.project ?? { worktree: props.directory ?? "" }))
const canOpenPath = () =>
platform.platform === "desktop" && !!platform.openPath && server.isLocal && !!props.directory
const openPath = () => {
if (!canOpenPath() || !platform.openPath || !props.directory) return
void platform.openPath(props.directory).catch((cause: unknown) =>
@@ -73,8 +77,10 @@ export function SessionProjectMenu(props: {
}),
)
}
const openProjectSettings = () => {
const current = props.project
if (!current) return
settingsSurface.openProject({
server: ServerConnection.key(server.conn),
@@ -119,6 +125,7 @@ export function SessionProjectMenu(props: {
disabled={!props.project}
onSelect={() => {
const project = props.project
if (!project) return
server.ctx.projects.open(project.worktree)
layout.home.setSelection({ server: server.key, directory: project.worktree })
@@ -208,13 +215,14 @@ export function SessionAncestorTrail(props: {
trailing: boolean
}) {
const server = useServer()
const tabs = useTabs()
const navigate = useNavigate()
const language = useLanguage()
const open = useOpenSessionRoute()
const ancestors = createMemo(() => {
const path: { id: string; title: string; direct: boolean }[] = []
const seen = new Set([props.sessionID])
let id: string | undefined = props.parentID
while (id && !seen.has(id)) {
seen.add(id)
const info = server.ctx.data.session.get(id)
@@ -227,18 +235,9 @@ export function SessionAncestorTrail(props: {
})
id = info?.parentID
}
return path
})
const open = (id: string) => {
const tab = tabs.store.find(
(item) =>
item.type === "session" &&
item.server === server.key &&
(item.sessionId === props.sessionID || item.routeSessionId === props.sessionID),
)
if (tab?.type === "session") tabs.rememberSessionRoute(tab, id, server.ctx.data.session.get(id)?.parentID)
navigate(sessionHref(server.key, id))
}
return (
<div class="flex min-w-0 max-w-full items-center">
@@ -256,7 +255,7 @@ export function SessionAncestorTrail(props: {
title={ancestor.title}
dir="auto"
class="max-w-[min(200px,40vw)] shrink-0 truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
onClick={() => open(ancestor.id)}
onClick={() => open(props.sessionID, ancestor.id)}
>
{ancestor.title}
</button>
@@ -286,11 +285,32 @@ export function SessionAncestorTrail(props: {
)
}
// Opens a related session in the tab showing `from`, and records its parent so the tab's route stays consistent.
// `reveal` names a tool call or shell the opened timeline scrolls to and expands.
export function useOpenSessionRoute() {
const server = useServer()
const tabs = useTabs()
const navigate = useNavigate()
return (from: string, id: string, reveal?: string) => {
const tab = tabs.store.find(
(item) =>
item.type === "session" &&
item.server === server.key &&
(item.sessionId === from || item.routeSessionId === from),
)
if (tab?.type === "session") tabs.rememberSessionRoute(tab, id, server.ctx.data.session.get(id)?.parentID)
navigate(sessionHref(server.key, id), reveal ? { state: { reveal } } : undefined)
}
}
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
const server = useServer()
const tabs = useTabs()
const language = useLanguage()
const pending = createMemo(() => tabs.pendingSession(server.key, props.sessionID))
const tab = createMemo(() =>
tabs.store.find(
(item) =>
@@ -299,48 +319,64 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
(item.sessionId === props.sessionID || item.routeSessionId === props.sessionID),
),
)
const info = createMemo(() => {
const current = tab()
return current ? tabs.info[tabKey(current)] : undefined
})
const parentID = createMemo(() => {
if (props.session?.parentID) return props.session.parentID
const current = tab()
if (current?.type !== "session" || current.routeSessionId !== props.sessionID) return
return current.routeParentId ?? current.sessionId
})
const parent = createMemo(() => {
const id = parentID()
return id ? server.ctx.data.session.get(id) : undefined
})
const parentTitle = createMemo(() => {
const id = parentID()
const current = tab()
return sessionTitle(
parent()?.title ?? (current?.type === "session" && current.sessionId === id ? info()?.title : undefined),
)
})
const directory = createMemo(
() => props.session?.location.directory ?? pending()?.draft.directory ?? info()?.directory,
)
const title = createMemo(() =>
pending()
? language.t("session.tab.session")
: sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)),
)
const project = createMemo(() => {
if (props.session) return server.ctx.projects.forSession(props.session)
const projects = server.ctx.projects.list()
const value = directory()
if (!value) return undefined
const key = pathKey(value)
return (
projects.find(
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
) ?? server.ctx.sync.data.project.find((item) => isProjectDirectory(item, value))
)
})
const workspaceSession = createMemo(() => !!pending() || isWorkspaceDirectory(project(), directory() ?? ""))
return (
<Show when={title() || parentTitle()}>
<SessionTitleHeader>
@@ -28,6 +28,7 @@ describe("visibleTimelineMessages", () => {
delivery: "steer",
payload: { text: "queued" },
} satisfies SessionInboxInfo
const work = {
id: "msg_5",
type: "assistant",
@@ -65,8 +66,9 @@ describe("visibleTimelineMessages", () => {
shellToolDefaultOpen: () => false,
editToolDefaultOpen: () => false,
timelineDetail: () => timelinePresets[2].value,
pendingUserMessageIDs: () => new Set([steer.id]),
pendingInputIDs: () => new Set([steer.id]),
})
expect(projection.activeMessageID()).toBe("msg_1")
expect(projection.rows().map((row) => [row._tag, row.userMessageID])).toEqual([
["UserMessage", "msg_1"],
@@ -113,7 +115,7 @@ describe("visibleTimelineMessages", () => {
expect(visibleTimelineMessages(delivered, [])).toBe(delivered)
})
test("preserves steer order and excludes reverted steers", () => {
test("preserves pending input order and excludes reverted steers", () => {
const source = [...messages, work]
const pending = [steer, { ...steer, id: "msg_4" }]
expect(visibleTimelineMessages(source, pending).map((message) => message.id)).toEqual([
@@ -123,6 +125,30 @@ describe("visibleTimelineMessages", () => {
"msg_3",
"msg_4",
])
// A notice admitted after the steers, before the next step, sinks with them in admission order. The
// server delivers steers in that order and the store moves each delivered input to the end, so the
// rendered order does not change at delivery.
const notice = {
id: "msg_4a",
sessionID: "ses_1",
time: { created: 4 },
type: "synthetic",
delivery: "steer",
payload: { text: "", description: "Task finished" },
} satisfies SessionInboxInfo
const noticeMessage = {
id: notice.id,
type: "synthetic",
...notice.payload,
time: notice.time,
} satisfies SessionMessageInfo
const pendingOrder = visibleTimelineMessages([...messages, noticeMessage, work], [...pending, notice])
// The order the server delivers in: active work, then steers and notices by admission.
expect(pendingOrder.map((message) => message.id)).toEqual(["msg_1", "msg_2", "msg_5", "msg_3", "msg_4", "msg_4a"])
expect(visibleTimelineMessages(source, pending, "msg_4").map((message) => message.id)).toEqual([
"msg_1",
"msg_2",
@@ -185,6 +211,7 @@ describe("applyTimelineMessageHandoff", () => {
...handoff,
files: [{ data: "YQ==", mime: "image/png", source: { type: "inline" } }],
} satisfies SessionMessageInfo
expect(applyTimelineMessageHandoff([durable], handoff)).toEqual([durable])
})
})
@@ -3,9 +3,12 @@ import type { SessionInboxInfo, SessionMessageInfo, SessionMessageUser } from "@
export function applyTimelineMessageHandoff(messages: SessionMessageInfo[], handoff?: SessionMessageUser) {
if (!handoff) return messages
const index = messages.findIndex((message) => message.id === handoff.id)
if (index < 0) return [...messages, handoff]
const message = messages[index]
if (message.type !== "user" || message.files?.length) return messages
return messages.map((item, current) => (current === index ? { ...message, files: handoff.files } : item))
}
@@ -17,18 +20,26 @@ export function visibleTimelineMessages(
const queued = new Set(
pending.flatMap((item) => (item.type === "user" && item.delivery === "queue" ? [item.id] : [])),
)
const steers = new Set(
pending.flatMap((item) => (item.type === "user" && item.delivery === "steer" ? [item.id] : [])),
const inputs = new Set(
pending.flatMap((item) =>
(item.type === "user" && item.delivery === "steer") || item.type === "synthetic" ? [item.id] : [],
),
)
if (queued.size === 0 && steers.size === 0 && !revertMessageID) return messages
if (queued.size === 0 && inputs.size === 0 && !revertMessageID) return messages
const visible = messages.filter(
(message) => !queued.has(message.id) && (!revertMessageID || message.id < revertMessageID),
)
if (steers.size === 0) return visible
// Pending steers do not own assistant work until they are delivered.
if (inputs.size === 0) return visible
// Undelivered inputs do not own assistant work, so they stay below the active work like the TUI.
// They keep admission order: the server delivers steers in that order, so delivery moves nothing.
return [
...visible.filter((message) => !steers.has(message.id)),
...visible.filter((message) => steers.has(message.id)),
...visible.filter((message) => !inputs.has(message.id)),
...visible.filter((message) => inputs.has(message.id)),
]
}
@@ -39,6 +50,8 @@ export function timelineChildTitle(input: {
fallback: string
}) {
if (!input.parentID) return input.title ?? ""
if (input.taskDescription) return input.taskDescription
return input.title?.replace(/\s+\(@[^)]+ subagent\)$/, "") || input.fallback
}
@@ -4,6 +4,7 @@ import { Button } from "@opencode/ui/button"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, on } from "solid-js"
import { createStore } from "solid-js/store"
import { Option, Predicate, Schema } from "effect"
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
import { useDialog } from "@opencode/ui/context/dialog"
import { useLanguage } from "@/runtime/i18n/language"
@@ -25,18 +26,27 @@ import { getSessionMessageHandoff } from "@/session/handoff"
import type { ReasoningMode } from "@opencode/session-ui/timeline/projection"
const emptyMessages: SessionMessageInfo[] = []
const decodeErrorData = Schema.decodeUnknownOption(Schema.Struct({ data: Schema.Struct({ message: Schema.String }) }))
const taskDescription = (message: SessionMessageInfo, sessionID: string): string | undefined => {
if (message.type !== "assistant") return
const tool = message.content.findLast((item) => {
if (item.type !== "tool" || (item.name !== "task" && item.name !== "subagent")) return false
const metadata =
item.state.status === "running" || item.state.status === "completed" ? item.state.metadata : undefined
return metadata?.sessionId === sessionID || metadata?.sessionID === sessionID
})
if (tool?.type !== "tool") return
const input = typeof tool.state.input === "string" ? undefined : tool.state.input
const input = Predicate.isString(tool.state.input) ? undefined : tool.state.input
const value = input?.description
if (typeof value === "string" && value) return value
if (Predicate.isString(value) && value) return value
return undefined
}
@@ -57,44 +67,63 @@ export function createTimelineController(input: { session: TimelineSessionSource
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform()
const handedOffMessages = createMemo(() =>
applyTimelineMessageHandoff(
input.session.history.messages(),
getSessionMessageHandoff(input.session.identity.sessionKey()),
),
)
const projectedMessages = createMemo(() => {
const id = input.session.identity.sessionID()
return visibleTimelineMessages(
handedOffMessages(),
id ? data.session.pending.list(id) : [],
input.session.data.info()?.revert?.messageID,
)
})
const pendingUserMessageIDs = createMemo(() => {
const pendingInputIDs = createMemo(() => {
const id = input.session.identity.sessionID()
return new Set(
(id ? data.session.pending.list(id) : []).flatMap((item) =>
item.type === "user" && item.delivery === "steer" ? [item.id] : [],
(item.type === "user" && item.delivery === "steer") || item.type === "synthetic" ? [item.id] : [],
),
)
})
const queuedCompactionIDs = createMemo(() => {
const id = input.session.identity.sessionID()
return (id ? data.session.pending.list(id) : []).flatMap((item) => (item.type === "compaction" ? [item.id] : []))
})
const titleValue = createMemo(() => input.session.data.info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()) ?? language.t("session.tab.session"))
const parentMessages = createMemo(() => {
const id = input.session.data.parentID()
return id ? data.session.message.list(id) : emptyMessages
})
const parentTitle = createMemo(
() => sessionTitle(input.session.data.parent()?.title) ?? language.t("session.tab.session"),
)
const childTaskDescription = createMemo(() => {
const id = input.session.identity.sessionID()
if (!id) return undefined
return parentMessages()
.map((message) => taskDescription(message, id))
.findLast((value): value is string => !!value)
})
const childTitle = createMemo(() => {
return timelineChildTitle({
parentID: input.session.data.parentID(),
@@ -103,9 +132,12 @@ export function createTimelineController(input: { session: TimelineSessionSource
fallback: language.t("session.tab.session"),
})
})
const showHeader = createMemo(() => !!input.session.identity.sessionID())
const timelineDetail = createMemo(() => {
const detail = settings.general.timelineDetail()
return {
shell: { ...detail.shell },
edit: { ...detail.edit },
@@ -115,14 +147,17 @@ export function createTimelineController(input: { session: TimelineSessionSource
tools: { ...detail.tools },
}
})
const reasoningMode = (): ReasoningMode =>
timelineDetail().thinking.placement === "hidden"
? "hidden"
: timelineDetail().thinking.details === "expanded"
? "full"
: "compact"
const shellToolPartsExpanded = () => timelineDetail().shell.details === "expanded"
const editToolPartsExpanded = () => timelineDetail().edit.details === "expanded"
const projection = createTimelineProjection({
sessionMessages: projectedMessages,
status: input.session.data.status,
@@ -130,48 +165,63 @@ export function createTimelineController(input: { session: TimelineSessionSource
shellToolDefaultOpen: shellToolPartsExpanded,
editToolDefaultOpen: editToolPartsExpanded,
timelineDetail,
pendingUserMessageIDs,
pendingInputIDs,
queuedCompactionIDs,
})
const [pending, setPending] = createStore({ rename: false })
const errorMessage = (error: unknown) => {
if (error && typeof error === "object" && "data" in error) {
const data = error.data
if (data && typeof data === "object" && "message" in data && typeof data.message === "string") return data.message
}
if (error instanceof Error) return error.message
return language.t("common.requestFailed")
}
const errorMessage = (cause: unknown) =>
Option.match(decodeErrorData(cause), {
onSome: (error) => error.data.message,
onNone: () => (cause instanceof Error ? cause.message : language.t("common.requestFailed")),
})
const rename = async (title: string) => {
const id = input.session.identity.sessionID()
if (!id || pending.rename) return false
const next = title.trim()
if (!next || next === (titleLabel() ?? "")) return true
setPending("rename", true)
const success = await serverSDK.api.session
.update({ sessionID: id, title: next })
.then(() => true)
.catch((error) => {
showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) })
return false
})
setPending("rename", false)
if (!success) return false
const current = data.session.get(id)
if (current) data.session.remember({ ...current, title: next })
return true
}
const href = (id: string) => sessionHref(server.key, id)
const navigateAfterRemoval = (id: string, parent?: string, next?: string) => {
if (input.session.identity.params.id !== id) return
if (parent) return navigate(href(parent))
if (next) return navigate(href(next))
return tabs.newDraft({ server: server.key, directory: sdk().directory })
}
const exportSession = async (id: string) => {
try {
const data = await fetchSessionExport({ sessionID: id, api: serverSDK.api })
const filename = sessionExportFilename(data.info)
if (!(await saveSessionExport(filename, data, platform))) return
showToast({
variant: "success",
@@ -187,23 +237,29 @@ export function createTimelineController(input: { session: TimelineSessionSource
})
}
}
const remove = async (id: string) => {
const session = data.session.get(id)
if (!session) return false
const sessions = data.session.list().filter((item) => !item.parentID && !item.time?.archived)
const index = sessions.findIndex((item) => item.id === id)
const next = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const removed = sessionTreeIDs(data.session.list(), id)
const success = await data.session
.remove(id)
.then(() => true)
.catch((error) => {
showToast({ title: language.t("session.delete.failed.title"), description: errorMessage(error) })
return false
})
if (!success) return false
void navigateAfterRemoval(id, session.parentID, next?.id)
notifySessionTabsRemoved({ server: server.key, directory: sdk().directory, sessionIDs: removed })
return true
}
@@ -211,10 +267,12 @@ export function createTimelineController(input: { session: TimelineSessionSource
const name = createMemo(
() => sessionTitle(data.session.get(props.sessionID)?.title) ?? language.t("session.tab.session"),
)
const confirm = async () => {
await remove(props.sessionID)
dialog.close()
}
return (
<Dialog fit>
<DialogHeader hideClose>
@@ -272,6 +330,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
showDelete: (id: string) => dialog.show(() => <DeleteDialog sessionID={id} />),
navigateParent: () => {
const id = input.session.data.parentID()
if (id) navigate(href(id))
},
},
@@ -10,11 +10,22 @@ import { createTimelineModel } from "./model"
export function createSessionTimelineInteraction(session: SessionModel) {
const layout = useLayout()
const location = useLocation()
const location = useLocation<{ reveal?: string }>()
const timeline = createTimelineModel({ session })
const [state, setState] = createStore({
messageID: undefined as string | undefined,
pendingMessage: undefined as string | undefined,
const [state, setState] = createStore<{
messageID: string | undefined
pendingMessage: string | undefined
scroll: { overflow: boolean; jump: boolean }
follow: { sessionKey: string; pinned: boolean }
refs: {
scroller: HTMLDivElement | undefined
content: HTMLDivElement | undefined
dock: HTMLDivElement | undefined
}
}>({
messageID: undefined,
pendingMessage: undefined,
scroll: {
overflow: false,
jump: false,
@@ -24,23 +35,32 @@ export function createSessionTimelineInteraction(session: SessionModel) {
pinned: true,
},
refs: {
scroller: undefined as HTMLDivElement | undefined,
content: undefined as HTMLDivElement | undefined,
dock: undefined as HTMLDivElement | undefined,
scroller: undefined,
content: undefined,
dock: undefined,
},
})
// The single source of truth for "follow the newest content". The virtualizer pins and unpins
// it from scroll geometry; everything else only expresses explicit intent.
const pinned = () => state.follow.sessionKey !== session.identity.sessionKey() || state.follow.pinned
const pin = () => setState("follow", { sessionKey: session.identity.sessionKey(), pinned: true })
const unpin = () => {
if (!scroller || scroller.scrollHeight - scroller.clientHeight <= 1) return
setState("follow", { sessionKey: session.identity.sessionKey(), pinned: false })
}
let scroller: HTMLDivElement | undefined
let dockHeight = 0
// The history entry whose tool call was already revealed.
let revealed: string | undefined
// The active timeline's navigation, registered by its virtualizer.
let revealMessage = (_id: string, _partID?: string) => {}
let scrollToEnd = () => {}
let scrollMark = 0
let messageMark = 0
let scrollStateFrame: number | undefined
@@ -55,49 +75,65 @@ export function createSessionTimelineInteraction(session: SessionModel) {
if (!scroller) return state.messageID
const box = scroller.getBoundingClientRect()
const line = box.top + 100
const list = [...scroller.querySelectorAll<HTMLElement>("[data-message-id]")]
.map((element) => {
const id = element.dataset.messageId
if (!id) return undefined
const rect = element.getBoundingClientRect()
return { id, top: rect.top, bottom: rect.bottom }
})
.filter((item): item is { id: string; top: number; bottom: number } => !!item)
const list = [...scroller.querySelectorAll<HTMLElement>("[data-message-id]")].flatMap((element) => {
const id = element.dataset.messageId
if (!id) return []
const rect = element.getBoundingClientRect()
return [{ id, top: rect.top, bottom: rect.bottom }]
})
const shown = list.filter((item) => item.bottom > box.top && item.top < box.bottom)
const hit = shown.find((item) => item.top <= line && item.bottom >= line)
if (hit) return hit.id
const near = [...shown].sort((a, b) => {
const aDistance = Math.abs(a.top - line)
const bDistance = Math.abs(b.top - line)
if (aDistance !== bDistance) return aDistance - bDistance
return a.top - b.top
})[0]
if (near) return near.id
return list.filter((item) => item.top <= line).at(-1)?.id ?? list[0]?.id ?? state.messageID
}
const setActiveMessage = (message: SessionMessageUser | undefined) => {
messageMark = scrollMark
setState("messageID", message?.id)
}
const jumpThreshold = (element: HTMLDivElement) => Math.max(400, element.clientHeight)
const updateScrollState = (element: HTMLDivElement) => {
const max = element.scrollHeight - element.clientHeight
const distance = max - element.scrollTop
const overflow = max > 1
const jump = overflow && distance > jumpThreshold(element)
if (state.scroll.overflow === overflow && state.scroll.jump === jump) return
setState("scroll", { overflow, jump })
}
const scheduleScrollState = (element: HTMLDivElement) => {
scrollStateTarget = element
if (scrollStateFrame !== undefined) return
scrollStateFrame = requestAnimationFrame(() => {
scrollStateFrame = undefined
const target = scrollStateTarget
scrollStateTarget = undefined
if (target) updateScrollState(target)
})
}
const { clearMessageHash, scrollToMessage } = useSessionHashScroll({
sessionKey: session.identity.sessionKey,
sessionID: () => session.identity.params.id,
@@ -123,64 +159,85 @@ export function createSessionTimelineInteraction(session: SessionModel) {
scheduleScrollState,
consumePendingMessage: (key) => layout.pendingMessage.consume(key),
})
const resume = () => {
setState("messageID", undefined)
pin()
scrollToEnd()
clearMessageHash()
if (scroller) scheduleScrollState(scroller)
}
const navigateMessage = (offset: number) => {
const messages = visibleUserMessages()
if (messages.length === 0) return
const current = state.messageID && messageMark === scrollMark ? state.messageID : cursor()
const base = current ? messages.findIndex((message) => message.id === current) : messages.length
const target = (base === -1 ? messages.length : base) + offset
if (target < 0 || target > messages.length) return
if (target === messages.length) {
resume()
return
}
unpin()
scrollToMessage(messages[target], "auto")
}
// A gesture inside a nested scrollable region scrolls that region, not the timeline.
const markUserScroll = (target?: EventTarget | null) => {
if (!scroller) return
const element = target instanceof Element ? target : undefined
const nested = element?.closest("[data-scrollable]")
if (nested && nested !== scroller) return
scrollMark += 1
}
const selectionInteraction = () => {
const selection = window.getSelection()
if (selection && selection.toString().length > 0) unpin()
}
const setScrollRef = (element: HTMLDivElement | undefined) => {
scroller = element
setState("refs", "scroller", element)
if (!element) return
scheduleScrollState(element)
fill()
}
async function loadOlder() {
const owner = session.ownership.capture()
if (timeline.history.loading() || historyRequests.has(owner.key)) return
historyRequests.add(owner.key)
const before = timeline.messages().length
try {
await timeline.history.loadOlder()
} finally {
historyRequests.delete(owner.key)
}
if (!owner.current() || timeline.messages().length <= before) return
if (pinned() || !scroller || scroller.scrollTop >= 200 || !timeline.history.more()) return
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
historyContinuationFrame = requestAnimationFrame(() => {
historyContinuationFrame = undefined
owner.run(onHistoryScroll)
})
}
const onHistoryScroll = () => {
if (
historyRequests.has(session.ownership.key()) ||
@@ -192,16 +249,21 @@ export function createSessionTimelineInteraction(session: SessionModel) {
return
void loadOlder()
}
const fill = () => {
if (fillFrame !== undefined) return
fillFrame = requestAnimationFrame(() => {
fillFrame = undefined
if (!session.identity.params.id || !timeline.ready()) return
if (!pinned() || timeline.history.loading() || !scroller) return
if (scroller.scrollHeight > scroller.clientHeight + 1 || !timeline.history.more()) return
void loadOlder()
})
}
createEffect(
on(
() => visibleUserMessages().at(-1)?.id,
@@ -272,20 +334,27 @@ export function createSessionTimelineInteraction(session: SessionModel) {
() => state.refs.dock,
({ height }) => {
const next = Math.ceil(height)
if (next === dockHeight) return
const delta = next - dockHeight
const stick = scroller
? pinned() || scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop < 10 + Math.max(0, delta)
: false
dockHeight = next
if (stick) scrollToEnd()
if (scroller) scheduleScrollState(scroller)
fill()
},
)
onCleanup(() => {
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
})
@@ -311,6 +380,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
scheduleScrollState,
setContentRef: (element: HTMLDivElement | undefined) => {
setState("refs", "content", element)
if (scroller) scheduleScrollState(scroller)
},
setDockRef: (element: HTMLDivElement | undefined) => {
@@ -319,6 +389,13 @@ export function createSessionTimelineInteraction(session: SessionModel) {
setRevealMessage: (reveal: (id: string, partID?: string) => void) => {
revealMessage = reveal
},
// The tool call the route opened the session on, revealed once per history entry.
reveal: {
target: () => (revealed === location.key ? undefined : location.state?.reveal),
done: () => {
revealed = location.key
},
},
setScrollRef,
setScrollToEnd: (scroll: () => void) => {
scrollToEnd = scroll
@@ -26,10 +26,18 @@ import { parseCommentNote, readPromptPresentation } from "@/composer/comment-not
import { useCommand } from "@/shell/commands/command"
import { SessionAncestorTrail, SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
import { SessionHeaderSpacer } from "@/session/header/session-header"
import { SessionRunningMenu } from "@/session/header/session-running-menu"
type BlockingTask = { type: "shell" | "subagent"; partID: string; id?: string; label?: string }
type SessionBackground = {
blocking: Accessor<{ type: "shell" | "subagent"; partID: string; id?: string; label?: string }[]>
blocking: Accessor<BlockingTask[]>
tasks: Accessor<readonly BackgroundTask[]>
running: {
sessionID: Accessor<string | undefined>
blocking: Accessor<BlockingTask[]>
tasks: Accessor<readonly BackgroundTask[]>
}
move: () => Promise<void>
}
@@ -79,6 +87,7 @@ type MessageTimelineProps = {
anchor: (id: string) => string
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
reveal?: { target: () => string | undefined; done: () => void }
search?: JSX.Element
}
@@ -174,7 +183,12 @@ function MessageTimelineView(
onUserScroll: props.onUserScroll,
onHistoryScroll: props.onHistoryScroll,
canRenderImmediately: (row, disclosure) => {
if (Predicate.isTagged(row, "TurnGap") || Predicate.isTagged(row, "TurnDivider")) return true
if (
Predicate.isTagged(row, "TurnGap") ||
Predicate.isTagged(row, "TurnDivider") ||
Predicate.isTagged(row, "CompactionQueued")
)
return true
if (Predicate.isTagged(row, "Notice")) {
const message = messageByID().get(row.messageID)
@@ -217,6 +231,7 @@ function MessageTimelineView(
},
setRevealMessage: props.setRevealMessage,
setScrollToEnd: props.setScrollToEnd,
reveal: props.reveal,
})
const VirtualizedTimeline = virtualized.View
@@ -543,6 +558,13 @@ function MessageTimelineView(
</Menu>
)}
</Show>
<SessionRunningMenu
sessionID={sessionID()}
owner={props.background.running.sessionID()}
blocking={props.background.running.blocking()}
tasks={props.background.running.tasks()}
onReveal={virtualized.revealPart}
/>
</div>
</div>
<Show when={sessionID()} keyed>
+26 -2
View File
@@ -1,9 +1,11 @@
import { createMemo, createResource, type Accessor } from "solid-js"
import type { SessionMessageInfo } from "@opencode/client/promise"
import { useData } from "@/runtime/server/current"
import { getSessionMessageHandoff } from "@/session/handoff"
import type { SessionModel } from "../model"
const leadingTurnPageDelay = 200
const leadingTurnPageLimit = 3
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history" | "ownership"> }) {
@@ -24,22 +26,36 @@ export function createTimelineModel(input: { session: Pick<SessionModel, "identi
pause: () => new Promise((resolve) => setTimeout(resolve, leadingTurnPageDelay)),
maxPages: leadingTurnPageLimit,
}).catch(() => undefined)
return id
},
)
const ready = createMemo(() => {
const id = input.session.identity.sessionID()
// Enrich the partial leading group without withholding the already loaded tail.
return !id || data.session.message.list(id).length > 0 || (!resource.loading && resource.latest === id)
if (!id || (!resource.loading && resource.latest === id)) return true
// Enrich the partial leading group without withholding the already loaded tail. Undelivered inbox rows
// load with the inbox, not the transcript, so alone they are no tail; a first prompt shows through its handoff.
return (
data.session.message.list(id).some((message) => !data.session.input.has(id, message.id)) ||
!!getSessionMessageHandoff(input.session.identity.sessionKey())
)
})
const more = () => {
const id = input.session.identity.sessionID()
return id ? data.session.message.more(id) : false
}
const loading = () => {
const id = input.session.identity.sessionID()
return id ? data.session.message.loading(id) : false
}
const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => {
return loadOlderTimeline({
sessionID: input.session.identity.sessionID,
@@ -74,18 +90,24 @@ export async function enrichLeadingTurn(input: {
if (!input.current() || pages >= input.maxPages || !leadingTurnNeedsParent(input.messages()) || !input.more())
return
await input.pause()
if (!input.current() || !leadingTurnNeedsParent(input.messages()) || !input.more()) return
if (input.loading()) return load(pages)
await input.loadMore()
return load(pages + 1)
}
return load(0)
}
function leadingTurnNeedsParent(messages: SessionMessageInfo[]) {
const assistant = messages.findIndex((message) => message.type === "assistant")
if (assistant === -1) return false
const boundary = messages.findIndex((message) => message.type === "user" || message.type === "shell")
return boundary === -1 || assistant < boundary
}
@@ -98,6 +120,7 @@ export async function loadOlderTimeline(input: {
after?: (done: boolean) => void
}) {
const id = input.sessionID()
if (!id || !input.more() || input.loading()) return
input.before?.()
@@ -105,6 +128,7 @@ export async function loadOlderTimeline(input: {
if (input.sessionID() === id) input.after?.(true)
throw error
})
if (input.sessionID() !== id) return
input.after?.(true)
}
@@ -7,7 +7,8 @@ import {
type VirtualItem,
} from "@tanstack/solid-virtual"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode/ui/scroll-view"
import { TimelineRow } from "@opencode/session-ui/timeline/projection"
import { Predicate } from "effect"
import { Timeline, TimelineRow } from "@opencode/session-ui/timeline/projection"
import { useLanguage } from "@/runtime/i18n/language"
import {
batch,
@@ -29,11 +30,15 @@ import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { filterVirtualIndexes } from "./virtual-items"
const fallbackItemSize = 60
const pendingMarkdown = '[data-component="markdown"]:not([data-markdown-ready])'
// Distance from the bottom that counts as "at the end". Deliberately tight: a collapse clamps
// exactly to the end, while a one-pixel nudge upward is a deliberate move away from it.
const endEpsilon = 0.5
const upwardKeys = new Set(["up", "page-up", "home"])
const cache = new Map<
string,
{
@@ -46,7 +51,7 @@ const cache = new Map<
type Projection = Pick<
ReturnType<typeof createTimelineProjection>,
"activeMessageID" | "messageLastRowIndex" | "messageRowIndex" | "rowByKey" | "rows"
"activeMessageID" | "messageByID" | "messageLastRowIndex" | "messageRowIndex" | "rowByKey" | "rows"
>
type Input = {
@@ -73,6 +78,8 @@ type Input = {
) => boolean
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
/** A tool call the route asks this timeline to open on; `done` clears it once revealed. */
reveal?: { target: () => string | undefined; done: () => void }
}
type ViewProps = {
@@ -106,11 +113,14 @@ export function createTimelineVirtualizer(input: Input) {
const [rendering, setRendering] = createStore({ initialTail: coldBottomMount, scrollAdjustment: 0 })
const rows = input.projection.rows
const rowByKey = input.projection.rowByKey
const rowKeys = createMemo(() => rows().map(TimelineRow.key), undefined, {
equals: (previous, next) => previous.length === next.length && previous.every((key, index) => key === next[index]),
})
const knownKeys = new Set(rowKeys())
const addedKeys = new Set<string>()
const getItemKey = createMemo(() => {
const keys = rowKeys()
keys
@@ -119,39 +129,47 @@ export function createTimelineVirtualizer(input: Input) {
knownKeys.add(key)
addedKeys.add(key)
})
return (index: number) => keys[index] ?? `removed:${index}`
})
const rangeExtractor = createMemo(() => {
const id = input.projection.activeMessageID()
const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1
const initialTail = rendering.initialTail && input.pinned()
return (range: Range) => {
// Batch a bounded cheap suffix, but stop before unknown/large content.
// A large tail still mounts alone before estimates expose earlier history.
const start = Math.max(0, range.startIndex - 2)
const boundary = initialTail
? rows()
.slice(start, range.count)
.findLastIndex(
(row) =>
!(
row._tag === "AssistantPart" &&
Predicate.isTagged(row, "AssistantPart") &&
row.group.type === "context" &&
row.group.refs.length <= 64 &&
!toolOpen[`context:${row.group.key}`]
) && !input.canRenderImmediately?.(row, toolOpen),
)
: -1
const first = Math.min(range.count - 1, start + boundary + 1)
const indexes = initialTail
? Array.from({ length: range.count - first }, (_, index) => first + index)
: defaultRangeExtractor({ ...range, overscan: 2 })
return filterVirtualIndexes(
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
range.count,
)
}
})
const measuredElements = new WeakSet<Element>()
let touchStart: number | undefined
let touchTarget: EventTarget | null = null
@@ -173,6 +191,7 @@ export function createTimelineVirtualizer(input: Input) {
getScrollElement: () => listRoot() ?? null,
observeElementRect: (instance, callback) => {
reportRect = callback
return observeElementRect(instance, (rect) => {
if (active()) callback(rect)
})
@@ -190,19 +209,23 @@ export function createTimelineVirtualizer(input: Input) {
// clamping row translations lets the compositor paint between
// corrections and makes the content oscillate at the top.
const root = listRoot()
if (
rendering.scrollAdjustment !== 0 &&
root &&
(logicalOffset <= 0 || offset <= 0 || (touchStart !== undefined && offset <= root.clientHeight))
)
flushTouchAdjustment()
if (!scrolling && touchStart === undefined) finishTouchScroll()
})
settleColdBottom()
}
return observeElementOffsetReconnectAware(instance, reportOffset, () => {
if (!active()) return
virtualContent?.querySelectorAll<HTMLDivElement>("[data-index]").forEach(virtualizer.measureElement)
if (input.pinned()) virtualizer.scrollToEnd()
settleColdBottom()
})
@@ -222,17 +245,23 @@ export function createTimelineVirtualizer(input: Input) {
const initial = !measuredElements.has(element)
measuredElements.add(element)
const box = entry?.borderBoxSize[0]
if (box) return Math.round(box.blockSize)
if (initial) {
const size = instance.itemSizeCache.get(instance.options.getItemKey(instance.indexFromElement(element)))
if (size !== undefined || coldPending) return size ?? fallbackItemSize
}
return element.offsetHeight
},
scrollToFn: (offset, options, instance) => {
if (!active()) return
if (batchingColdSizes && input.pinned()) return
setRendering("scrollAdjustment", 0)
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
elementScroll(offset, options, instance)
},
@@ -255,6 +284,7 @@ export function createTimelineVirtualizer(input: Input) {
return rangeExtractor()
},
})
const resizeItem = virtualizer.resizeItem
const pendingSizes = new Map<number, { key: string; size: number }>()
let resizeScheduled = false
@@ -263,20 +293,27 @@ export function createTimelineVirtualizer(input: Input) {
virtualizer.resizeItem = (index, size) => {
if (!active()) return
const row = rows()[index]
if (!row) return
const key = TimelineRow.key(row)
if ((virtualizer.itemSizeCache.get(key) ?? fallbackItemSize) === size) {
pendingSizes.delete(index)
return
}
pendingSizes.set(index, { key, size })
if (resizeScheduled) return
resizeScheduled = true
queueMicrotask(() => {
resizeScheduled = false
if (!pendingSizes.size) return
const sizes = [...pendingSizes]
pendingSizes.clear()
if (!active()) return
// The hidden pinned mount needs one bottom write after the whole batch,
// not a layout-forcing scroll adjustment for every measured row.
@@ -284,40 +321,52 @@ export function createTimelineVirtualizer(input: Input) {
batch(() => {
sizes.forEach(([index, value]) => {
const row = rows()[index]
if (!row || TimelineRow.key(row) !== value.key) return
resizeItem(index, value.size)
// TanStack recalculates its range after each resize. Advance the
// logical fold before deciding whether the next row needs anchoring.
if (!touchAdjustment) return
setRendering("scrollAdjustment", (value) => value + touchAdjustment)
touchAdjustment = 0
const root = listRoot()
if (root) reportOffset?.(root.scrollTop, virtualizer.isScrolling)
})
})
batchingColdSizes = false
if (coldPending) pinColdBottom()
settleColdBottom()
if (coldPending) return
if (!input.pinned()) return
const root = listRoot()
// Reopening a settled scroll-to-end operation can fight subsequent keyboard scrolling.
if (root && Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > endEpsilon)
virtualizer.scrollToEnd()
})
}
onCleanup(() => pendingSizes.clear())
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, delta, instance) => {
// Prepended rows can resize more than once as deferred content mounts. Keep
// compensating while they remain entirely above the visible content fold.
const first = instance.range?.startIndex
const adjust = addedKeys.has(String(item.key))
? item.end <= (instance.scrollOffset ?? 0) + instance.scrollAdjustments + instance.options.scrollMargin
: first !== undefined && item.index < first
if (!touchScrolling || input.pinned()) return adjust
// iOS defers native scroll writes until momentum ends. Keep the same visual
// anchor now, rather than moving rows now and snapping the viewport back later.
if (adjust) touchAdjustment += delta
return false
}
@@ -326,6 +375,46 @@ export function createTimelineVirtualizer(input: Input) {
flushTouchAdjustment()
}
// Scrolls to a tool call, named by its ID or by the shell ID a backgrounded shell call reports, and expands it and
// the collapsed context group that hides it. Returns whether the call is in the list.
function revealPart(target: string) {
if (!active()) return false
const matches = (ref: { messageID: string; partID: string }) => {
const content = Timeline.resolveContent(input.projection.messageByID().get(ref.messageID), ref.partID)
return (
content?.type === "tool" &&
(content.id === target || (content.state.status !== "streaming" && content.state.metadata?.shellID === target))
)
}
const found = rows()
.flatMap((row, index) =>
Predicate.isTagged(row, "AssistantPart")
? (row.group.type === "part" ? [row.group.ref] : row.group.refs).flatMap((ref) =>
matches(ref) ? [{ group: row.group, index, partID: ref.partID }] : [],
)
: [],
)
.at(-1)
if (!found) return false
const key = found.group.key
setToolOpen(
found.group.type === "context"
? { [`context:${key}`]: true, [`${key}:tool:${found.partID}`]: true }
: { [key]: true },
)
input.onUnpin()
prepareNavigation()
virtualizer.scrollToIndex(found.index, { align: "center" })
return true
}
function prepareNavigation() {
if (touchStart === undefined) touchScrolling = false
flushTouchAdjustment()
@@ -334,41 +423,57 @@ export function createTimelineVirtualizer(input: Input) {
function flushTouchAdjustment() {
const adjustment = rendering.scrollAdjustment
const root = listRoot()
if (!adjustment || !root) return
// Transfer the translation into the native offset in the same paint.
batch(() => {
setRendering("scrollAdjustment", 0)
if (virtualContent) virtualContent.style.height = `${virtualizer.getTotalSize()}px`
elementScroll(Math.max(0, root.scrollTop + adjustment), {}, virtualizer)
})
}
const virtualItemByKey = createMemo(
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
)
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => String(item.key)))
createEffect(() => {
if (!active()) return
const root = listRoot()
if (root) input.setScrollRef(root)
if (virtualContent) input.setContentRef(virtualContent)
queueMicrotask(() => {
if (!active() || !root?.isConnected) return
// A detached view can miss its nonzero ResizeObserver delivery. Publish
// its real viewport before restoring the offset and admitting rows.
reportRect?.({ width: root.offsetWidth, height: root.offsetHeight })
if (input.pinned()) virtualizer.scrollToEnd()
// A route can open this timeline on a tool call, e.g. a shell picked from inside a subagent, instead of its end.
const target = input.reveal?.target()
if (target && revealPart(target)) input.reveal?.done()
else if (input.pinned()) virtualizer.scrollToEnd()
reportOffset?.(root.scrollTop, false)
settleColdBottom()
})
input.setRevealMessage?.((id, partID) => {
if (!active()) return
const partIndex = partID
? rows().findIndex(
(row) => row._tag === "AssistantPart" && row.group.type === "part" && row.group.ref.partID === partID,
(row) =>
Predicate.isTagged(row, "AssistantPart") && row.group.type === "part" && row.group.ref.partID === partID,
)
: -1
const index = partIndex >= 0 ? partIndex : input.projection.messageRowIndex().get(id)
if (index === undefined) return
prepareNavigation()
virtualizer.scrollToIndex(index, { align: "center" })
@@ -385,32 +490,42 @@ export function createTimelineVirtualizer(input: Input) {
let settleQueued = false
let contentObserver: MutationObserver | undefined
let viewportObserver: ResizeObserver | undefined
const pinColdBottom = () => {
if (!active()) return
const root = listRoot()
if (!input.pinned() || !virtualContent || !root) return
// scrollToEnd computes its target from the DOM, not the new size cache.
virtualContent.style.height = `${virtualizer.getTotalSize()}px`
if (Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > endEpsilon) virtualizer.scrollToEnd()
// Report after core size adjustments finish so they cannot apply a delta
// twice. This avoids waiting a frame for the native scroll event.
if (virtualizer.scrollOffset !== root.scrollTop) reportOffset?.(root.scrollTop, false)
}
const pendingMeasurements = () => {
const items = virtualizer.getVirtualItems()
return (
(rows().length > 0 && items.length === 0) ||
items.some((item) => !virtualizer.elementsCache.get(item.key)?.isConnected)
)
}
const settleColdBottom = () => {
if (!active() || !coldPending || settleQueued) return
settleQueued = true
queueMicrotask(() => {
settleQueued = false
const root = listRoot()
if (!coldPending || !virtualContent?.isConnected || !root) return
if (virtualContent.querySelector(pendingMarkdown)) return
if (!root.clientHeight) return
// Markdown can finish before ResizeObserver delivers its new box. The
// normal measureElement path skips reads while scrolling; this gate needs
@@ -418,17 +533,23 @@ export function createTimelineVirtualizer(input: Input) {
virtualizer.elementsCache.forEach((element) => {
if (element.isConnected) virtualizer.resizeItem(virtualizer.indexFromElement(element), element.offsetHeight)
})
if (pendingSizes.size || pendingMeasurements()) return
pinColdBottom()
if (input.pinned() && Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > 1) return
// The scroll event must update the range before newly exposed rows can reveal.
if (root.scrollHeight > root.clientHeight && Math.abs((virtualizer.scrollOffset ?? 0) - root.scrollTop) > 1)
return
if (rendering.initialTail) {
setRendering("initialTail", false)
settleColdBottom()
return
}
if (pendingSizes.size || pendingMeasurements() || virtualContent.querySelector(pendingMarkdown)) return
coldPending = false
contentObserver?.disconnect()
@@ -436,6 +557,7 @@ export function createTimelineVirtualizer(input: Input) {
virtualContent.style.removeProperty("visibility")
})
}
onMount(() => {
if (!coldPending || !virtualContent) return
contentObserver = new MutationObserver(settleColdBottom)
@@ -447,6 +569,7 @@ export function createTimelineVirtualizer(input: Input) {
})
viewportObserver = new ResizeObserver(settleColdBottom)
const root = listRoot()
if (root) viewportObserver.observe(root)
settleColdBottom()
})
@@ -454,6 +577,7 @@ export function createTimelineVirtualizer(input: Input) {
let measuredSessionKey = input.sessionKey()
createEffect(() => {
const key = input.sessionKey()
if (measuredSessionKey !== key) {
measuredSessionKey = key
virtualizer.measure()
@@ -467,6 +591,7 @@ export function createTimelineVirtualizer(input: Input) {
setListRoot(root)
scrollTop = root.scrollTop
maxScroll = root.scrollHeight - root.clientHeight
if (active()) input.setScrollRef(root)
viewportObserver?.observe(root)
settleColdBottom()
@@ -476,6 +601,7 @@ export function createTimelineVirtualizer(input: Input) {
// the end must stop following, even though the resulting position still looks like the end.
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
input.onUserScroll(event.target)
if (event.deltaY < 0) input.onUnpin()
}
@@ -493,20 +619,25 @@ export function createTimelineVirtualizer(input: Input) {
touchTarget?.addEventListener("touchmove", handleListTouchMove, { passive: true })
touchTarget?.addEventListener("touchend", handleListTouchEnd, { passive: true })
touchTarget?.addEventListener("touchcancel", handleListTouchEnd, { passive: true })
if (root) reportOffset?.(root.scrollTop, virtualizer.isScrolling)
}
const handleListTouchMove = (event: Event) => {
if (!(event instanceof TouchEvent)) return
const current = event.touches[0]?.clientY
if (current === undefined || touchStart === undefined) return
const previous = touchStart
touchStart = current
// A retained target can outlive its whole session view. Only the active
// timeline may change the shared follow state; release still cleans up below.
if (!active()) return
// Dragging the content downward reveals earlier messages.
if (current <= previous) return
// A nested scrollport owns the intent. If it chains into the timeline at a
// boundary, the resulting native timeline scroll below will unpin instead.
if (touchNested) return
@@ -516,6 +647,7 @@ export function createTimelineVirtualizer(input: Input) {
const handleListTouchEnd = () => {
clearTouchTarget()
touchStart = undefined
if (!virtualizer.isScrolling) finishTouchScroll()
}
@@ -525,6 +657,7 @@ export function createTimelineVirtualizer(input: Input) {
touchTarget?.removeEventListener("touchcancel", handleListTouchEnd)
touchTarget = null
}
onCleanup(clearTouchTarget)
// Drag-selecting past the edge and dragging the scrollbar both scroll without a wheel or key,
@@ -533,9 +666,11 @@ export function createTimelineVirtualizer(input: Input) {
input.onUserScroll(event.target)
pointerHeld = true
}
const releasePointer = () => {
pointerHeld = false
}
onMount(() => {
window.addEventListener("pointerup", releasePointer)
window.addEventListener("pointercancel", releasePointer)
@@ -547,10 +682,14 @@ export function createTimelineVirtualizer(input: Input) {
const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
const key = scrollKey(event)
if (!key) return
if (!isScrollKeyTarget(event.target, key)) return
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
input.onUserScroll(event.currentTarget)
if (upwardKeys.has(key)) input.onUnpin()
}
@@ -566,6 +705,7 @@ export function createTimelineVirtualizer(input: Input) {
maxScroll = root.scrollHeight - root.clientHeight
const atEnd = maxScroll - scrollTop <= endEpsilon
const arrived = scrollTop > previousTop + endEpsilon || maxScroll < previousMaxScroll
if (maxScroll <= 1 || (atEnd && arrived)) input.onPin()
else if ((pointerHeld || touchScrolling) && scrollTop < previousTop - endEpsilon) input.onUnpin()
settleColdBottom()
@@ -601,13 +741,19 @@ export function createTimelineVirtualizer(input: Input) {
width: "100%",
height: `${item().size}px`,
overflow: "clip",
"overflow-clip-margin": row()._tag === "TurnGap" ? undefined : "0.5px",
"overflow-clip-margin": Predicate.isTagged(row(), "TurnGap") ? undefined : "0.5px",
}}
>
<div
ref={(value) => {
element = value
if (row()._tag !== "UserMessage" || !addedKeys.has(rowProps.rowKey) || !input.pinned() || coldPending)
if (
!Predicate.isTagged(row(), "UserMessage") ||
!addedKeys.has(rowProps.rowKey) ||
!input.pinned() ||
coldPending
)
return
// The optimistic row can paint before ResizeObserver corrects the tail estimates.
// Measure the mounted tail and pin it in this render's microtask instead.
@@ -626,9 +772,11 @@ export function createTimelineVirtualizer(input: Input) {
>
{props.renderRow(row, () => {
setReady(true)
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
contentMeasureFrame = requestAnimationFrame(() => {
contentMeasureFrame = undefined
if (active() && element.isConnected) virtualizer.measureElement(element)
})
})}
@@ -692,6 +840,7 @@ export function createTimelineVirtualizer(input: Input) {
data-timeline-virtual-content
ref={(element) => {
virtualContent = element
if (active()) input.setContentRef(element)
}}
style={{
@@ -723,10 +872,12 @@ export function createTimelineVirtualizer(input: Input) {
patchGroupKeys,
presentationKey: input.presentationKey?.(),
})
while (cache.size > 16) cache.delete(cache.keys().next().value!)
coldPending = false
contentObserver?.disconnect()
viewportObserver?.disconnect()
if (active()) {
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
@@ -740,6 +891,7 @@ export function createTimelineVirtualizer(input: Input) {
value: (key: string) => toolOpen[key],
set: (key: string, open: boolean) => setToolOpen(key, open),
},
revealPart,
View,
}
}
@@ -27,17 +27,20 @@ export const useSessionHashScroll = (input: {
let pendingKey = ""
let clearing = false
const location = useLocation()
const location = useLocation<{ reveal?: string }>()
const navigate = useNavigate()
const frames = new Set<number>()
const queue = (fn: () => void) => {
const id = requestAnimationFrame(() => {
frames.delete(id)
fn()
})
frames.add(id)
}
const cancel = () => {
for (const id of frames) cancelAnimationFrame(id)
frames.clear()
@@ -46,7 +49,9 @@ export const useSessionHashScroll = (input: {
const clearMessageHash = () => {
cancel()
input.consumePendingMessage(input.sessionKey())
if (input.pendingMessage()) input.setPendingMessage(undefined)
if (!location.hash) return
clearing = true
navigate(location.pathname + location.search, { replace: true })
@@ -54,6 +59,7 @@ export const useSessionHashScroll = (input: {
const updateHash = (id: string) => {
const hash = `#${input.anchor(id)}`
if (location.hash === hash) return
clearing = false
navigate(location.pathname + location.search + hash, {
@@ -63,6 +69,7 @@ export const useSessionHashScroll = (input: {
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
const root = input.scroller()
if (!root) return false
const a = el.getBoundingClientRect()
@@ -71,27 +78,33 @@ export const useSessionHashScroll = (input: {
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
const top = Math.max(0, a.top - b.top + root.scrollTop - inset)
root.scrollTo({ top, behavior })
return true
}
const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => {
input.revealMessage?.(id)
const el = document.getElementById(input.anchor(id))
if (el) return scrollToElement(el, behavior)
if (left <= 0) return false
queue(() => {
seek(id, behavior, left - 1)
})
return false
}
const scrollToMessage = (message: SessionMessageUser, behavior: ScrollBehavior = "smooth") => {
cancel()
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
input.revealMessage?.(message.id)
if (seek(message.id, behavior)) {
updateHash(message.id)
return
}
@@ -100,39 +113,54 @@ export const useSessionHashScroll = (input: {
const applyHash = (behavior: ScrollBehavior) => {
const hash = location.hash.slice(1)
if (!hash) {
// A route that opens the session on a tool call leaves the scroll to the timeline, which reveals the call.
if (location.state?.reveal) return
input.follow.toBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
return
}
const messageId = messageIdFromHash(hash)
if (messageId) {
input.follow.unpin()
const msg = messageById().get(messageId)
if (msg) {
scrollToMessage(msg, behavior)
return
}
return
}
const target = document.getElementById(hash)
if (target) {
input.follow.unpin()
scrollToElement(target, behavior)
return
}
input.follow.toBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
}
createEffect(() => {
const hash = location.hash
if (!hash) clearing = false
if (!input.sessionID() || !input.messagesReady()) return
cancel()
queue(() => applyHash("auto"))
@@ -144,11 +172,14 @@ export const useSessionHashScroll = (input: {
visibleUserMessages()
let targetId = input.pendingMessage()
if (!targetId) {
const key = input.sessionKey()
if (pendingKey !== key) {
pendingKey = key
const next = input.consumePendingMessage(key)
if (next) {
input.setPendingMessage(next)
targetId = next
@@ -157,13 +188,16 @@ export const useSessionHashScroll = (input: {
}
if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
if (!targetId) return
const pending = input.pendingMessage() === targetId
const msg = messageById().get(targetId)
if (!msg) return
if (pending) input.setPendingMessage(undefined)
if (input.currentMessageId() === targetId && !pending) return
input.follow.unpin()
@@ -173,14 +207,19 @@ export const useSessionHashScroll = (input: {
createEffect(() => {
const sessionID = input.sessionID()
if (!sessionID || !input.messagesReady()) return
visibleUserMessages()
let targetId = input.pendingMessage()
if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
if (!targetId) return
if (messageById().has(targetId)) return
if (!input.historyMore() || input.historyLoading()) return
void input.loadMore(sessionID)
@@ -13,40 +13,61 @@ export function useSessionTabAvatarState(
const settings = useSettings()
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
const serverCtx = useServerCtx(connection)
const sessions = createMemo(() => {
const data = serverCtx()?.data
if (!data) return []
if (!root?.()) return data.session.list()
const id = sessionId()
return [...new Set([id, ...data.session.family(id)])].flatMap((id) => {
const info = data.session.get(id)
return info ? [info] : []
})
})
const hasPermissions = createMemo(() => {
if (settings.permissions.autoApprove()) return false
const ctx = serverCtx()
if (!ctx) return false
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId())
})
const hasForms = createMemo(() => {
const data = serverCtx()?.data
if (!data) return false
return !!sessionFormRequest(sessions(), data.session.form.list, sessionId())
})
const needsAttention = createMemo(() => hasPermissions() || hasForms())
const unread = createMemo(
() => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0,
)
const loading = createMemo(() => {
const data = serverCtx()?.data
if (!data) return false
if (needsAttention()) return false
// Like the TUI, work waiting in the inbox counts as busy; parked synthetic context does not.
const busy = (id: string) =>
data.session.status(id) === "running" || data.session.pending.list(id).some((item) => item.type !== "synthetic")
if (root?.())
return data.session
.list()
.some((session) => data.session.root(session.id) === sessionId() && data.session.status(session.id) === "running")
return data.session.status(sessionId()) === "running"
return data.session.list().some((session) => data.session.root(session.id) === sessionId() && busy(session.id))
return busy(sessionId())
})
return { unread, loading }
}
@@ -272,3 +272,30 @@
transform: translateX(-50%);
}
}
/* A prompt that arrives in a background tab rings it once, like the TUI's prompt pulse. */
[data-titlebar-tab] [data-slot="tab-prompt-pulse"] {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
opacity: 0;
box-shadow: inset 0 0 0 1px var(--v2-border-border-strong);
animation: tab-prompt-pulse 900ms ease-out;
}
@keyframes tab-prompt-pulse {
20% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-titlebar-tab] [data-slot="tab-prompt-pulse"] {
animation: none;
}
}
+58 -2
View File
@@ -1,5 +1,6 @@
import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
import { createStore } from "solid-js/store"
import { Predicate } from "effect"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query"
@@ -51,44 +52,75 @@ export function TabNavItem(props: {
event.stopPropagation()
props.onClose()
}
const servers = useServers()
const serverCtx = useServerCtx(() => servers.list.find((item) => ServerConnection.key(item) === props.server))
const project = createMemo(() => {
const session = props.session
if (!session) return
return serverCtx()?.projects.forSession(session)
})
const title = createMemo(() => {
const session = props.session
return sessionTabTitle(session ? session.title : props.fallbackTitle, language.t("session.tab.session"))
})
const projectName = createMemo(() => {
const session = props.session
if (!session) return
return displayName(project() ?? { worktree: session.location.directory })
})
const previewPath = createMemo(() => {
const session = props.session
if (!session) return
const home = serverCtx()?.sync.data.path.home
return home ? session.location.directory.replace(home, "~") : session.location.directory
})
// Only label the server when multiple servers are connected.
const serverLabel = createMemo(() => {
if (servers.list.length <= 1) return
const conn = servers.list.find((item) => ServerConnection.key(item) === props.server)
return conn ? serverName(conn) : undefined
})
// Like the TUI, a prompt sent to a background tab's session pulses the tab once.
const [promptPulse, setPromptPulse] = createSignal(0)
createEffect(() => {
const data = serverCtx()?.data
const sessionID = props.session?.id
if (!data || !sessionID) return
onCleanup(
data.on("session.inbox.enqueued", (event) => {
if (props.active || event.data.item.type !== "user" || data.session.root(event.data.sessionID) !== sessionID)
return
setPromptPulse((count) => count + 1)
}),
)
})
const [popoverOpen, setPopoverOpen] = createSignal(false)
const previewBlocked = () => !!props.dragging || editing() || menu.open || !!props.pressed || !props.session
const measureTitleOverflow = () => {
if (!titleEl || editing()) {
setTitleOverflowing(false)
return
}
setTitleOverflowing(titleEl.scrollWidth > titleEl.clientWidth)
}
@@ -140,8 +172,10 @@ export function TabNavItem(props: {
createEffect(() => {
if (editing()) return
if (!titleEl) return
const value = title()
if (value === undefined) return
titleEl.textContent = value
})
@@ -149,8 +183,10 @@ export function TabNavItem(props: {
const openRename = (event?: MouseEvent) => {
event?.preventDefault()
event?.stopPropagation()
if (props.dragging || editing() || rename.isPending) return
const session = props.session
if (!session) return
titleEl.textContent = session.title ?? ""
setEditing(true)
@@ -169,7 +205,9 @@ export function TabNavItem(props: {
"pointerdown",
(event) => {
const target = event.target
if (!(target instanceof Node)) return
if (tabRoot.contains(target)) return
void closeRename(true)
},
@@ -183,7 +221,8 @@ export function TabNavItem(props: {
<div
ref={(el) => {
tabRoot = el
if (typeof props.ref === "function") props.ref(el)
if (Predicate.isFunction(props.ref)) props.ref(el)
}}
data-titlebar-tab
data-slot="titlebar-tab-item"
@@ -205,6 +244,9 @@ export function TabNavItem(props: {
closeTab(event)
}}
>
<Show when={promptPulse()} keyed>
<span data-slot="tab-prompt-pulse" aria-hidden="true" onAnimationEnd={() => setPromptPulse(0)} />
</Show>
<Menu.Context.Trigger
as="a"
disabled={editing() || props.dragging}
@@ -221,15 +263,20 @@ export function TabNavItem(props: {
onMouseDown={(event) => {
// Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return
if (editing()) return
if (props.suppressNavigation) return
props.onNavigate()
}}
onClick={(event) => {
event.preventDefault()
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return
if (editing()) return
if (props.suppressNavigation) return
props.onNavigate()
}}
@@ -277,11 +324,14 @@ export function TabNavItem(props: {
onDblClick={openRename}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void closeRename(true)
return
}
if (event.key !== "Escape") return
event.preventDefault()
titleEl.textContent = props.session?.title ?? ""
@@ -321,6 +371,7 @@ export function TabNavItem(props: {
modal={false}
onOpenChange={(open) => {
setMenu("open", open)
if (open) setPopoverOpen(false)
}}
>
@@ -372,15 +423,17 @@ export function DraftTabItem(props: {
orientation?: "horizontal" | "vertical"
}) {
const language = useLanguage()
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}
return (
<div
ref={(el) => {
if (typeof props.ref === "function") props.ref(el)
if (Predicate.isFunction(props.ref)) props.ref(el)
}}
data-titlebar-tab
data-slot="titlebar-tab-item"
@@ -412,13 +465,16 @@ export function DraftTabItem(props: {
onMouseDown={(event) => {
// Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return
if (props.suppressNavigation) return
props.onNavigate()
}}
onClick={(event) => {
event.preventDefault()
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return
if (props.suppressNavigation) return
props.onNavigate()
}}
+26 -1
View File
@@ -40,6 +40,7 @@ function SessionTabSlot(props: {
return props.index
},
})
let ref!: HTMLDivElement
return (
@@ -92,10 +93,12 @@ function SessionTabEntry(props: {
const pending = createMemo(() => tabs.pendingSession(props.tab.server, props.tab.sessionId))
const cachedSession = createMemo(() => props.serverCtx?.data.session.get(props.tab.sessionId))
const persisted = createMemo(() => tabs.info[props.id])
const [loadedSession] = createResource(
() => {
if (pending()) return null
const ctx = props.serverCtx
return ctx ? { id: props.tab.sessionId, ctx } : null
},
({ id, ctx }) =>
@@ -104,6 +107,7 @@ function SessionTabEntry(props: {
.then(() => ctx.data.session.get(id))
.catch(() => undefined),
)
const session = createMemo(() => (pending() ? undefined : (cachedSession() ?? loadedSession())))
const missingSession = createMemo(() => !pending() && !!props.serverCtx && !loadedSession.loading && !session())
const visible = createMemo(() => !!pending() || !!session() || missingSession() || !!persisted()?.title)
@@ -111,14 +115,17 @@ function SessionTabEntry(props: {
const rename = async (title: string) => {
const value = session()
const ctx = props.serverCtx
if (!value || !ctx) return
ctx.data.session.remember({ ...value, title })
try {
await ctx.sdk.api.session.update({ sessionID: value.id, title })
} catch (err) {
const current = session()
const currentCtx = props.serverCtx
if (current && currentCtx) currentCtx.data.session.remember({ ...current, title: value.title })
showToast({
title: language.t("common.requestFailed"),
@@ -132,25 +139,32 @@ function SessionTabEntry(props: {
createEffect(() => {
const ctx = props.serverCtx
const value = session()
if (!ctx || !value || props.active || ctx.sdk.connection.status() !== "connected") return
const timer = window.setTimeout(
() =>
void Promise.allSettled([
ctx.data.session.sync(value.id, { children: true }),
// The selected timeline loads transcript and inbox data; inactive tabs need only attention and metadata.
// The selected timeline loads the transcript; inactive tabs need attention, metadata, and the inbox,
// whose waiting work keeps the tab busy.
ctx.data.session.permission.sync(value.id),
ctx.data.session.form.sync(value.id),
ctx.data.session.pending.sync(value.id),
]),
300 + props.index * 50,
)
onCleanup(() => window.clearTimeout(timer))
})
createEffect(() => {
const value = session()
if (!value) return
tabs.rememberSessionInfo(props.tab, value)
const current = sdk()
if (!current) return
createTabComposerState(tabs, props.tab, current.scope, {
dir: base64Encode(value.location.directory),
@@ -199,6 +213,7 @@ function DraftTabSlot(props: {
return props.index
},
})
let ref!: HTMLDivElement
return (
@@ -270,6 +285,7 @@ export function TitlebarTabStrip(props: {
const current = props.currentTab
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
const next = props.tabs.find((tab) => tabKey(tab) === key)
if (next) props.onNavigate(next)
}
@@ -311,9 +327,12 @@ export function TitlebarTabStrip(props: {
]}
onDragStart={(event) => {
const source = event.operation.source
if (!source) return
const tab = props.tabs.find((item) => tabKey(item) === source.id.toString())
if (!tab) return
if (vertical()) return
const tabEl = source.element?.querySelector<HTMLDivElement>("[data-titlebar-tab]")
props.onNavigate(tab, tabEl ?? undefined)
@@ -321,9 +340,11 @@ export function TitlebarTabStrip(props: {
onDragEnd={(event) => {
const current = visibleTabIds()
const source = event.operation.source
if (event.canceled || !isSortable(source)) return
const { initialIndex, index } = source
if (initialIndex !== index) {
props.onReorder(
mergeVisibleTabOrder(
@@ -348,8 +369,10 @@ export function TitlebarTabStrip(props: {
let ref!: HTMLDivElement
const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id)
useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref))
const serverCtx = useServerCtx(() => {
if (tab.type !== "session") return
return global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
})
@@ -413,7 +436,9 @@ function useTabShortcut(index: () => number, onSelect: () => void) {
command.register(() => {
const number = index() + 1
if (number < 1 || number > 9) return []
return [
{
id: `tab.${number}`,
@@ -25,7 +25,8 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
test("only crosses the renderable boundary on text deltas, while retaining live content", () => {
createRoot((dispose) => {
let visits = 0
const [state, setState] = createStore({
const [state, setState] = createStore<{ messages: SessionMessageInfo[] }>({
messages: [
{
id: "old",
@@ -33,22 +34,25 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
text: "history",
get time() {
visits++
return { created: 0 }
},
},
assistant("old-answer", [{ type: "text", text: "History remains visible." }]),
{ id: "user", type: "user", text: "question", time: { created: 1 } },
assistant("answer", [{ type: "text", text: "" }]),
] as SessionMessageInfo[],
],
})
const projection = factory({
sessionMessages: () => state.messages,
status: () => ({ type: "busy" }),
reasoningMode: () => "compact",
shellToolDefaultOpen: () => false,
editToolDefaultOpen: () => false,
pendingUserMessageIDs: () => new Set(),
pendingInputIDs: () => new Set(),
})
const update = (text: string) =>
setState(
"messages",
@@ -57,6 +61,7 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
if (message.type === "assistant" && message.content[0].type === "text") message.content[0].text = text
}),
)
const empty = projection.rows()
visits = 0
update(" \n\t")
@@ -82,7 +87,14 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
test("matches full construction through grouping, notices, history and preference transitions", () => {
createRoot((dispose) => {
const [state, setState] = createStore({
const [state, setState] = createStore<{
messages: SessionMessageInfo[]
status: SessionStatus
reasoning: ReasoningMode
shell: boolean
edit: boolean
pending: Set<string>
}>({
messages: [
assistant("answer", [
{ type: "reasoning", text: "Inspect the source", time: { created: 1 } },
@@ -109,22 +121,25 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
time: { created: 1 },
},
]),
] as SessionMessageInfo[],
status: { type: "busy" } as SessionStatus,
reasoning: "compact" as ReasoningMode,
],
status: { type: "busy" },
reasoning: "compact",
shell: false,
edit: false,
pending: new Set<string>(),
})
const projection = factory({
sessionMessages: () => state.messages,
status: () => state.status,
reasoningMode: () => state.reasoning,
shellToolDefaultOpen: () => state.shell,
editToolDefaultOpen: () => state.edit,
pendingUserMessageIDs: () => state.pending,
pendingInputIDs: () => state.pending,
})
let previous: TimelineRow.TimelineRow[] | undefined
const verify = () => {
const full = Timeline.constructSessionMessageRows(
state.messages,
@@ -134,6 +149,7 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
state.shell,
state.edit,
)
previous = reuseTimelineRows(previous, full.rows)
expect(projection.rows()).toEqual(previous)
expect(projection.activeMessageID()).toBe(full.activeMessageID)
@@ -149,6 +165,7 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
expect(projection.rowByKey().get(TimelineRow.key(row))).toBe(projection.rows()[index])
})
}
const change = (update: (message: SessionMessageAssistant) => void) => {
setState(
"messages",
@@ -159,6 +176,7 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
)
verify()
}
verify()
change((message) => {
if (message.content[2].type === "text") message.content[2].text = "Split the context group"
@@ -215,6 +233,7 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
verify()
change((message) => {
const tool = message.content.at(-1)
if (tool?.type === "tool" && tool.state.status === "completed")
tool.state.metadata = { files: [{ status: "modified" }] }
})
@@ -224,6 +243,7 @@ for (const factory of [createTimelineProjection, createReactiveTimelineProjectio
expect(projection.rows().at(-1)?._tag).toBe("Thinking")
change((message) => {
const part = message.content.at(-1)
if (part?.type === "reasoning") part.time = { created: 3, completed: 4 }
})
change((message) => {
+31 -1
View File
@@ -3,6 +3,7 @@
import { $ } from "bun"
import { mkdir, rm } from "fs/promises"
import path from "path"
import { discoverEffectSpecifiers } from "@opencode/plugin/runtime-modules.bun"
import { Script } from "@opencode/script"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import type { BunPlugin } from "bun"
@@ -78,6 +79,28 @@ const appAssetsPlugin: BunPlugin = {
}))
},
}
const pluginRuntimeSpecifiers = [
"@opencode/plugin",
"@opencode/plugin/effect",
"@opencode/plugin/effect/plugin",
"@opencode/plugin/effect/tool",
"@opencode/plugin/promise/plugin",
"@opencode/plugin/promise/tool",
"@opencode/plugin/rpc",
...discoverEffectSpecifiers().map(([specifier]) => specifier),
]
const pluginRuntimeModulesSource = `const modules = {\n${pluginRuntimeSpecifiers
.map((specifier) => ` ${JSON.stringify(specifier)}: () => require(${JSON.stringify(specifier)}),`)
.join("\n")}\n}\nexport function pluginRuntimeModules() {\n return modules\n}\n`
const pluginRuntimeModulesPlugin: BunPlugin = {
name: "opencode-plugin-runtime-modules",
setup(build) {
build.onLoad({ filter: /plugin[/\\]src[/\\]runtime-modules\.bun\.ts$/ }, () => ({
contents: pluginRuntimeModulesSource,
loader: "js",
}))
},
}
for (const item of targets) {
const opencodePty = await resolveOpencodePty({
@@ -124,7 +147,14 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin],
plugins: [
appAssetsPlugin,
solidPlugin,
parcelWatcherPlugin,
opencodePtyPlugin,
pluginRuntimeModulesPlugin,
simulationGraphPlugin,
],
external: ["node-gyp"],
format: "esm",
minify: true,
+2 -1
View File
@@ -30,8 +30,9 @@ export default Runtime.handler(
...(links[0]
? [
"",
// uqr separates rows with "\n" on every platform, so splitting on EOL ("\r\n" on Windows) indents only the first row.
renderUnicodeCompact(links[0], { border: 2 })
.split(EOL)
.split("\n")
.map((line) => " " + line)
.join(EOL),
]
+11 -7
View File
@@ -95,7 +95,14 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
} else timeouts = undefined
if (service !== undefined) {
spawnDelay = timing.spawnDelay
const compatible = service.compatible && matchesVersion(service.version, options)
const versionMatches = matchesVersion(service.version, options)
const compatible = service.compatible && versionMatches
if (!service.compatible && versionMatches)
return yield* Effect.fail(
new Error(
"Background service uses an incompatible health protocol. Update this client or explicitly restart the service.",
),
)
if (compatible && service.state === "ready") {
yield* Effect.tryPromise(() => PtyHandoff.complete(options.file ?? fallback(), service.info))
return Option.some(service)
@@ -205,10 +212,7 @@ const probe = Effect.fnUntraced(function* (info: Info) {
return (yield* probeResult(info)).service
})
const probeResult = Effect.fnUntraced(function* (
info: Info,
timeout = defaultEnsureTiming.requestTimeout,
) {
const probeResult = Effect.fnUntraced(function* (info: Info, timeout = defaultEnsureTiming.requestTimeout) {
const endpoint = {
url: info.url,
auth:
@@ -230,8 +234,8 @@ const probeResult = Effect.fnUntraced(function* (
)
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
const response = result.value.response
// The previous V2 service exposes /api/status instead. Its authenticated 404 is enough
// to recognize the registered daemon as incompatible and route it through replacement.
// A missing health endpoint identifies protocol incompatibility, not an older
// version. Only an unmet version requirement lets ensure replace this owner.
if (response.status === 404)
return {
service: {
+8 -3
View File
@@ -76,7 +76,12 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
if (registration.service !== undefined) {
spawnDelay = timing.spawnDelay
const service = registration.service
const compatible = service.compatible && matchesVersion(service.version, options)
const versionMatches = matchesVersion(service.version, options)
const compatible = service.compatible && versionMatches
if (!service.compatible && versionMatches)
throw new Error(
"Background service uses an incompatible health protocol. Update this client or explicitly restart the service.",
)
if (compatible && service.state === "ready") {
await PtyHandoff.complete(options.file ?? fallback(), service.info)
return service.endpoint
@@ -177,8 +182,8 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
)
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
const response = result.value.response
// The previous V2 service exposes /api/status instead. Its authenticated 404 is enough
// to recognize the registered daemon as incompatible and route it through replacement.
// A missing health endpoint identifies protocol incompatibility, not an older
// version. Only an unmet version requirement lets ensure replace this owner.
if (response.status === 404)
return {
service: {
+7 -1
View File
@@ -44,8 +44,12 @@ const handoff = {
}
const server = Bun.serve({
port: 0,
async fetch(request) {
async fetch(request): Promise<Response> {
const pathname = new URL(request.url).pathname
if (mode === "protocol" && pathname === "/api/experimental/persistent-pty/handoff") {
await writeFile(registration + ".handoff-request", "")
return Response.json({ handoff: null })
}
if (pathname === "/api/experimental/persistent-pty/handoff" && mode === "handoff") {
if (request.method !== "POST" || request.headers.get("authorization") !== "Basic " + btoa("opencode:private"))
return new Response(null, { status: 401 })
@@ -55,6 +59,8 @@ const server = Bun.serve({
if (pathname === "/api/experimental/persistent-pty/handoff" && mode === "handoff-broken")
return new Response(null, { status: 500 })
if (pathname !== "/api/info") return new Response(null, { status: 404 })
if (mode === "protocol" && (await Bun.file(registration + ".missing-health").exists()))
return new Response(null, { status: 404 })
requests += 1
if (mode === "starting") await writeFile(registration + ".status-request", "")
if (mode === "hanging") {
@@ -0,0 +1,94 @@
import { NodeFileSystem } from "@effect/platform-node"
import { expect, test } from "bun:test"
import { Effect } from "effect"
import { Service } from "../src/effect/service"
import { ensure, stop } from "../src/promise/service"
import type { EnsureOptions, EnsureReason, StopOptions } from "../src/service"
import { serviceFixture } from "./fixture/service-fixture"
import { accelerate } from "./fixture/service-timing"
const effect = accelerate(Service.ensure)
const clients = [
{ name: "Promise", ensure: accelerate(ensure), stop },
{
name: "Effect",
ensure: (options: EnsureOptions) => Effect.runPromise(effect(options).pipe(Effect.provide(NodeFileSystem.layer))),
stop: (options: StopOptions) => Effect.runPromise(Service.stop(options).pipe(Effect.provide(NodeFileSystem.layer))),
},
]
for (const client of clients) {
for (const policy of [
{ name: "no version requirement", version: undefined },
{ name: "matching exact version", version: "test" },
{ name: "accepting version predicate", version: (version: string) => version === "test" },
]) {
test(`${client.name} reconnect preserves a protocol-incompatible owner with ${policy.name}`, async () => {
await using fixture = await serviceFixture()
const owner = fixture.spawn("protocol")
await fixture.waitForFile()
const original = await Bun.file(fixture.registration).json()
const starts: EnsureReason[] = []
const options = {
file: fixture.registration,
version: policy.version,
command: fixture.command("record-start"),
onStart: (reason: EnsureReason) => starts.push(reason),
}
expect((await client.ensure(options)).url).toBe(original.url)
// The same process remains alive. Only its health endpoint becomes unavailable.
await Bun.write(fixture.registration + ".missing-health", "")
const error = await client.ensure(options).catch((error: unknown) => error)
expect(await Bun.file(fixture.registration + ".signal").exists()).toBe(false)
expect(await Bun.file(fixture.registration + ".handoff-request").exists()).toBe(false)
expect(await Bun.file(fixture.registration + ".started").exists()).toBe(false)
expect(starts).toEqual([])
expect(owner.exitCode).toBe(null)
expect(await Bun.file(fixture.registration).json()).toEqual(original)
expect(error).toBeInstanceOf(Error)
if (!(error instanceof Error)) throw error
expect(error.message).toContain("incompatible health protocol")
await Bun.file(fixture.registration + ".missing-health").delete()
expect((await client.ensure(options)).url).toBe(original.url)
expect(owner.exitCode).toBe(null)
})
}
test(`${client.name} can explicitly replace a protocol-incompatible owner with a nonmatching version`, async () => {
await using fixture = await serviceFixture()
const owner = fixture.spawn("protocol")
await fixture.waitForFile()
await Bun.write(fixture.registration + ".missing-health", "")
const starts: EnsureReason[] = []
const endpoint = await client.ensure({
file: fixture.registration,
version: "2.1.0-next.1",
command: fixture.command("compatible"),
onStart: (reason) => starts.push(reason),
})
const replacement = await Bun.file(fixture.registration).json()
fixture.track(replacement.pid)
expect(await owner.exited).toBe(0)
expect(await Bun.file(fixture.registration + ".signal").text()).toBe("SIGTERM")
expect(await Bun.file(fixture.registration + ".handoff-request").exists()).toBe(true)
expect(starts).toEqual(["version-mismatch"])
expect(replacement.pid).not.toBe(owner.pid)
expect(replacement.version).toBe("2.1.0-next.1")
expect(endpoint.url).toBe(replacement.url)
})
test(`${client.name} can explicitly stop a protocol-incompatible owner`, async () => {
await using fixture = await serviceFixture()
const owner = fixture.spawn("protocol")
await fixture.waitForFile()
await Bun.write(fixture.registration + ".missing-health", "")
await client.stop({ file: fixture.registration })
expect(await owner.exited).toBe(0)
expect(await Bun.file(fixture.registration + ".signal").text()).toBe("SIGTERM")
expect(await Bun.file(fixture.registration).exists()).toBe(false)
})
}
+1 -1
View File
@@ -1 +1 @@
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
+11 -3
View File
@@ -61,9 +61,17 @@ const layer = Layer.effect(
release: holdUnsafe(),
})
})
const exit = yield* Effect.suspend(() =>
plugin.effect({ ...host, storage: PluginHost.storage(kv, plugin.id) }),
).pipe(
const exit = yield* Effect.suspend(() => {
const effect = plugin.effect({ ...host, storage: PluginHost.storage(kv, plugin.id) })
if (!Effect.isEffect(effect) || effect.pipe !== Effect.void.pipe) {
return Effect.die(
new Error(
`Plugin "${plugin.id}" returned an Effect from a bundled copy of effect. Declare "effect" as a peerDependency and do not bundle it.`,
),
)
}
return effect
}).pipe(
grouped,
inherit,
Effect.updateContext((context: Context.Context<never>) =>
+12 -1
View File
@@ -128,6 +128,17 @@ const load = Effect.fn("PluginModule.load")(function* (
target: operation.target,
...(installed?.version ? { version: installed.version } : {}),
},
effect: (host) => plugin.effect({ ...host, options: operation.options }),
effect: (host) =>
Effect.suspend(() => {
const effect = plugin.effect({ ...host, options: operation.options })
if (!Effect.isEffect(effect) || effect.pipe !== Effect.void.pipe) {
return Effect.die(
new Error(
`Plugin "${plugin.id}" returned an Effect from a bundled copy of effect. Declare "effect" as a peerDependency and do not bundle it.`,
),
)
}
return effect
}),
} satisfies Generation
})
+261 -2
View File
@@ -1,17 +1,27 @@
import { expect } from "bun:test"
import { cp, mkdir, readFile, writeFile } from "node:fs/promises"
import path from "node:path"
import { Deferred, Effect, Exit, Fiber, Layer, Schedule, Scope, Stream } from "effect"
import { Brand, Deferred, Effect, Exit, Fiber, Layer, Option, Schedule, Schema, Scope, Stream } from "effect"
import { Agent } from "@opencode/schema/agent"
import { Session } from "@opencode/schema/session"
import { SessionMessage } from "@opencode/schema/session-message"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Watcher } from "@opencode/core/filesystem/watcher"
import { Plugin } from "@opencode/core/plugin"
import { PluginModule } from "@opencode/core/plugin/module"
import { Rpc } from "@opencode/core/rpc"
import { Tool } from "@opencode/core/tool"
import { execute } from "@opencode/core/tool/runtime"
import { Global } from "@opencode/util/global"
import { Npm } from "@opencode/util/npm"
import { tempGlobalLayer } from "../fixture/global"
import { tmpdirScoped } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(
Layer.merge(
Layer.mergeAll(
PluginTestLayer,
AppNodeBuilder.build(Npm.node, [Global.node.replace(tempGlobalLayer)]),
Watcher.layer().pipe(Layer.provide(Watcher.nativeLayer)),
),
@@ -90,3 +100,252 @@ it.live("interrupts pending watcher setup when the loader scope closes during mo
yield* Effect.promise(() => Bun.sleep(50))
}),
)
it.live("loads plugins and their transitive dependencies against the host's Effect instance", () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const pluginDir = path.join(directory.path, "plugin")
const pluginEffectDir = path.join(pluginDir, "node_modules/effect")
const hostEffectDir = path.dirname(Bun.resolveSync("effect/package.json", import.meta.dir))
yield* Effect.promise(async () => {
await mkdir(path.join(pluginDir, "node_modules"), { recursive: true })
await cp(hostEffectDir, pluginEffectDir, { recursive: true })
const pkgPath = path.join(pluginEffectDir, "package.json")
const pkg = { ...(await Bun.file(pkgPath).json()), version: "4.0.0-rc.111" }
await writeFile(pkgPath, JSON.stringify(pkg, null, 2))
// Reproduce the version-skew failure modes on the plugin's own Effect copy so loading it would crash:
// 1. Effect.log reading an incompatible fiber log-level property (crashing host logger with logLevel.toUpperCase)
// 2. Effect.runPromise calling fiber.succeedWith on a host fiber
// 3. Schema.withDecodingDefault / Schema.Int / Schema.isPattern / Schema.Trim using foreign parser sentinels
const internalEffectPath = path.join(pluginEffectDir, "dist/internal/effect.js")
const internalEffect = (await readFile(internalEffectPath, "utf8"))
.replace(
"const logLevel = level ?? fiber.currentLogLevel;\n if (isLogLevelGreaterThan(fiber.minimumLogLevel, logLevel)) {",
"const logLevel = level ?? fiber.cache?.logLevel;\n if (isLogLevelGreaterThan(fiber.cache?.minimumLogLevel, logLevel)) {",
)
.replace(
"export const runPromiseWith = context => {",
"export const runPromiseWith = context => {\n return (effect) => Promise.resolve().then(() => { const fiber = {}; return fiber.succeedWith(effect); });",
)
await writeFile(internalEffectPath, internalEffect)
const depDir = path.join(pluginDir, "node_modules/transitive-dep")
await mkdir(depDir, { recursive: true })
await writeFile(
path.join(depDir, "package.json"),
JSON.stringify({ name: "transitive-dep", type: "module", exports: { ".": "./index.js" } }),
)
await writeFile(
path.join(depDir, "index.js"),
`import { Effect as DepEffect, Schema as DepSchema } from "effect"
import { some as depSome } from "effect/Option"
export const depToolInput = DepSchema.Struct({
mode: DepSchema.String.pipe(DepSchema.withDecodingDefault(DepEffect.succeed("from-dep"))),
})
export { DepEffect, DepSchema, depSome }`,
)
const foreignPluginPkgDir = path.join(pluginDir, "node_modules/@opencode/plugin")
await mkdir(foreignPluginPkgDir, { recursive: true })
await writeFile(
path.join(foreignPluginPkgDir, "package.json"),
JSON.stringify({
name: "@opencode/plugin",
type: "module",
exports: { "./effect": "./effect.js", "./rpc": "./rpc.js" },
}),
)
await writeFile(
path.join(foreignPluginPkgDir, "effect.js"),
"export const Plugin = { define: (p) => p }",
)
await writeFile(path.join(foreignPluginPkgDir, "rpc.js"), "export const Rpc = { define: (d) => d }")
await writeFile(
path.join(pluginDir, "index.ts"),
`import { Plugin } from "@opencode/plugin/effect"
import { Rpc } from "@opencode/plugin/rpc"
import { Effect as PluginEffect, Schema as PluginSchema } from "effect"
import { some as pluginSome } from "effect/Option"
import { nominal as pluginNominal } from "effect/Brand"
import { DepEffect, DepSchema, depSome, depToolInput } from "transitive-dep"
export const captured = {
PluginEffect,
PluginSchema,
pluginSome,
pluginNominal,
DepEffect,
DepSchema,
depSome,
pluginCount: -1,
}
const Contract = Rpc.define({
id: "host-effect-rpc",
methods: {
check: {
input: PluginSchema.Struct({
count: PluginSchema.Int.pipe(PluginSchema.withDecodingDefault(PluginEffect.succeed(5))),
tag: PluginSchema.Trim.check(PluginSchema.isPattern(/^v[0-9]+$/)),
}),
output: PluginSchema.Struct({
value: PluginSchema.String,
}),
},
},
events: {},
})
export default Plugin.define({
id: "host-effect-fixture",
effect: (ctx) =>
PluginEffect.gen(function* () {
yield* PluginEffect.log("setup log from plugin")
const listed = yield* PluginEffect.promise(() =>
PluginEffect.runPromise(ctx.plugin.list().pipe(PluginEffect.orDie)),
)
captured.pluginCount = listed.data.length
yield* ctx.tool.transform((editor) => {
editor.add({
name: "default_tool",
description: "Tool with decoding default from transitive dependency",
input: depToolInput,
output: PluginSchema.Struct({ mode: PluginSchema.String }),
execute: ({ mode }) =>
PluginEffect.log("executing default_tool").pipe(
PluginEffect.as({ output: { mode }, content: mode }),
),
})
editor.add({
name: "check_tool",
description: "Tool with Int, Trim, and isPattern checks",
input: PluginSchema.Struct({
count: PluginSchema.Int,
code: PluginSchema.Trim.check(PluginSchema.isPattern(/^v[0-9]+$/)),
}),
output: PluginSchema.Struct({ formatted: PluginSchema.String }),
execute: ({ count, code }) =>
PluginEffect.succeed({
output: { formatted: \`\${code}:\${count}\` },
content: \`\${code}:\${count}\`,
}),
})
})
yield* ctx.rpc.register(Contract, {
check: ({ count, tag }) =>
PluginEffect.log("executing rpc check").pipe(
PluginEffect.as({ value: \`\${tag}#\${count}\` }),
),
}).pipe(PluginEffect.orDie)
}),
})`,
)
})
const modules = yield* PluginModule.make()
const plugins = yield* Plugin.Service
const tools = yield* Tool.Service
const rpc = yield* Rpc.Service
const definition = yield* modules.load({ type: "add", target: pluginDir, options: {} })
if ("pending" in definition) return yield* Effect.die(new Error("Local plugin was not loaded"))
yield* plugins.activate([definition])
yield* plugins.awaitActivation
expect(yield* plugins.list()).toMatchObject([{ id: "host-effect-fixture", state: { status: "active" } }])
const imported = (yield* Effect.promise(() => import(path.join(pluginDir, "index.ts")))) as {
captured: {
PluginEffect: unknown
PluginSchema: unknown
pluginSome: unknown
pluginNominal: unknown
DepEffect: unknown
DepSchema: unknown
depSome: unknown
pluginCount: number
}
}
expect(imported.captured.PluginEffect).toBe(Effect)
expect(imported.captured.PluginSchema).toBe(Schema)
expect(imported.captured.pluginSome).toBe(Option.some)
expect(imported.captured.pluginNominal).toBe(Brand.nominal)
expect(imported.captured.DepEffect).toBe(Effect)
expect(imported.captured.DepSchema).toBe(Schema)
expect(imported.captured.depSome).toBe(Option.some)
expect(imported.captured.pluginCount).toBe(0)
const registeredTools = yield* tools.list()
const defaultTool = registeredTools.find((tool) => tool.id === "default_tool")
const checkTool = registeredTools.find((tool) => tool.id === "check_tool")
expect(defaultTool).toBeDefined()
expect(checkTool).toBeDefined()
if (!defaultTool || !checkTool) return
const context = {
sessionID: Session.ID.make("ses_host_effect"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_host_effect"),
id: Tool.CallID.make("call_host_effect"),
progress: () => Effect.void,
}
expect(yield* execute(defaultTool, {}, context)).toEqual({
output: { mode: "from-dep" },
content: [{ type: "text", text: "from-dep" }],
})
expect(yield* execute(checkTool, { count: 3, code: " v42 " }, context)).toEqual({
output: { formatted: "v42:3" },
content: [{ type: "text", text: "v42:3" }],
})
expect(yield* rpc.call("host-effect-rpc", "check", { tag: " v9 " })).toEqual({ value: "v9#5" })
}),
)
it.live("fails activation when a plugin returns an Effect from a bundled copy of effect", () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const pluginDir = path.join(directory.path, "bundled-plugin")
const bundledEffectDir = path.join(pluginDir, "bundled-effect")
const hostEffectDir = path.dirname(Bun.resolveSync("effect/package.json", import.meta.dir))
yield* Effect.promise(async () => {
await mkdir(pluginDir, { recursive: true })
await cp(hostEffectDir, bundledEffectDir, { recursive: true })
await writeFile(
path.join(pluginDir, "index.ts"),
`import { Effect as BundledEffect } from "./bundled-effect/dist/index.js"
export default {
id: "bundled-effect-plugin",
effect: () => BundledEffect.void,
}`,
)
})
const modules = yield* PluginModule.make()
const plugins = yield* Plugin.Service
const definition = yield* modules.load({ type: "add", target: pluginDir, options: {} })
if ("pending" in definition) return yield* Effect.die(new Error("Local plugin was not loaded"))
yield* plugins.activate([definition])
yield* plugins.awaitActivation
expect(yield* plugins.list()).toMatchObject([
{
id: "bundled-effect-plugin",
state: {
status: "failed",
error: expect.stringContaining(
'Plugin "bundled-effect-plugin" returned an Effect from a bundled copy of effect. Declare "effect" as a peerDependency and do not bundle it.',
),
},
},
])
}),
)
@@ -13,15 +13,19 @@ export function createPickedFileAuthorizations(
add(sender: number, paths: string[]) {
const token = randomUUID()
selections.set(token, { sender, paths: new Set(paths), remaining: MAX_ATTACHMENT_BYTES })
return token
},
read: Effect.fn("DesktopFiles.readPickedFile")(function* (sender: number, token: string, path: string) {
const selection = selections.get(token)
if (selection?.sender !== sender || !selection.paths.delete(path))
throw new Error(nativeT("desktop.picker.error.notSelected"))
const bytes = yield* read(path, selection.remaining)
selection.remaining -= bytes.byteLength
if (selection.paths.size === 0) selections.delete(token)
return bytes
}),
release(sender: number, token: string) {
@@ -32,6 +36,7 @@ export function createPickedFileAuthorizations(
export function assertAttachmentBudget(files: { size: number }[]) {
const total = files.reduce((sum, file) => sum + file.size, 0)
if (total <= MAX_ATTACHMENT_BYTES) return
throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 }))
}
@@ -42,16 +47,20 @@ export function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES
const fs = yield* FileSystem.FileSystem
const file = yield* fs.open(filePath, { flag: "r" })
const info = yield* file.stat
if (info.size > FileSystem.Size(maxBytes))
throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 }))
const bytes = new Uint8Array(Number(info.size))
let offset = 0
while (offset < bytes.byteLength) {
const read = Number(yield* file.read(bytes.subarray(offset)))
if (read === 0) break
offset += read
}
return bytes.buffer.slice(0, offset)
}),
)
+58 -22
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { EventEmitter } from "node:events"
import { MessageChannel } from "node:worker_threads"
import type { MessagePortMain, WebContents } from "electron"
import { Effect, Layer, ManagedRuntime, Schema, Stream } from "effect"
import { Effect, Layer, ManagedRuntime, Predicate, Schema, Stream } from "effect"
import { Rpc, RpcGroup, RpcMessage, RpcServer } from "effect/unstable/rpc"
import { Transferable } from "effect/unstable/workers"
import { omitUndefined } from "../shared/ipc-transport"
@@ -13,13 +13,16 @@ describe("desktop RPC transport", () => {
test("decodes renderer payloads whose optional fields are undefined", async () => {
let received: unknown
const rpcs = RpcGroup.make(FilesOpenFilePicker)
const handlers = rpcs.toLayer({
FilesOpenFilePicker: ({ options }) =>
Effect.sync(() => {
received = options
return null
}),
})
const live = RpcServer.layer(rpcs).pipe(Layer.provide(handlers), Layer.provideMerge(IpcServerProtocolLive))
const runtime = ManagedRuntime.make(live)
const handoff = await runtime.runPromise(IpcPortHandoff)
@@ -30,13 +33,10 @@ describe("desktop RPC transport", () => {
// Structured clone keeps a present-but-undefined key, so it reaches the JSON codec.
const rejected = await call(channel.port2, 0, "FilesOpenFilePicker", payload)
expect(rejected.exit).toMatchObject({
_tag: "Failure",
cause: [{ _tag: "Die", defect: expect.stringContaining('["options"]["title"]') }],
})
expect(rejected.exit).toMatchObject(died(expect.stringContaining('["options"]["title"]')))
const accepted = await call(channel.port2, 1, "FilesOpenFilePicker", omitUndefined(payload))
expect(accepted.exit).toEqual({ _tag: "Success", value: null })
expect(accepted.exit).toEqual(success(null))
expect(received).toEqual({ multiple: true, defaultPath: "C:\\project" })
channel.port2.close()
@@ -56,13 +56,16 @@ describe("desktop RPC transport", () => {
test("keeps multiple renderer ports independent", async () => {
let received: unknown
const handlers = TestRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return TestRpcs.of({
"test.focused": (_request, context) => Effect.succeed(handoff.sender(context.client.id)?.id === 1),
"test.blob.put": ({ data }) => {
received = data
return Effect.succeed([...data].join(","))
},
"test.blob.get": () => Effect.succeed(new Uint8Array([3, 1, 4])),
@@ -70,6 +73,7 @@ describe("desktop RPC transport", () => {
})
}),
)
const live = RpcServer.layer(TestRpcs).pipe(Layer.provide(handlers), Layer.provideMerge(IpcServerProtocolLive))
const runtime = ManagedRuntime.make(live)
const handoff = await runtime.runPromise(IpcPortHandoff)
@@ -82,34 +86,36 @@ describe("desktop RPC transport", () => {
call(first.port2, 0, "test.focused", null),
call(second.port2, 0, "test.focused", null),
])
expect(focused.exit).toEqual({ _tag: "Success", value: true })
expect(unfocused.exit).toEqual({ _tag: "Success", value: false })
expect(focused.exit).toEqual(success(true))
expect(unfocused.exit).toEqual(success(false))
const put = await call(first.port2, 1, "test.blob.put", omitUndefined({ data: new Uint8Array([2, 7, 1]) }))
expect(put.exit).toEqual({ _tag: "Success", value: "2,7,1" })
expect(put.exit).toEqual(success("2,7,1"))
// Binary payloads arrive as bytes, not as base64 text or a plain object.
expect(received).toBeInstanceOf(Uint8Array)
expect((await call(first.port2, 2, "test.blob.get", null)).exit).toEqual({
_tag: "Success",
value: new Uint8Array([3, 1, 4]),
})
expect((await call(first.port2, 2, "test.blob.get", null)).exit).toEqual(success(new Uint8Array([3, 1, 4])))
expect((await call(first.port2, 3, "test.events", null)).chunks).toEqual([
{ _tag: "TestEvent", value: "session.new" },
Schema.encodeSync(TestEvent)(new TestEvent({ value: "session.new" })),
])
const reloaded = new MessageChannel()
handoff.bind(sender(1), serverPort(reloaded.port1))
const [reloadedFocused, stillUnfocused] = await Promise.all([
call(reloaded.port2, 0, "test.focused", null),
call(second.port2, 1, "test.focused", null),
])
expect(reloadedFocused.exit).toEqual({ _tag: "Success", value: true })
expect(stillUnfocused.exit).toEqual({ _tag: "Success", value: false })
expect(reloadedFocused.exit).toEqual(success(true))
expect(stillUnfocused.exit).toEqual(success(false))
for (const port of [first.port2, second.port2, reloaded.port2]) port.close()
await runtime.dispose()
})
})
class TestEvent extends Schema.TaggedClass<TestEvent>()("TestEvent", { value: Schema.String }) {}
const TestRpcs = RpcGroup.make(
Rpc.make("test.focused", { success: Schema.Boolean }),
Rpc.make("test.blob.put", { payload: { data: Transferable.Uint8Array }, success: Schema.String }),
@@ -117,28 +123,47 @@ const TestRpcs = RpcGroup.make(
Rpc.make("test.events", { success: TestEvent, stream: true }),
)
// SAFETY: these are the RPC wire format's plain encoded messages and exits, which the tests post and expect as is.
/* oxlint-disable anti-slop-effect/no-manual-tagged-construction -- see SAFETY above */
function success<A>(value: A) {
return { _tag: "Success", value } as const
}
function died<A>(defect: A) {
return { _tag: "Failure", cause: [{ _tag: "Die", defect }] } as const
}
// Speaks the wire format the way src/renderer/ipc-client.ts does: post a request, ack each chunk,
// and settle on the exit. The payload is posted as given so a test can send what omitUndefined drops.
function call(port: MessageChannel["port2"], id: number, tag: string, payload: unknown) {
function call(port: MessageChannel["port2"], id: number, tag: string, payload: RpcMessage.RequestEncoded["payload"]) {
const chunks: unknown[] = []
return new Promise<{ chunks: unknown[]; exit: RpcMessage.ResponseExitEncoded["exit"] }>((resolve) => {
const onMessage = (message: RpcMessage.FromServerEncoded) => {
if (!("requestId" in message) || Number(message.requestId) !== id) return
if (message._tag === "Chunk") {
if (Predicate.isTagged(message, "Chunk")) {
chunks.push(...message.values)
port.postMessage({ _tag: "Ack", requestId: message.requestId } satisfies RpcMessage.AckEncoded)
return
}
port.off("message", onMessage)
resolve({ chunks, exit: message.exit })
}
port.on("message", onMessage)
port.postMessage({ _tag: "Request", id, tag, payload, headers: [] })
})
}
/* oxlint-enable anti-slop-effect/no-manual-tagged-construction */
function sender(id: number) {
const events = new EventEmitter()
// SAFETY: the transport reads only a sender's `id` and `isDestroyed`, and its `destroyed` event.
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
return {
id,
isDestroyed: () => false,
@@ -148,27 +173,38 @@ function sender(id: number) {
}
function serverPort(port: MessageChannel["port1"]) {
const listeners = new Map<(event: Electron.MessageEvent) => void, (data: unknown) => void>()
return {
const listeners = new Map<(event: Electron.MessageEvent) => void, (data: Electron.MessageEvent["data"]) => void>()
const fake = {
on(event: string, listener: (event: Electron.MessageEvent) => void) {
if (event !== "message") {
port.on(event, listener)
return
}
const wrapped = (data: unknown) => listener({ data } as Electron.MessageEvent)
// SAFETY: the transport reads only the `data` of a message event.
const wrapped = (data: Electron.MessageEvent["data"]) => listener({ data } as Electron.MessageEvent)
listeners.set(listener, wrapped)
port.on("message", wrapped)
},
off(event: string, listener: (event: Electron.MessageEvent) => void) {
if (event !== "message") {
port.off(event, listener)
return
}
const wrapped = listeners.get(listener)
if (wrapped) port.off("message", wrapped)
},
postMessage: port.postMessage.bind(port),
start: port.start.bind(port),
close: port.close.bind(port),
} as unknown as MessagePortMain
}
// SAFETY: the transport uses only these methods of a port.
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
return fake as unknown as MessagePortMain
}
@@ -39,8 +39,10 @@ export const IpcServerProtocolLive = Layer.unwrap(
const disconnect = Effect.fnUntraced(function* (id: number) {
const binding = bindings.get(id)
if (!binding) return
bindings.delete(id)
if (senderBindings.get(binding.sender.id) === id) senderBindings.delete(binding.sender.id)
binding.port.off("message", binding.onMessage)
binding.port.off("close", binding.onClose)
@@ -52,16 +54,23 @@ export const IpcServerProtocolLive = Layer.unwrap(
const bind = Effect.fnUntraced(function* (sender: WebContents, port: MessagePortMain) {
const previous = senderBindings.get(sender.id)
if (previous !== undefined) yield* disconnect(previous)
if (sender.isDestroyed()) {
port.close()
return
}
const id = nextClientId++
const onMessage = (event: Electron.MessageEvent) => {
// SAFETY: the other end of this port is the renderer's ipc-client, which posts only RPC client
// messages; the RPC server decodes each request's payload with that RPC's schema.
Queue.offerUnsafe(inbound, [id, event.data as RpcMessage.FromClientEncoded] as const)
}
const onClose = () => runFork(disconnect(id))
const unbindEvents = yield* bindIpcEvents(sender.id)
const binding = { id, sender, port, onMessage, onClose, unbindEvents }
+16 -4
View File
@@ -22,6 +22,7 @@ import { DesktopCli } from "./service/desktop-cli"
import { getLastFocusedWindow } from "./windows"
const services = Layer.mergeAll(DesktopFiles.layer, Extensions.layer)
const handlers = Layer.mergeAll(
appHandlers,
storageHandlers,
@@ -31,6 +32,7 @@ const handlers = Layer.mergeAll(
eventHandlers,
extensionHandlers,
)
export const layer = RpcServer.layer(DesktopRpcs, { disableFatalDefects: true }).pipe(
Layer.provide(handlers),
Layer.provideMerge(IpcServerProtocolLive),
@@ -42,9 +44,11 @@ export const registerIpcHandlers = Effect.gen(function* () {
const lifecycle = yield* ApplicationLifecycle.Service
const desktopCli = yield* DesktopCli.Service
const runFork = Effect.runForkWith(yield* Effect.context())
const menu = {
trigger: (id: string) => {
const win = getLastFocusedWindow()
if (win) sendMenuCommand(win, id)
},
installCli: () => runFork(showCliInstaller(desktopCli)),
@@ -52,26 +56,34 @@ export const registerIpcHandlers = Effect.gen(function* () {
openExternal: (url: string) => runFork(openExternalURL(url)),
relaunch: lifecycle.relaunch,
}
const wire = (_event: Electron.Event, win: BrowserWindow) => {
const wire = (win: BrowserWindow) => {
win.webContents.on("before-input-event", (_event, input) => {
if (input.type !== "keyDown" || input.key !== "Escape") return
win.webContents.send(DragCancelEvent)
})
const post = () => {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
const channel = new MessageChannelMain()
handoff.bind(win.webContents, channel.port1)
win.webContents.postMessage(IpcTransportPort, null, [channel.port2])
}
win.webContents.on("did-finish-load", post)
// The first window starts loading before the layers exist and may already be done.
if (!win.webContents.isLoading() && win.webContents.getURL()) post()
}
const onWindowCreated = (_event: Electron.Event, win: BrowserWindow) => wire(win)
yield* Effect.sync(() => {
app.on("browser-window-created", wire)
BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win))
app.on("browser-window-created", onWindowCreated)
BrowserWindow.getAllWindows().forEach((win) => wire(win))
})
yield* Effect.addFinalizer(() => Effect.sync(() => app.off("browser-window-created", wire)))
yield* Effect.addFinalizer(() => Effect.sync(() => app.off("browser-window-created", onWindowCreated)))
return {
installMenu: () => createMenu(menu),
}
+55 -4
View File
@@ -11,33 +11,47 @@ import { IpcTransportPort, omitUndefined } from "../shared/ipc-transport"
// out of the renderer's initial module graph is worth about a third of its startup script.
type EventTag = DesktopEvent["_tag"]
type InvokeTag = Exclude<keyof DesktopRpcClient, "DesktopEvents">
type InvokeArgs<Tag extends InvokeTag> = Parameters<DesktopRpcClient[Tag]>
type InvokeResult<Tag extends InvokeTag> =
ReturnType<DesktopRpcClient[Tag]> extends Effect.Effect<infer Value, unknown> ? Value : never
type EventValue<Tag extends EventTag> = Extract<DesktopEvent, { readonly _tag: Tag }>
type Pending = {
// SAFETY: the reply is the RPC's success value as the trusted main process encoded it; see the note above.
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- see SAFETY above
readonly resolve: (value: unknown) => void
readonly reject: (error: unknown) => void
readonly reject: (cause: unknown) => void
readonly chunk?: (values: ReadonlyArray<unknown>) => void
}
const pending = new Map<number, Pending>()
const listeners = new Map<EventTag, Set<(value: unknown) => void>>()
const listeners = new Map<EventTag, Set<(value: DesktopEvent) => void>>()
// SAFETY: pagehide only waits for these callbacks to settle and ignores what they resolve to.
// oxlint-disable-next-line anti-slop/no-unknown-returns -- see SAFETY above
const beforeDispose = new Set<() => Promise<unknown> | void>()
let nextId = 0
const port = new Promise<MessagePort>((resolve) => {
const onMessage = (event: MessageEvent) => {
if (event.source !== window || event.data !== IpcTransportPort) return
const value = event.ports[0]
if (!value) return
window.removeEventListener("message", onMessage)
// SAFETY: the port comes from the trusted main process, whose RPC server posts only its own messages.
value.addEventListener("message", (message) => receive(value, message.data as RpcMessage.FromServerEncoded))
value.start()
resolve(value)
}
window.addEventListener("message", onMessage)
})
@@ -49,15 +63,20 @@ window.addEventListener(
)
void request("DesktopEvents", null, (values) => {
// SAFETY: DesktopEvents streams desktop events, whose encoded fields are JSON-native and read as decoded.
for (const value of values as ReadonlyArray<DesktopEvent>) listeners.get(value._tag)?.forEach((fn) => fn(value))
})
// SAFETY: as for `beforeDispose`, the callback's result is only awaited, never read.
// oxlint-disable-next-line anti-slop/no-unknown-returns -- see SAFETY above
export function onBeforeDispose(callback: () => Promise<unknown> | void) {
beforeDispose.add(callback)
return () => beforeDispose.delete(callback)
}
export function invoke<Tag extends InvokeTag>(tag: Tag, ...payload: InvokeArgs<Tag>): Promise<InvokeResult<Tag>> {
// SAFETY: main replies with the success value of `tag`'s RPC; its encoding is JSON-native or bytes, as decoded.
return request(tag, payload[0] ?? null) as Promise<InvokeResult<Tag>>
}
@@ -71,37 +90,55 @@ export function cancellable<Tag extends InvokeTag>(
payload: InvokeArgs<Tag>[0],
signal: AbortSignal | undefined,
): Promise<InvokeResult<Tag>> {
// SAFETY: as in `invoke`, the reply is the success value of `tag`'s RPC.
return request(tag, payload ?? null, undefined, signal) as Promise<InvokeResult<Tag>>
}
export function listen<Tag extends EventTag>(tag: Tag, listener: (value: EventValue<Tag>) => void) {
const callback = listener as (value: unknown) => void
// SAFETY: events dispatch by tag, so this listener only receives `Tag` events.
const callback = listener as (value: DesktopEvent) => void
const callbacks = listeners.get(tag) ?? new Set()
callbacks.add(callback)
listeners.set(tag, callbacks)
return () => {
callbacks.delete(callback)
if (callbacks.size === 0) listeners.delete(tag)
}
}
function request(tag: string, payload: unknown, chunk?: Pending["chunk"], signal?: AbortSignal) {
// SAFETY: this side speaks the RPC wire format by hand, whose messages are plain tagged objects. Effect's Match,
// Predicate, and message constructors would load the Effect runtime into the renderer; see the note above.
/* oxlint-disable anti-slop-effect/no-manual-tag-comparison, anti-slop-effect/no-manual-tagged-construction -- see SAFETY above */
function request(
tag: InvokeTag | "DesktopEvents",
payload: InvokeArgs<InvokeTag>[0] | null,
chunk?: Pending["chunk"],
signal?: AbortSignal,
) {
const id = nextId++
return new Promise<unknown>((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason)
const abort = () => {
if (!pending.delete(id)) return
reject(signal?.reason)
void port.then((p) => p.postMessage({ _tag: "Interrupt", requestId: id } satisfies RpcMessage.InterruptEncoded))
}
signal?.addEventListener("abort", abort, { once: true })
const settle =
<Value>(callback: (value: Value) => void) =>
(value: Value) => {
signal?.removeEventListener("abort", abort)
callback(value)
}
pending.set(id, { resolve: settle(resolve), reject: settle(reject), chunk })
const message: RpcMessage.RequestEncoded = {
_tag: "Request",
id,
@@ -109,6 +146,7 @@ function request(tag: string, payload: unknown, chunk?: Pending["chunk"], signal
payload: omitUndefined(payload),
headers: [],
}
void port.then((p) => p.postMessage(message))
})
}
@@ -118,24 +156,33 @@ function receive(p: MessagePort, message: RpcMessage.FromServerEncoded) {
case "Chunk": {
pending.get(Number(message.requestId))?.chunk?.(message.values)
p.postMessage({ _tag: "Ack", requestId: message.requestId } satisfies RpcMessage.AckEncoded)
return
}
case "Exit": {
const id = Number(message.requestId)
const entry = pending.get(id)
pending.delete(id)
if (!entry) return
if (message.exit._tag === "Success") return entry.resolve(message.exit.value)
return entry.reject(failure(message.exit.cause))
}
case "Defect": {
const error = new Error("Desktop IPC defect", { cause: message.defect })
pending.forEach((entry) => entry.reject(error))
pending.clear()
return
}
case "ClientProtocolError": {
console.error("[desktop-ipc] protocol error", message.error)
return
}
}
@@ -145,8 +192,12 @@ function receive(p: MessagePort, message: RpcMessage.FromServerEncoded) {
// and interrupts surface as errors.
function failure(cause: ReadonlyArray<{ readonly _tag: string; readonly error?: unknown; readonly defect?: unknown }>) {
const failed = cause.find((item) => item._tag === "Fail")
if (failed) return failed.error
const died = cause.find((item) => item._tag === "Die")
if (died) return new Error("Desktop IPC handler failed", { cause: died.defect })
return new Error("Desktop IPC request interrupted")
}
/* oxlint-enable anti-slop-effect/no-manual-tag-comparison, anti-slop-effect/no-manual-tagged-construction */
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff. Show more