From 8b00f4579343798a086a56b15f52b126388a0ec6 Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Thu, 27 Aug 2026 17:58:49 +0200 Subject: [PATCH] Add composer completions and update OpenCode contract --- .github/ISSUE_TEMPLATE/bug-report.yml | 4 +- TODO.md | 6 +- .../screens/prompt-admission-model.test.ts | 1 + .../src/screens/prompt-admission-model.ts | 3 + .../screens/session-composer-model.test.ts | 277 +++++++++++++++ .../src/screens/session-composer-model.ts | 314 ++++++++++++++++++ .../src/screens/session-composer.test.tsx | 153 ++++++++- apps/mobile/src/screens/session-composer.tsx | 286 +++++++++++++++- .../screens/session-execution-panel.test.tsx | 27 ++ .../src/screens/session-execution-panel.tsx | 4 +- .../src/screens/use-session-draft.test.tsx | 56 +++- apps/mobile/src/screens/use-session-draft.ts | 33 +- .../screens/use-session-execution.test.tsx | 96 ++++++ .../src/screens/use-session-execution.ts | 141 ++++++-- apps/mobile/src/screens/workspace-screen.tsx | 43 ++- .../connection-event-query-bridge.test.ts | 42 +++ .../state/connection-event-query-bridge.ts | 2 + .../src/state/open-code-query-keys.test.ts | 31 ++ apps/mobile/src/state/open-code-query-keys.ts | 9 + apps/mobile/src/storage/database.test.ts | 56 +++- apps/mobile/src/storage/database.ts | 34 +- .../src/storage/draft-repository.test.ts | 42 ++- apps/mobile/src/storage/draft-repository.ts | 91 ++++- .../prompt-admission-repository.test.ts | 4 + .../storage/prompt-admission-repository.ts | 11 +- docs/COMPATIBILITY.md | 33 ++ docs/NOTIFICATIONS.md | 2 +- docs/PUSH_AGENT_RUNBOOK.md | 8 +- docs/SPEC.md | 2 +- packages/opencode-adapter/package.json | 2 +- packages/opencode-adapter/src/index.test.ts | 199 ++++++++++- packages/opencode-adapter/src/index.ts | 134 +++++++- .../opencode-notification-plugin/package.json | 2 +- packages/test-fixtures/src/index.ts | 20 ++ pnpm-lock.yaml | 60 ++-- pnpm-workspace.yaml | 8 +- 36 files changed, 2112 insertions(+), 124 deletions(-) create mode 100644 apps/mobile/src/screens/session-composer-model.test.ts create mode 100644 apps/mobile/src/screens/session-composer-model.ts diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 87ccc33..b853840 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -54,8 +54,8 @@ body: id: opencode-version attributes: label: OpenCode version - description: The tested baseline is beta 18050. Do not include a server address. - placeholder: "0.0.0-beta-18050" + description: The tested baseline is beta 18387. Do not include a server address. + placeholder: "0.0.0-beta-18387" validations: required: true diff --git a/TODO.md b/TODO.md index 35a6c0f..e7e2c7c 100644 --- a/TODO.md +++ b/TODO.md @@ -64,7 +64,7 @@ Android, and CI checks an empty native application. ## 2. React Native client compatibility spike - [x] Check the V2 client docs and replace the pinned `@next` build with tested - beta 18050. + beta 18387. - [x] Record the exact tested OpenCode application versions and behavioral compatibility probes; report differing versions without rejecting a server that passes those probes. @@ -398,8 +398,8 @@ against OpenCode. - [ ] Add native hardware-keyboard commands without changing multiline Enter behavior. -- [ ] Add command and skill completion. -- [ ] Add file, agent, and skill mentions. +- [x] Add command completion. +- [x] Add file, agent, and skill mentions. - [ ] Add phone-file attachments using bounded inline data URLs and server-file attachments using server-accessible file URLs. - [ ] Implement filesystem find, list, and read through generated operations. diff --git a/apps/mobile/src/screens/prompt-admission-model.test.ts b/apps/mobile/src/screens/prompt-admission-model.test.ts index 8206489..d5ec2b8 100644 --- a/apps/mobile/src/screens/prompt-admission-model.test.ts +++ b/apps/mobile/src/screens/prompt-admission-model.test.ts @@ -22,6 +22,7 @@ test("creates a stable caller-owned message ID before transmission", () => { delivery: "queue", durable: false, id: "msg_12345678123442348234123456789abc", + kind: "prompt", status: "submitting", submittedAtMs: 42, }); diff --git a/apps/mobile/src/screens/prompt-admission-model.ts b/apps/mobile/src/screens/prompt-admission-model.ts index d4ce305..576648c 100644 --- a/apps/mobile/src/screens/prompt-admission-model.ts +++ b/apps/mobile/src/screens/prompt-admission-model.ts @@ -1,6 +1,7 @@ import * as Crypto from "expo-crypto"; export type PromptDelivery = "queue" | "steer"; +export type PromptAdmissionKind = "command" | "prompt"; export type PromptAdmissionStatus = | "submitting" @@ -19,6 +20,7 @@ export type PromptAdmission = { draftRevision?: number; durable: boolean; id: string; + kind: PromptAdmissionKind; retryOffered?: boolean; serverAdmittedAtMs?: number; status: PromptAdmissionStatus; @@ -43,6 +45,7 @@ export function createPromptAdmission( ...(delivery ? { delivery } : {}), durable: false, id: `msg_${randomUUID().replaceAll("-", "")}`, + kind: "prompt", status: "submitting", submittedAtMs: now(), }; diff --git a/apps/mobile/src/screens/session-composer-model.test.ts b/apps/mobile/src/screens/session-composer-model.test.ts new file mode 100644 index 0000000..81c7b78 --- /dev/null +++ b/apps/mobile/src/screens/session-composer-model.test.ts @@ -0,0 +1,277 @@ +import { expect, test } from "@jest/globals"; +import type { + AgentInfo, + CommandInfo, + FileSystemEntry, + SkillInfo, +} from "@opencode2-mobile/opencode-adapter"; + +import { + applyMentionCompletion, + applySlashCompletion, + findMentionTrigger, + listMentionCompletions, + listSlashCompletions, + rebaseComposerMentions, + resolveComposerSubmitIntent, + serverFileUri, +} from "./session-composer-model"; + +const commands = [{ name: "review" }, { name: "release-notes" }] as CommandInfo[]; +const agents = [ + { hidden: false, id: "build", mode: "primary", name: "Build" }, + { hidden: false, id: "explore", mode: "subagent", name: "Explore" }, +] as AgentInfo[]; +const skills = [ + { + content: "Content", + id: "release", + location: "/skills/release.md", + name: "Release workflow", + slash: false, + }, +] as SkillInfo[]; +const files = [ + { path: "src/index.ts", type: "file" }, + { path: "src", type: "directory" }, +] as FileSystemEntry[]; + +test("keeps slash completion command-only", () => { + expect(listSlashCompletions("/re", commands).map((item) => item.name)).toEqual([ + "release-notes", + "review", + ]); + expect(listSlashCompletions("Explain /review", commands)).toEqual([]); + expect(listSlashCompletions("/review arguments", commands)).toEqual([]); + expect(applySlashCompletion("/rev", { label: "review", name: "review" })).toBe("/review "); +}); + +test("finds an at trigger at the caret only after a text boundary", () => { + expect(findMentionTrigger("Ask @src/in", { end: 11, start: 11 })).toEqual({ + end: 11, + query: "src/in", + start: 4, + }); + expect(findMentionTrigger("mail@example", { end: 12, start: 12 })).toBeUndefined(); + expect(findMentionTrigger("@one two", { end: 8, start: 8 })).toBeUndefined(); + expect(findMentionTrigger("@file", { end: 3, start: 1 })).toBeUndefined(); + expect(findMentionTrigger("Ask @src/index.ts now", { end: 8, start: 8 })).toEqual({ + end: 17, + query: "src", + start: 4, + }); +}); + +test("combines files, skills, and non-primary agents for at completion", () => { + expect(listMentionCompletions("", agents, skills, files).map((item) => item.type)).toEqual([ + "agent", + "skill", + "file", + ]); + expect(listMentionCompletions("rel", agents, skills, [])).toMatchObject([ + { id: "release", type: "skill" }, + ]); + expect(listMentionCompletions("index", agents, skills, files)).toMatchObject([ + { path: "src/index.ts", type: "file" }, + ]); +}); + +test("preserves fuzzy-ranked file results from the server", () => { + const rankedFiles = [ + { path: "src/session-input.ts", type: "file" }, + { path: "src/index.ts", type: "file" }, + ] as FileSystemEntry[]; + + expect(listMentionCompletions("sit", [], [], rankedFiles).map((item) => item.label)).toEqual([ + "src/session-input.ts", + "src/index.ts", + ]); +}); + +test("inserts a structured mention and rebases it around later edits", () => { + const trigger = findMentionTrigger("Ask @ind now", { end: 8, start: 8 }); + if (!trigger) throw new Error("Expected mention trigger"); + const inserted = applyMentionCompletion( + "Ask @ind now", + trigger, + { label: "src/index.ts", path: "src/index.ts", type: "file" }, + [], + ); + + expect(inserted.draft).toBe("Ask @src/index.ts now"); + expect(inserted.mentions).toEqual([ + { + mention: { end: 17, start: 4, text: "@src/index.ts" }, + path: "src/index.ts", + type: "file", + }, + ]); + expect( + rebaseComposerMentions(inserted.draft, `Please ${inserted.draft}`, inserted.mentions), + ).toEqual([ + { + mention: { end: 24, start: 11, text: "@src/index.ts" }, + path: "src/index.ts", + type: "file", + }, + ]); + expect(rebaseComposerMentions(inserted.draft, "Ask @broken now", inserted.mentions)).toEqual([]); +}); + +test("keeps separate ranges when the same item is mentioned twice", () => { + const firstTrigger = findMentionTrigger("@src", { end: 4, start: 4 }); + if (!firstTrigger) throw new Error("Expected first mention trigger"); + const first = applyMentionCompletion( + "@src", + firstTrigger, + { label: "src/index.ts", path: "src/index.ts", type: "file" }, + [], + ); + const secondDraft = `${first.draft}@src`; + const secondTrigger = findMentionTrigger(secondDraft, { + end: secondDraft.length, + start: secondDraft.length, + }); + if (!secondTrigger) throw new Error("Expected second mention trigger"); + const second = applyMentionCompletion( + secondDraft, + secondTrigger, + { label: "src/index.ts", path: "src/index.ts", type: "file" }, + first.mentions, + ); + + expect(second.mentions).toHaveLength(2); + expect(second.mentions.map((item) => item.mention.start)).toEqual([0, 14]); +}); + +test("keeps the surviving attachment identity when equal mention text repeats", () => { + const mentions = [ + { + mention: { end: 4, start: 0, text: "@foo" }, + name: "foo", + type: "agent" as const, + }, + { + id: "foo", + mention: { end: 9, start: 5, text: "@foo" }, + type: "skill" as const, + }, + ]; + + expect(rebaseComposerMentions("@foo @foo", "@foo", mentions, { end: 5, start: 0 })).toEqual([ + { + id: "foo", + mention: { end: 4, start: 0, text: "@foo" }, + type: "skill", + }, + ]); + expect(rebaseComposerMentions("@foo @foo", "@foo", mentions, { end: 9, start: 4 })).toEqual([ + { + mention: { end: 4, start: 0, text: "@foo" }, + name: "foo", + type: "agent", + }, + ]); +}); + +test("builds generated prompt attachments with UTF-16 mention ranges", () => { + const draft = "Check æ @src/a#b.ts with @Explore and @release"; + const fileText = "@src/a#b.ts"; + const agentText = "@Explore"; + const skillText = "@release"; + const fileStart = draft.indexOf(fileText); + const agentStart = draft.indexOf(agentText); + const skillStart = draft.indexOf(skillText); + + expect( + resolveComposerSubmitIntent( + draft, + commands, + [ + { + mention: { end: fileStart + fileText.length, start: fileStart, text: fileText }, + path: "src/a#b.ts", + type: "file", + }, + { + mention: { end: agentStart + agentText.length, start: agentStart, text: agentText }, + name: "Explore", + type: "agent", + }, + { + id: "release", + mention: { end: skillStart + skillText.length, start: skillStart, text: skillText }, + type: "skill", + }, + ], + { directory: "/workspace" }, + ), + ).toEqual({ + agents: [{ mention: { end: 33, start: 25, text: "@Explore" }, name: "Explore" }], + files: [ + { + mention: { end: 19, start: 8, text: "@src/a#b.ts" }, + name: "src/a#b.ts", + uri: "file:///workspace/src/a%23b.ts", + }, + ], + skills: [{ id: "release", mention: { end: 46, start: 38, text: "@release" } }], + type: "prompt", + }); +}); + +test("preserves command arguments and structured mentions", () => { + const draft = "/review @src/index.ts\nthen tests"; + expect( + resolveComposerSubmitIntent( + draft, + commands, + [ + { + mention: { end: 21, start: 8, text: "@src/index.ts" }, + path: "src/index.ts", + type: "file", + }, + ], + { directory: "/workspace" }, + ), + ).toMatchObject({ + arguments: "@src/index.ts\nthen tests", + command: "review", + files: [ + { + mention: { end: 13, start: 0, text: "@src/index.ts" }, + uri: "file:///workspace/src/index.ts", + }, + ], + type: "command", + }); + expect( + resolveComposerSubmitIntent("/unknown value", commands, [], { directory: "/workspace" }), + ).toEqual({ + type: "prompt", + }); +}); + +test("preserves multiline command arguments after removing one separator space", () => { + expect( + resolveComposerSubmitIntent("/review indented", commands, [], { directory: "/workspace" }), + ).toMatchObject({ arguments: " indented", type: "command" }); + expect( + resolveComposerSubmitIntent("/review\nnext line", commands, [], { directory: "/workspace" }), + ).toMatchObject({ arguments: "\nnext line", type: "command" }); +}); + +test("creates server-local file URLs without treating hostile path characters as URL syntax", () => { + expect(serverFileUri("/workspace", "odd name?#.ts")).toBe( + "file:///workspace/odd%20name%3F%23.ts", + ); + expect(serverFileUri("C:\\workspace", "src\\index.ts")).toBe("file:///C:/workspace/src/index.ts"); + expect(() => serverFileUri("/workspace", "../secret.txt")).toThrow( + "FILE_MENTION_OUTSIDE_LOCATION", + ); + expect(() => serverFileUri("/workspace", "/etc/passwd")).toThrow("FILE_MENTION_OUTSIDE_LOCATION"); + expect(() => serverFileUri("C:\\workspace", "C:\\outside.txt")).toThrow( + "FILE_MENTION_OUTSIDE_LOCATION", + ); +}); diff --git a/apps/mobile/src/screens/session-composer-model.ts b/apps/mobile/src/screens/session-composer-model.ts new file mode 100644 index 0000000..276646f --- /dev/null +++ b/apps/mobile/src/screens/session-composer-model.ts @@ -0,0 +1,314 @@ +import type { + AgentInfo, + CommandInfo, + FileSystemEntry, + LocationRef, + SkillInfo, +} from "@opencode2-mobile/opencode-adapter"; +import type { SessionDraftMention } from "../storage/draft-repository"; + +type MentionRange = { end: number; start: number; text: string }; + +export type ComposerMention = SessionDraftMention; + +export type MentionCompletion = + | { description?: string; label: string; path: string; type: "file" } + | { description?: string; label: string; name: string; type: "agent" } + | { description?: string; id: string; label: string; type: "skill" }; + +export type SlashCompletion = { + description?: string; + label: string; + name: string; +}; + +type ComposerAttachments = { + agents?: Array<{ mention: MentionRange; name: string }>; + files?: Array<{ mention: MentionRange; name: string; uri: string }>; + skills?: Array<{ id: string; mention: MentionRange }>; +}; + +export type ComposerSubmitIntent = + | ({ type: "prompt" } & ComposerAttachments) + | ({ arguments?: string; command: string; type: "command" } & ComposerAttachments); + +export type MentionTrigger = { end: number; query: string; start: number }; + +const maximumSlashCompletions = 4; +const maximumMentionCompletions = 10; + +export function listSlashCompletions(draft: string, commands: readonly CommandInfo[]) { + const match = draft.match(/^\/([^\s/]*)$/); + if (!match) return []; + const query = (match[1] ?? "").toLocaleLowerCase(); + + return commands + .filter( + (command) => + command.name.toLocaleLowerCase().includes(query) || + command.description?.toLocaleLowerCase().includes(query), + ) + .sort((first, second) => { + const firstStarts = first.name.toLocaleLowerCase().startsWith(query); + const secondStarts = second.name.toLocaleLowerCase().startsWith(query); + if (firstStarts !== secondStarts) return firstStarts ? -1 : 1; + return first.name.localeCompare(second.name); + }) + .slice(0, maximumSlashCompletions) + .map( + (command): SlashCompletion => ({ + ...(command.description ? { description: command.description } : {}), + label: command.name, + name: command.name, + }), + ); +} + +export function findMentionTrigger(draft: string, selection: { end: number; start: number }) { + if (selection.start !== selection.end) return undefined; + const beforeCursor = draft.slice(0, selection.start); + const start = beforeCursor.lastIndexOf("@"); + if (start < 0) return undefined; + const beforeTrigger = start === 0 ? undefined : beforeCursor[start - 1]; + const query = beforeCursor.slice(start + 1); + if ((beforeTrigger !== undefined && !/\s/.test(beforeTrigger)) || /\s/.test(query)) { + return undefined; + } + const suffix = draft.slice(selection.end); + const nextWhitespace = suffix.search(/\s/); + const end = nextWhitespace < 0 ? draft.length : selection.end + nextWhitespace; + return { end, query, start } satisfies MentionTrigger; +} + +export function listMentionCompletions( + query: string, + agents: readonly AgentInfo[], + skills: readonly SkillInfo[], + files: readonly FileSystemEntry[], +) { + const normalizedQuery = query.toLocaleLowerCase(); + const nonFileOptions: MentionCompletion[] = [ + ...agents + .filter((agent) => !agent.hidden && agent.mode !== "primary") + .map((agent) => ({ + ...(agent.description ? { description: agent.description } : {}), + label: agent.name, + name: agent.name, + type: "agent" as const, + })), + ...skills.map((skill) => ({ + ...(skill.description ? { description: skill.description } : {}), + id: skill.id, + label: skill.name, + type: "skill" as const, + })), + ]; + const fileOptions: MentionCompletion[] = files + .filter((file) => file.type === "file") + .map((file) => ({ label: file.path, path: file.path, type: "file" as const })); + + const matchingNonFiles = nonFileOptions + .filter((option) => mentionSearchText(option).includes(normalizedQuery)) + .sort((first, second) => { + const firstStarts = mentionValue(first).toLocaleLowerCase().startsWith(normalizedQuery); + const secondStarts = mentionValue(second).toLocaleLowerCase().startsWith(normalizedQuery); + if (firstStarts !== secondStarts) return firstStarts ? -1 : 1; + const kindOrder = mentionKindOrder(first.type) - mentionKindOrder(second.type); + return kindOrder || mentionValue(first).localeCompare(mentionValue(second)); + }); + return [...matchingNonFiles, ...fileOptions].slice(0, maximumMentionCompletions); +} + +export function applySlashCompletion(draft: string, completion: SlashCompletion) { + if (!/^\/[^\s/]*$/.test(draft)) return draft; + return `/${completion.name} `; +} + +export function applyMentionCompletion( + draft: string, + trigger: MentionTrigger, + completion: MentionCompletion, + mentions: readonly ComposerMention[], +) { + const value = mentionValue(completion); + const text = `@${value}`; + const suffix = draft.slice(trigger.end); + const trailing = suffix.startsWith(" ") ? "" : " "; + const replacement = `${text}${trailing}`; + const nextDraft = `${draft.slice(0, trigger.start)}${replacement}${suffix}`; + const delta = replacement.length - (trigger.end - trigger.start); + const shifted = mentions + .filter( + (mention) => mention.mention.end <= trigger.start || mention.mention.start >= trigger.end, + ) + .map((mention) => + mention.mention.start >= trigger.end ? shiftMention(mention, delta) : mention, + ); + const range = { end: trigger.start + text.length, start: trigger.start, text }; + const nextMention: ComposerMention = + completion.type === "file" + ? { mention: range, path: completion.path, type: "file" } + : completion.type === "agent" + ? { mention: range, name: completion.name, type: "agent" } + : { id: completion.id, mention: range, type: "skill" }; + + return { + draft: nextDraft, + mentions: [...shifted, nextMention].sort( + (first, second) => first.mention.start - second.mention.start, + ), + selection: { end: range.end + trailing.length, start: range.end + trailing.length }, + }; +} + +export function rebaseComposerMentions( + previousDraft: string, + nextDraft: string, + mentions: readonly ComposerMention[], + selection?: { end: number; start: number }, +) { + if (previousDraft === nextDraft || mentions.length === 0) return [...mentions]; + let start = 0; + while (start < previousDraft.length && previousDraft[start] === nextDraft[start]) start += 1; + let previousEnd = previousDraft.length; + let nextEnd = nextDraft.length; + while ( + previousEnd > start && + nextEnd > start && + previousDraft[previousEnd - 1] === nextDraft[nextEnd - 1] + ) { + previousEnd -= 1; + nextEnd -= 1; + } + if (selection && selection.start < selection.end) { + const insertedLength = + nextDraft.length - (previousDraft.length - selection.end + selection.start); + if ( + insertedLength >= 0 && + `${previousDraft.slice(0, selection.start)}${nextDraft.slice( + selection.start, + selection.start + insertedLength, + )}${previousDraft.slice(selection.end)}` === nextDraft + ) { + start = selection.start; + previousEnd = selection.end; + nextEnd = selection.start + insertedLength; + } + } + const delta = nextEnd - previousEnd; + return mentions.flatMap((mention) => { + if (previousEnd <= mention.mention.start) return [shiftMention(mention, delta)]; + if (start >= mention.mention.end) return [mention]; + return []; + }); +} + +export function resolveComposerSubmitIntent( + draft: string, + commands: readonly CommandInfo[], + mentions: readonly ComposerMention[], + location: LocationRef, +): ComposerSubmitIntent { + const match = draft.match(/^\/([^\s/]+)([\s\S]*)$/); + if (!match) return { ...composerAttachments(draft, mentions, location), type: "prompt" }; + const name = match[1] ?? ""; + const remainder = match[2] ?? ""; + const command = commands.find((candidate) => candidate.name === name); + if (!command) return { ...composerAttachments(draft, mentions, location), type: "prompt" }; + const separatorLength = remainder.startsWith(" ") ? 1 : 0; + const argumentsText = remainder.slice(separatorLength); + const argumentsStart = 1 + name.length + separatorLength; + const attachments = composerAttachments(draft, mentions, location, argumentsStart); + return { + ...attachments, + ...(argumentsText ? { arguments: argumentsText } : {}), + command: command.name, + type: "command", + }; +} + +export function serverFileUri(directory: string, path: string) { + const normalizedDirectory = directory.replaceAll("\\", "/").replace(/\/+$/, ""); + const normalizedPath = path.replaceAll("\\", "/"); + if (!isSafeRelativeFilePath(normalizedPath)) throw new Error("FILE_MENTION_OUTSIDE_LOCATION"); + const absolute = `${normalizedDirectory}/${normalizedPath}`; + const url = new URL("file:///"); + url.pathname = absolute.startsWith("/") ? absolute : `/${absolute}`; + return url.href; +} + +function isSafeRelativeFilePath(path: string) { + return ( + Boolean(path) && + !path.startsWith("/") && + !/^[A-Za-z]:\//.test(path) && + path.split("/").every((segment) => Boolean(segment) && segment !== "." && segment !== "..") + ); +} + +function composerAttachments( + draft: string, + mentions: readonly ComposerMention[], + location: LocationRef, + offset = 0, +) { + const valid = mentions.filter( + (mention) => + mention.mention.start >= offset && + mention.mention.end <= draft.length && + draft.slice(mention.mention.start, mention.mention.end) === mention.mention.text, + ); + const files = valid.flatMap((mention) => + mention.type === "file" + ? [ + { + mention: offsetMention(mention.mention, offset), + name: mention.path, + uri: serverFileUri(location.directory, mention.path), + }, + ] + : [], + ); + const agents = valid.flatMap((mention) => + mention.type === "agent" + ? [{ mention: offsetMention(mention.mention, offset), name: mention.name }] + : [], + ); + const skills = valid.flatMap((mention) => + mention.type === "skill" + ? [{ id: mention.id, mention: offsetMention(mention.mention, offset) }] + : [], + ); + return { + ...(agents.length ? { agents } : {}), + ...(files.length ? { files } : {}), + ...(skills.length ? { skills } : {}), + }; +} + +function offsetMention(mention: MentionRange, offset: number) { + return { ...mention, end: mention.end - offset, start: mention.start - offset }; +} + +function mentionSearchText(option: MentionCompletion) { + return `${mentionValue(option)}\n${option.label}\n${option.description ?? ""}`.toLocaleLowerCase(); +} + +function mentionValue(option: MentionCompletion) { + return option.type === "file" ? option.path : option.type === "agent" ? option.name : option.id; +} + +function mentionKindOrder(type: MentionCompletion["type"]) { + return type === "agent" ? 0 : type === "skill" ? 1 : 2; +} + +function shiftMention(mention: ComposerMention, delta: number): ComposerMention { + return { + ...mention, + mention: { + ...mention.mention, + end: mention.mention.end + delta, + start: mention.mention.start + delta, + }, + }; +} diff --git a/apps/mobile/src/screens/session-composer.test.tsx b/apps/mobile/src/screens/session-composer.test.tsx index 5e6d351..934d6d3 100644 --- a/apps/mobile/src/screens/session-composer.test.tsx +++ b/apps/mobile/src/screens/session-composer.test.tsx @@ -1,13 +1,24 @@ import { expect, jest, test } from "@jest/globals"; -import type { AgentInfo, ModelInfo, ModelRef } from "@opencode2-mobile/opencode-adapter"; +import type { + AgentInfo, + CommandInfo, + FileSystemEntry, + ModelInfo, + ModelRef, + SkillInfo, +} from "@opencode2-mobile/opencode-adapter"; import { fireEvent, render, screen, within } from "@testing-library/react-native"; import { useState } from "react"; import { Keyboard } from "react-native"; import type { PromptDelivery } from "./prompt-admission-model"; import { SessionComposer } from "./session-composer"; +import type { ComposerMention, ComposerSubmitIntent } from "./session-composer-model"; -const agents = [{ hidden: false, id: "build", mode: "primary", name: "Build" }] as AgentInfo[]; +const agents = [ + { hidden: false, id: "build", mode: "primary", name: "Build" }, + { hidden: false, id: "explore", mode: "subagent", name: "Explore" }, +] as AgentInfo[]; const models = [ { enabled: true, @@ -18,6 +29,24 @@ const models = [ variants: [{ id: "deep" }], }, ] as ModelInfo[]; +const commands = [{ description: "Review changes", name: "review" }] as CommandInfo[]; +const skills = [ + { + content: "Release instructions", + id: "release", + location: "/workspace/.opencode/skills/release.md", + name: "Release workflow", + slash: true, + }, + { + content: "Automatic context", + id: "automatic", + location: "/workspace/.opencode/skills/automatic.md", + name: "Automatic", + slash: false, + }, +] as SkillInfo[]; +const files = [{ path: "src/index.ts", type: "file" }] as FileSystemEntry[]; test("keeps the native prompt multiline and submits through an explicit control", () => { const onSubmit = jest.fn(); @@ -113,15 +142,22 @@ test("keeps the editor read-only until its encrypted draft has loaded", () => { , ); @@ -135,14 +171,21 @@ test("selects a server agent and model variant", () => { , ); @@ -163,26 +206,128 @@ test("selects a server agent and model variant", () => { }); }); -function ComposerHarness({ active = false, onSubmit }: { active?: boolean; onSubmit: () => void }) { +test("completes and submits a command with multiline Unicode arguments", () => { + const onSubmit = jest.fn<(intent: ComposerSubmitIntent) => void>(); + render(); + + const input = screen.getByLabelText("Prompt"); + fireEvent(input, "focus"); + fireEvent.changeText(input, "/rev"); + expect(screen.getByLabelText("Command suggestions")).toBeOnTheScreen(); + fireEvent.press(screen.getByRole("button", { name: "/review, command" })); + expect(screen.getByLabelText("Prompt").props.value).toBe("/review "); + fireEvent.changeText(screen.getByLabelText("Prompt"), "/review src/æ.ts\nfocus errors"); + fireEvent.press(screen.getByRole("button", { name: "Send" })); + + expect(onSubmit).toHaveBeenCalledWith({ + arguments: "src/æ.ts\nfocus errors", + command: "review", + type: "command", + }); +}); + +test("keeps slash search command-only", () => { + render(); + + const input = screen.getByLabelText("Prompt"); + fireEvent(input, "focus"); + fireEvent.changeText(input, "/"); + + expect(screen.getByRole("button", { name: "/review, command" })).toBeOnTheScreen(); + expect(screen.queryByRole("button", { name: /release/ })).not.toBeOnTheScreen(); +}); + +test("does not submit a slash command with arguments before the catalog loads", () => { + const onSubmit = jest.fn(); + render(); + + const input = screen.getByLabelText("Prompt"); + fireEvent.changeText(input, "/review src/index.ts"); + fireEvent.press(screen.getByRole("button", { name: "Send" })); + + expect(screen.getByRole("button", { name: "Send" })).toBeDisabled(); + expect(onSubmit).not.toHaveBeenCalled(); +}); + +test("does not submit slash input when the command catalog is unavailable", () => { + const onSubmit = jest.fn(); + render(); + + const input = screen.getByLabelText("Prompt"); + fireEvent.changeText(input, "/review src/index.ts"); + fireEvent.press(screen.getByRole("button", { name: "Send" })); + + expect(screen.getByRole("button", { name: "Send" })).toBeDisabled(); + expect(onSubmit).not.toHaveBeenCalled(); +}); + +test("searches files, skills, and agents with at and submits a structured skill mention", () => { + const onSubmit = jest.fn<(intent: ComposerSubmitIntent) => void>(); + render(); + + const input = screen.getByLabelText("Prompt"); + fireEvent(input, "focus"); + fireEvent.changeText(input, "Ask @"); + fireEvent(input, "selectionChange", { + nativeEvent: { selection: { end: 5, start: 5 } }, + }); + + expect(screen.getByLabelText("File, skill, and agent suggestions")).toBeOnTheScreen(); + expect(screen.getByRole("button", { name: "@src/index.ts, file" })).toBeOnTheScreen(); + expect(screen.getByRole("button", { name: "@Explore, agent" })).toBeOnTheScreen(); + expect(screen.getByRole("button", { name: "@release, skill" })).toBeOnTheScreen(); + fireEvent.press(screen.getByRole("button", { name: "@release, skill" })); + fireEvent.press(screen.getByRole("button", { name: "Send" })); + + expect(onSubmit).toHaveBeenCalledWith({ + skills: [{ id: "release", mention: { end: 12, start: 4, text: "@release" } }], + type: "prompt", + }); +}); + +function ComposerHarness({ + active = false, + completionLoading = false, + completionUnavailable = false, + onSubmit, +}: { + active?: boolean; + completionLoading?: boolean; + completionUnavailable?: boolean; + onSubmit: (intent: ComposerSubmitIntent) => void; +}) { const [draft, setDraft] = useState(""); const [delivery, setDelivery] = useState(); const [agent, setAgent] = useState(); const [model, setModel] = useState(); + const [mentions, setMentions] = useState([]); return ( { + setDraft(content); + setMentions(nextMentions); + }} onModelChange={setModel} + onMentionSearchChange={jest.fn()} onSubmit={onSubmit} + skills={skills} /> ); } diff --git a/apps/mobile/src/screens/session-composer.tsx b/apps/mobile/src/screens/session-composer.tsx index 04cfeab..357ddb4 100644 --- a/apps/mobile/src/screens/session-composer.tsx +++ b/apps/mobile/src/screens/session-composer.tsx @@ -1,4 +1,12 @@ -import type { AgentInfo, ModelInfo, ModelRef } from "@opencode2-mobile/opencode-adapter"; +import type { + AgentInfo, + CommandInfo, + FileSystemEntry, + LocationRef, + ModelInfo, + ModelRef, + SkillInfo, +} from "@opencode2-mobile/opencode-adapter"; import { useDeferredValue, useEffect, useRef, useState } from "react"; import { FlatList, @@ -15,6 +23,19 @@ import { import { ModalSheet } from "../components/modal-sheet"; import { palette, radius, space, typeRamp } from "../theme"; import type { PromptDelivery } from "./prompt-admission-model"; +import { + applyMentionCompletion, + applySlashCompletion, + type ComposerMention, + type ComposerSubmitIntent, + findMentionTrigger, + listMentionCompletions, + listSlashCompletions, + type MentionCompletion, + rebaseComposerMentions, + resolveComposerSubmitIntent, + type SlashCompletion, +} from "./session-composer-model"; const maximumDraftLength = 32_000; @@ -22,6 +43,9 @@ export function SessionComposer({ active, agent, agents, + commands, + completionLoading, + completionUnavailable, delivery, disabled, draft, @@ -29,17 +53,28 @@ export function SessionComposer({ error, focusOnMount, largeText, + location, + mentionAgents, + mentionFiles, + mentionLoading, + mentions, + mentionUnavailable, model, models, onAgentChange, onDeliveryChange, onDraftChange, onModelChange, + onMentionSearchChange, onSubmit, + skills, }: { active: boolean; agent?: string | undefined; agents: AgentInfo[]; + commands: CommandInfo[]; + completionLoading?: boolean | undefined; + completionUnavailable?: boolean | undefined; delivery?: PromptDelivery | undefined; disabled?: boolean | undefined; draft: string; @@ -47,13 +82,21 @@ export function SessionComposer({ error?: string | undefined; focusOnMount?: boolean | undefined; largeText: boolean; + location: LocationRef; + mentionAgents: AgentInfo[]; + mentionFiles: FileSystemEntry[]; + mentionLoading?: boolean | undefined; + mentions: ComposerMention[]; + mentionUnavailable?: boolean | undefined; model?: ModelRef | undefined; models: ModelInfo[]; onAgentChange: (agent: string) => void; onDeliveryChange: (delivery: PromptDelivery) => void; - onDraftChange: (draft: string) => void; + onDraftChange: (draft: string, mentions: ComposerMention[]) => void; onModelChange: (model: ModelRef) => void; - onSubmit: () => void; + onMentionSearchChange: (query: string | undefined) => void; + onSubmit: (intent: ComposerSubmitIntent) => void; + skills: SkillInfo[]; }) { const inputRef = useRef(null); const [agentPickerOpen, setAgentPickerOpen] = useState(false); @@ -61,6 +104,7 @@ export function SessionComposer({ const [focused, setFocused] = useState(false); const [modelPickerOpen, setModelPickerOpen] = useState(false); const [modelSearch, setModelSearch] = useState(""); + const [selection, setSelection] = useState({ end: draft.length, start: draft.length }); const deferredModelSearch = useDeferredValue(modelSearch.trim().toLocaleLowerCase()); const deferredAgentSearch = useDeferredValue(agentSearch.trim().toLocaleLowerCase()); const selectedAgent = agents.find((candidate) => candidate.id === agent); @@ -82,8 +126,23 @@ export function SessionComposer({ ) : agents; const expanded = largeText || focused || agentPickerOpen || modelPickerOpen; + const completions = listSlashCompletions(draft, commands); + const mentionTrigger = findMentionTrigger(draft, selection); + const mentionCompletions = mentionTrigger + ? listMentionCompletions(mentionTrigger.query, mentionAgents, skills, mentionFiles) + : []; + const submitIntent = resolveComposerSubmitIntent(draft, commands, mentions, location); + const slashCatalogPending = completionLoading && draft.startsWith("/"); + const slashCatalogUnavailable = completionUnavailable && draft.startsWith("/"); + const submitHint = slashCatalogPending + ? "Wait for commands to load." + : slashCatalogUnavailable + ? "Commands are unavailable." + : undefined; const canSubmit = !disabled && + !slashCatalogPending && + !slashCatalogUnavailable && draft.trim().length > 0 && (!active || delivery === "queue" || delivery === "steer"); @@ -93,12 +152,52 @@ export function SessionComposer({ return () => cancelAnimationFrame(frame); }, [editable, focusOnMount]); + useEffect(() => { + if (!focused) { + setSelection({ end: draft.length, start: draft.length }); + return; + } + setSelection((current) => + current.end <= draft.length ? current : { end: draft.length, start: draft.length }, + ); + }, [draft, focused]); + + useEffect(() => { + onMentionSearchChange(mentionTrigger?.query); + }, [mentionTrigger?.query, onMentionSearchChange]); + + useEffect( + () => () => { + onMentionSearchChange(undefined); + }, + [onMentionSearchChange], + ); + function submit() { if (!canSubmit) return; inputRef.current?.blur(); setFocused(false); Keyboard.dismiss(); - onSubmit(); + onSubmit(submitIntent); + } + + function changeDraft(nextDraft: string) { + onDraftChange(nextDraft, rebaseComposerMentions(draft, nextDraft, mentions, selection)); + } + + function selectCompletion(completion: (typeof completions)[number]) { + const nextDraft = applySlashCompletion(draft, completion); + onDraftChange(nextDraft, mentions); + setSelection({ end: nextDraft.length, start: nextDraft.length }); + inputRef.current?.focus(); + } + + function selectMention(completion: MentionCompletion) { + if (!mentionTrigger) return; + const next = applyMentionCompletion(draft, mentionTrigger, completion, mentions); + onDraftChange(next.draft, next.mentions); + setSelection(next.selection); + inputRef.current?.focus(); } return ( @@ -117,14 +216,16 @@ export function SessionComposer({ multiline numberOfLines={expanded ? 4 : 1} onBlur={() => setFocused(false)} - onChangeText={onDraftChange} + onChangeText={changeDraft} onFocus={() => setFocused(true)} + onSelectionChange={(event) => setSelection(event.nativeEvent.selection)} placeholder={active ? "Add a follow-up" : "Ask OpenCode"} placeholderTextColor={palette.dim} ref={inputRef} returnKeyType="default" scrollEnabled={expanded} selectionColor={palette.signal} + selection={selection} style={[styles.input, expanded ? styles.inputExpanded : styles.inputCollapsed]} submitBehavior="newline" textAlignVertical={expanded ? "top" : "center"} @@ -135,11 +236,69 @@ export function SessionComposer({ active={active} canSubmit={canSubmit} delivery={delivery} + disabledHint={submitHint} onPress={submit} /> ) : null} + {expanded && /^\/[^\s/]*$/.test(draft) ? ( + + {completions.length > 0 ? ( + completions.map((completion) => ( + selectCompletion(completion)} + /> + )) + ) : ( + + {completionLoading + ? "Loading commands" + : completionUnavailable + ? "Commands are unavailable" + : "No matching commands"} + + )} + + ) : null} + + {expanded && mentionTrigger ? ( + + {mentionCompletions.map((completion) => ( + selectMention(completion)} + /> + ))} + {mentionCompletions.length === 0 || mentionLoading || mentionUnavailable ? ( + + {mentionLoading + ? "Searching files, skills, and agents" + : mentionUnavailable + ? "Some mention results are unavailable" + : "No matching files, skills, or agents"} + + ) : null} + + ) : null} + {expanded && active ? ( @@ -313,6 +473,99 @@ function OptionSeparator() { return ; } +function CompletionButton({ + completion, + onPress, +}: { + completion: SlashCompletion; + onPress: () => void; +}) { + return ( + [styles.completion, pressed && styles.pressed]} + > + + + /{completion.name} + + + COMMAND + + + {completion.label !== completion.name || completion.description ? ( + + {[ + completion.label !== completion.name ? completion.label : undefined, + completion.description, + ] + .filter(Boolean) + .join(" / ")} + + ) : null} + + ); +} + +function MentionButton({ + completion, + onPress, +}: { + completion: MentionCompletion; + onPress: () => void; +}) { + const value = + completion.type === "file" + ? completion.path + : completion.type === "agent" + ? completion.name + : completion.id; + return ( + [styles.completion, pressed && styles.pressed]} + > + + + @{value} + + + {completion.type.toUpperCase()} + + + {completion.label !== value || completion.description ? ( + + {[completion.label !== value ? completion.label : undefined, completion.description] + .filter(Boolean) + .join(" / ")} + + ) : null} + + ); +} + +function mentionCompletionKey(completion: MentionCompletion) { + return completion.type === "file" + ? `file:${completion.path}` + : completion.type === "agent" + ? `agent:${completion.name}` + : `skill:${completion.id}`; +} + function EmptyResults({ label }: { label: string }) { return ( @@ -325,16 +578,20 @@ function SendButton({ active, canSubmit, delivery, + disabledHint, onPress, }: { active: boolean; canSubmit: boolean; delivery?: PromptDelivery | undefined; + disabledHint?: string | undefined; onPress: () => void; }) { return ( { + render( + , + ); + + expect(screen.getByText(/server may have run this command/i)).toBeOnTheScreen(); +}); + test("shows and replies to a permission blocking the current session", () => { render( {admission.status === "unknown-delivery" - ? "The server may have admitted this prompt. Check inbox and transcript state before sending it again." + ? admission.kind === "command" + ? "The server may have run this command. Check the transcript before sending it again." + : "The server may have admitted this prompt. Check inbox and transcript state before sending it again." : "Waiting for the durable inbox item or projected message."} {admission.status === "unknown-delivery" ? ( diff --git a/apps/mobile/src/screens/use-session-draft.test.tsx b/apps/mobile/src/screens/use-session-draft.test.tsx index 56a6f6a..24e9da0 100644 --- a/apps/mobile/src/screens/use-session-draft.test.tsx +++ b/apps/mobile/src/screens/use-session-draft.test.tsx @@ -5,9 +5,19 @@ import { useSessionDraft } from "./use-session-draft"; const mockDeleteDraft = jest.fn<(...args: unknown[]) => Promise>(async () => undefined); const mockReadDraft = jest.fn< - ( - ...args: unknown[] - ) => Promise<{ content: string; revision: number; updatedAtMs: number } | undefined> + (...args: unknown[]) => Promise< + | { + content: string; + mentions: Array<{ + id: string; + mention: { end: number; start: number; text: string }; + type: "skill"; + }>; + revision: number; + updatedAtMs: number; + } + | undefined + > >(async () => undefined); const mockWriteDraft = jest.fn<(...args: unknown[]) => Promise>(async () => undefined); const mockDatabase = {}; @@ -41,18 +51,54 @@ test("does not clear text edited after the submitted draft revision", async () = expect(hook.result.current.draft).toBe(""); }); -test("restores a durable revision and clears that exact submitted draft", async () => { - mockReadDraft.mockResolvedValueOnce({ content: "Submitted", revision: 7, updatedAtMs: 10 }); +test("restores a durable revision and structured mentions", async () => { + const mentions = [ + { + id: "release", + mention: { end: 8, start: 0, text: "@release" }, + type: "skill" as const, + }, + ]; + mockReadDraft.mockResolvedValueOnce({ + content: "@release Submitted", + mentions, + revision: 7, + updatedAtMs: 10, + }); const hook = renderHook(() => useSessionDraft("connection-1", "ses_a")); await waitFor(() => expect(hook.result.current.loaded).toBe(true)); expect(hook.result.current.revision).toBe(7); + expect(hook.result.current.mentions).toEqual(mentions); act(() => hook.result.current.clearDraft(7)); await waitFor(() => expect(mockDeleteDraft).toHaveBeenCalledWith(mockDatabase, "connection-1", "ses_a"), ); expect(hook.result.current.draft).toBe(""); + expect(hook.result.current.mentions).toEqual([]); +}); + +test("writes mention metadata with the encrypted draft", async () => { + const hook = renderHook(() => useSessionDraft("connection-1", "ses_a")); + await waitFor(() => expect(hook.result.current.loaded).toBe(true)); + const mentions = [ + { + id: "release", + mention: { end: 8, start: 0, text: "@release" }, + type: "skill" as const, + }, + ]; + + act(() => hook.result.current.setDraft("@release ", mentions)); + await act(async () => { + await hook.result.current.persistDraft("@release ", hook.result.current.revision); + }); + + expect(mockWriteDraft).toHaveBeenCalledWith( + mockDatabase, + expect.objectContaining({ content: "@release ", mentions }), + ); }); test("a late confirmation only clears the draft scope that submitted it", async () => { diff --git a/apps/mobile/src/screens/use-session-draft.ts b/apps/mobile/src/screens/use-session-draft.ts index 42fc5af..2164cab 100644 --- a/apps/mobile/src/screens/use-session-draft.ts +++ b/apps/mobile/src/screens/use-session-draft.ts @@ -5,6 +5,7 @@ import { AppState } from "react-native"; import { deleteSessionDraft, readSessionDraft, + type SessionDraftMention, writeSessionDraft, } from "../storage/draft-repository"; @@ -14,10 +15,12 @@ export function useSessionDraft(connectionId: string, sessionId: string) { const db = useSQLiteContext(); const scope = `${connectionId}\u0000${sessionId}`; const [draft, setDraftState] = useState(""); + const [mentions, setMentions] = useState([]); const [error, setError] = useState(); const [loaded, setLoaded] = useState(false); const [revision, setRevision] = useState(0); const latestRef = useRef(""); + const latestMentionsRef = useRef([]); const revisionRef = useRef(0); const revisionsByScopeRef = useRef(new Map()); const scopeRef = useRef(scope); @@ -27,7 +30,7 @@ export function useSessionDraft(connectionId: string, sessionId: string) { const writeChainRef = useRef>(Promise.resolve()); const enqueue = useCallback( - (content: string, contentRevision: number) => { + (content: string, contentMentions: SessionDraftMention[], contentRevision: number) => { const operation = writeChainRef.current .catch(() => undefined) .then(async () => { @@ -35,6 +38,7 @@ export function useSessionDraft(connectionId: string, sessionId: string) { await writeSessionDraft(db, { connectionId, content, + mentions: contentMentions, revision: contentRevision, sessionId, }); @@ -64,17 +68,21 @@ export function useSessionDraft(connectionId: string, sessionId: string) { } if (!dirtyRef.current) return; dirtyRef.current = false; - void enqueue(latestRef.current, revisionsByScopeRef.current.get(scope) ?? 0).catch( - () => undefined, - ); + void enqueue( + latestRef.current, + latestMentionsRef.current, + revisionsByScopeRef.current.get(scope) ?? 0, + ).catch(() => undefined); }, [enqueue, scope]); useEffect(() => { let active = true; latestRef.current = ""; + latestMentionsRef.current = []; revisionRef.current = revisionsByScopeRef.current.get(scope) ?? 0; dirtyRef.current = false; setDraftState(""); + setMentions([]); setError(undefined); setLoaded(false); setRevision(revisionRef.current); @@ -84,9 +92,11 @@ export function useSessionDraft(connectionId: string, sessionId: string) { const content = stored?.content ?? ""; const storedRevision = stored?.revision ?? 0; latestRef.current = content; + latestMentionsRef.current = stored?.mentions ?? []; revisionRef.current = storedRevision; revisionsByScopeRef.current.set(scope, storedRevision); setDraftState(content); + setMentions(stored?.mentions ?? []); setRevision(storedRevision); }) .catch(() => { @@ -106,13 +116,18 @@ export function useSessionDraft(connectionId: string, sessionId: string) { }; }, [connectionId, db, flush, scope, sessionId]); - function setDraft(content: string) { + function setDraft( + content: string, + contentMentions: SessionDraftMention[] = latestMentionsRef.current, + ) { latestRef.current = content; + latestMentionsRef.current = contentMentions; const nextRevision = (revisionsByScopeRef.current.get(scope) ?? 0) + 1; revisionRef.current = nextRevision; revisionsByScopeRef.current.set(scope, nextRevision); dirtyRef.current = true; setDraftState(content); + setMentions(contentMentions); setRevision(revisionRef.current); if (writeTimerRef.current !== null) clearTimeout(writeTimerRef.current); writeTimerRef.current = setTimeout(flush, draftWriteDelayMs); @@ -130,12 +145,14 @@ export function useSessionDraft(connectionId: string, sessionId: string) { } if (scopeRef.current === scope) { latestRef.current = ""; + latestMentionsRef.current = []; revisionRef.current = nextRevision; dirtyRef.current = false; setDraftState(""); + setMentions([]); setRevision(nextRevision); } - void enqueue("", nextRevision).catch(() => undefined); + void enqueue("", [], nextRevision).catch(() => undefined); }, [enqueue, scope], ); @@ -153,10 +170,10 @@ export function useSessionDraft(connectionId: string, sessionId: string) { writeTimerRef.current = null; dirtyRef.current = false; } - await enqueue(content, expectedRevision); + await enqueue(content, latestMentionsRef.current, expectedRevision); }, [enqueue, scope], ); - return { clearDraft, draft, error, loaded, persistDraft, revision, setDraft }; + return { clearDraft, draft, error, loaded, mentions, persistDraft, revision, setDraft }; } diff --git a/apps/mobile/src/screens/use-session-execution.test.tsx b/apps/mobile/src/screens/use-session-execution.test.tsx index 0417f53..bfa8cf0 100644 --- a/apps/mobile/src/screens/use-session-execution.test.tsx +++ b/apps/mobile/src/screens/use-session-execution.test.tsx @@ -29,6 +29,7 @@ const mockAdmissionDb = { }; const mockBackground = jest.fn<(...args: unknown[]) => Promise>(async () => undefined); const mockCancel = jest.fn<(...args: unknown[]) => Promise>(async () => undefined); +const mockCommand = jest.fn<(...args: unknown[]) => Promise>(async () => undefined); const mockGetMessage = jest.fn<(...args: unknown[]) => Promise>(); const mockInterrupt = jest.fn<(...args: unknown[]) => Promise>(async () => undefined); const mockListActive = jest.fn< @@ -37,10 +38,16 @@ const mockListActive = jest.fn< const mockListAgents = jest.fn< (...args: unknown[]) => Promise<{ data: []; location: LocationRef }> >(async () => ({ data: [], location: mockLocation })); +const mockListCommands = jest.fn< + (...args: unknown[]) => Promise<{ data: []; location: LocationRef }> +>(async () => ({ data: [], location: mockLocation })); const mockListInbox = jest.fn<(...args: unknown[]) => Promise>(async () => []); const mockListModels = jest.fn< (...args: unknown[]) => Promise<{ data: []; location: LocationRef }> >(async () => ({ data: [], location: mockLocation })); +const mockListSkills = jest.fn< + (...args: unknown[]) => Promise<{ data: []; location: LocationRef }> +>(async () => ({ data: [], location: mockLocation })); const mockPrompt = jest.fn<(...args: unknown[]) => Promise>(); const mockQueue = jest.fn<(...args: unknown[]) => Promise>(async () => undefined); const mockSteer = jest.fn<(...args: unknown[]) => Promise>(async () => undefined); @@ -61,10 +68,13 @@ jest.mock("@opencode2-mobile/opencode-adapter", () => ({ interruptOpenCodeSession: (...args: unknown[]) => mockInterrupt(...args), listActiveOpenCodeSessions: (...args: unknown[]) => mockListActive(...args), listOpenCodeAgents: (...args: unknown[]) => mockListAgents(...args), + listOpenCodeCommands: (...args: unknown[]) => mockListCommands(...args), listOpenCodeModels: (...args: unknown[]) => mockListModels(...args), listOpenCodeSessionInbox: (...args: unknown[]) => mockListInbox(...args), + listOpenCodeSkills: (...args: unknown[]) => mockListSkills(...args), promptOpenCodeSession: (...args: unknown[]) => mockPrompt(...args), queueOpenCodeSessionInboxItem: (...args: unknown[]) => mockQueue(...args), + runOpenCodeSessionCommand: (...args: unknown[]) => mockCommand(...args), steerOpenCodeSessionInboxItem: (...args: unknown[]) => mockSteer(...args), switchOpenCodeSessionAgent: (...args: unknown[]) => mockSwitchAgent(...args), switchOpenCodeSessionModel: (...args: unknown[]) => mockSwitchModel(...args), @@ -260,11 +270,95 @@ test("requires explicit active-turn delivery and applies inbox and execution con await waitFor(() => expect(mockWait).toHaveBeenCalledTimes(1)); }); +test("submits a command through the command endpoint with the existing admission guard", async () => { + const clearDraft = jest.fn(); + const hook = renderExecutionHook({ onAdmissionConfirmed: clearDraft }); + await waitFor(() => expect(hook.result.current.submitDisabled).toBe(false)); + + act(() => + hook.result.current.submit("/review src/æ.ts\nthen tests", { + arguments: "src/æ.ts\nthen tests", + command: "review", + type: "command", + }), + ); + + await waitFor(() => expect(mockCommand).toHaveBeenCalledTimes(1)); + expect(mockCommand.mock.calls[0]?.[2]).toMatchObject({ + command: "review", + delivery: "steer", + text: "src/æ.ts\nthen tests", + }); + expect(mockPrompt).not.toHaveBeenCalled(); + await waitFor(() => expect(clearDraft).toHaveBeenCalledTimes(1)); +}); + +test("offers an explicit duplicate-risk retry when a command response is lost", async () => { + mockCommand.mockRejectedValueOnce(new TypeError("Network request failed")); + const clearDraft = jest.fn(); + const hook = renderExecutionHook({ onAdmissionConfirmed: clearDraft }); + await waitFor(() => expect(hook.result.current.submitDisabled).toBe(false)); + + act(() => + hook.result.current.submit("/review", { + command: "review", + type: "command", + }), + ); + + await waitFor(() => expect(hook.result.current.admissions[0]?.status).toBe("unknown-delivery")); + expect(hook.result.current.admissions[0]).toMatchObject({ + kind: "command", + retryOffered: true, + }); + expect(hook.result.current.submitDisabled).toBe(true); + expect(clearDraft).not.toHaveBeenCalled(); + + const admissionID = hook.result.current.admissions[0]?.id ?? ""; + act(() => hook.result.current.reconcileAdmission(admissionID)); + await waitFor(() => expect(hook.result.current.error).toMatch(/cannot be identified/i)); + expect(mockGetMessage).not.toHaveBeenCalled(); +}); + +test("submits structured file, skill, and agent mentions through the prompt endpoint", async () => { + mockPrompt.mockImplementation(async (...args) => { + const input = args[2] as { delivery: "queue" | "steer"; id: string }; + return userInbox(input.id, input.delivery); + }); + const clearDraft = jest.fn(); + const hook = renderExecutionHook({ onAdmissionConfirmed: clearDraft }); + await waitFor(() => expect(hook.result.current.submitDisabled).toBe(false)); + + act(() => + hook.result.current.submit("Check @src/index.ts with @Explore and @release", { + agents: [{ mention: { end: 33, start: 25, text: "@Explore" }, name: "Explore" }], + files: [ + { + mention: { end: 19, start: 6, text: "@src/index.ts" }, + name: "src/index.ts", + uri: "file:///workspace/src/index.ts", + }, + ], + skills: [{ id: "release", mention: { end: 46, start: 38, text: "@release" } }], + type: "prompt", + }), + ); + + await waitFor(() => expect(mockPrompt).toHaveBeenCalledTimes(1)); + expect(mockPrompt.mock.calls[0]?.[2]).toMatchObject({ + agents: [{ name: "Explore" }], + files: [{ uri: "file:///workspace/src/index.ts" }], + skills: [{ id: "release" }], + }); + await waitFor(() => expect(clearDraft).toHaveBeenCalledTimes(1)); +}); + test("restores an unresolved admission after restart and reconciles it from the inbox", async () => { const queryClient = createQueryClient(); const admission = { durable: false, id: "msg_reconnected", + kind: "prompt", status: "unknown-delivery", submittedAtMs: 1, } satisfies PromptAdmission; @@ -273,6 +367,7 @@ test("restores an unresolved admission after restart and reconciles it from the admission_id: admission.id, delivery: null, draft_revision: 0, + submission_kind: "prompt", status: "submitting", submitted_at_ms: 1, }, @@ -294,6 +389,7 @@ test("does not clear a newer draft when a handled admission is restored", async delivery: "queue", durable: true, id: "msg_handled", + kind: "prompt", status: "queued", submittedAtMs: 1, } satisfies PromptAdmission; diff --git a/apps/mobile/src/screens/use-session-execution.ts b/apps/mobile/src/screens/use-session-execution.ts index 197f3a7..2a5862c 100644 --- a/apps/mobile/src/screens/use-session-execution.ts +++ b/apps/mobile/src/screens/use-session-execution.ts @@ -9,12 +9,15 @@ import { type LocationRef, listActiveOpenCodeSessions, listOpenCodeAgents, + listOpenCodeCommands, listOpenCodeModels, listOpenCodeSessionInbox, + listOpenCodeSkills, type ModelRef, type OpenCodeClient, promptOpenCodeSession, queueOpenCodeSessionInboxItem, + runOpenCodeSessionCommand, type SessionInfo, type SessionMessageInfo, steerOpenCodeSessionInboxItem, @@ -51,6 +54,7 @@ import { type PromptDelivery, reconcilePromptAdmission, } from "./prompt-admission-model"; +import type { ComposerSubmitIntent } from "./session-composer-model"; type SessionExecutionOptions = { client: OpenCodeClient | undefined; @@ -126,6 +130,14 @@ export function useSessionExecution({ }, queryKey: openCodeQueryKeys.agents(scopedConnectionId, location), }); + const commandsQuery = useQuery({ + enabled, + queryFn: ({ signal }) => { + if (!client) throw new Error("CONNECTION_NOT_READY"); + return listOpenCodeCommands(client, location, { signal }); + }, + queryKey: openCodeQueryKeys.commands(scopedConnectionId, location), + }); const modelsQuery = useQuery({ enabled, queryFn: ({ signal }) => { @@ -134,6 +146,14 @@ export function useSessionExecution({ }, queryKey: openCodeQueryKeys.models(scopedConnectionId, location), }); + const skillsQuery = useQuery({ + enabled, + queryFn: ({ signal }) => { + if (!client) throw new Error("CONNECTION_NOT_READY"); + return listOpenCodeSkills(client, location, { signal }); + }, + queryKey: openCodeQueryKeys.skills(scopedConnectionId, location), + }); const defaultModelQuery = useQuery({ enabled, queryFn: ({ signal }) => { @@ -203,6 +223,7 @@ export function useSessionExecution({ const confirmedAdmissions: PromptAdmission[] = []; updateAdmissions((current) => current.map((admission) => { + if (admission.kind === "command") return admission; const inboxItem = inboxById.get(admission.id); const projectedMessage = projectedMessagesById.get(admission.id); const serverAdmittedAtMs = inboxItem?.timeCreated ?? projectedMessage?.time.created; @@ -246,7 +267,7 @@ export function useSessionExecution({ updateAdmissions, ]); - const promptMutation = useMutation({ + const submissionMutation = useMutation({ mutationFn: async ({ admission, requestClient, @@ -254,6 +275,7 @@ export function useSessionExecution({ persistSubmittedDraft, requestSessionID, text, + intent, }: { admission: PromptAdmission; admissionKey: QueryKey; @@ -266,6 +288,7 @@ export function useSessionExecution({ requestSessionID: string; requestScope: string; text: string; + intent: ComposerSubmitIntent; }) => { return withController(controllersRef.current, async (signal) => { if (admission.draftRevision === undefined) { @@ -290,17 +313,37 @@ export function useSessionExecution({ } catch { throw new Error("PROMPT_ADMISSION_PERSISTENCE_FAILED"); } - return promptOpenCodeSession( + if (intent.type === "command") { + await runOpenCodeSessionCommand( + requestClient, + requestSessionID, + { + ...(intent.agents ? { agents: intent.agents } : {}), + command: intent.command, + delivery: admission.delivery ?? "steer", + ...(intent.files ? { files: intent.files } : {}), + ...(intent.skills ? { skills: intent.skills } : {}), + text: intent.arguments ?? "", + }, + { signal }, + ); + return { type: "command" as const }; + } + const item = await promptOpenCodeSession( requestClient, requestSessionID, { delivery: admission.delivery ?? "steer", + ...(intent.agents ? { agents: intent.agents } : {}), + ...(intent.files ? { files: intent.files } : {}), id: admission.id, resume: true, + ...(intent.skills ? { skills: intent.skills } : {}), text, }, { signal }, ); + return { item, type: "inbox" as const }; }); }, networkMode: "always", @@ -313,6 +356,7 @@ export function useSessionExecution({ requestConnectionID, requestSessionID, requestScope, + intent, }, ) => { const scopeIsCurrent = executionScopeRef.current === requestScope; @@ -329,7 +373,7 @@ export function useSessionExecution({ } if (caught instanceof Error && caught.message === "PROMPT_ADMISSION_PERSISTENCE_FAILED") { updateAdmissionAt(queryClient, submittedAdmissionKey, admission.id, markPromptCancelled); - if (scopeIsCurrent) setError("The prompt could not be saved safely and was not sent."); + if (scopeIsCurrent) setError("The submission could not be saved safely and was not sent."); return; } const classification = classifyOpenCodeError(caught); @@ -345,20 +389,27 @@ export function useSessionExecution({ requestSessionID, admission.id, ).catch(() => undefined); - if (scopeIsCurrent) setError("The server rejected this prompt before admission."); + if (scopeIsCurrent) { + setError( + intent.type === "command" + ? "The server rejected this command." + : "The server rejected this prompt before admission.", + ); + } return; } - updateAdmissionAt( - queryClient, - submittedAdmissionKey, - admission.id, - markPromptDeliveryUnknown, - ); + updateAdmissionAt(queryClient, submittedAdmissionKey, admission.id, (current) => { + const unknown = markPromptDeliveryUnknown(current); + return intent.type === "command" ? markPromptRetryOffered(unknown) : unknown; + }); if (scopeIsCurrent) { + const label = submissionLabel(intent); setError( classification === "CONFLICT" - ? "The server reported an admission conflict. Check delivery before sending again." - : "The response was lost. The prompt may have been admitted; check delivery before sending again.", + ? `The server reported a ${label} conflict. Check delivery before sending again.` + : intent.type === "command" + ? "The response was lost. The command may have run; check the transcript before sending again." + : "The response was lost. The prompt may have been admitted; check delivery before sending again.", ); } void reconcileSubmission(); @@ -367,7 +418,7 @@ export function useSessionExecution({ submittingRef.current = false; }, onSuccess: ( - item, + result, { admission, admissionKey: submittedAdmissionKey, @@ -378,6 +429,25 @@ export function useSessionExecution({ requestScope, }, ) => { + if (result.type === "command") { + updateAdmissionsAt(queryClient, submittedAdmissionKey, (current) => + current.filter((candidate) => candidate.id !== admission.id), + ); + void deleteUnresolvedPromptAdmission( + db, + requestConnectionID, + requestSessionID, + admission.id, + ).catch(() => undefined); + if (executionScopeRef.current === requestScope) setError(undefined); + confirmAdmission(admission.draftRevision ?? 0); + void reconcile(); + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch( + () => undefined, + ); + return; + } + const item = result.item; updateAdmissionAt(queryClient, submittedAdmissionKey, admission.id, (current) => markPromptConfirmationHandled(markPromptAdmitted(current, item.delivery, item.timeCreated)), ); @@ -528,14 +598,16 @@ export function useSessionExecution({ requestSessionID: string; }) => { if (executionScopeRef.current === requestScope) setBusyAction(action); - await withController(controllersRef.current, (signal) => { + await withController(controllersRef.current, async (signal) => { if (action === "interrupt") { - return interruptOpenCodeSession(requestClient, requestSessionID, false, { signal }); + await interruptOpenCodeSession(requestClient, requestSessionID, false, { signal }); + return; } if (action === "background") { - return backgroundOpenCodeSession(requestClient, requestSessionID, { signal }); + await backgroundOpenCodeSession(requestClient, requestSessionID, { signal }); + return; } - return waitForOpenCodeSession(requestClient, requestSessionID, { signal }); + await waitForOpenCodeSession(requestClient, requestSessionID, { signal }); }); return action; }, @@ -574,14 +646,14 @@ export function useSessionExecution({ ); } - function submit(text: string) { + function submit(text: string, intent: ComposerSubmitIntent = { type: "prompt" }) { if ( !client || !draftReady || !executionStateReady || (active && !delivery) || submittingRef.current || - promptMutation.isPending || + submissionMutation.isPending || unresolvedAdmission ) { return; @@ -590,9 +662,10 @@ export function useSessionExecution({ const admission = { ...createPromptAdmission(active ? delivery : "steer"), draftRevision, + kind: intent.type, }; updateAdmissions((current) => [...current, admission]); - promptMutation.mutate({ + submissionMutation.mutate({ admission, admissionKey, confirmAdmission: onAdmissionConfirmed, @@ -604,6 +677,7 @@ export function useSessionExecution({ requestSessionID: sessionID, requestScope: executionScope, text, + intent, }); } @@ -619,6 +693,17 @@ export function useSessionExecution({ if (!client) return; const requestScope = executionScope; setError(undefined); + const admission = admissions.find((candidate) => candidate.id === admissionID); + if (admission?.kind === "command") { + await reconcile(); + updateAdmission(admissionID, markPromptRetryOffered); + if (executionScopeRef.current === requestScope) { + setError( + "Command delivery cannot be identified after a lost response. Check the transcript before allowing another attempt.", + ); + } + return; + } const [inboxResult, messageResult] = await Promise.allSettled([ withController(controllersRef.current, (signal) => listOpenCodeSessionInbox(client, sessionID, { signal }), @@ -704,16 +789,24 @@ export function useSessionExecution({ admissions, agents, busyAction, + commands: commandsQuery.data?.data ?? [], + completionLoading: commandsQuery.isPending, + completionUnavailable: commandsQuery.isError, defaultModel: defaultModelQuery.data?.data ?? undefined, delivery, error, inbox, models, + mentionAgents: (agentsQuery.data?.data ?? []).filter( + (candidate) => !candidate.hidden && candidate.mode !== "primary", + ), + mentionLoading: agentsQuery.isPending || skillsQuery.isPending, + mentionUnavailable: agentsQuery.isError || skillsQuery.isError, projectedMessageIds, setDelivery, submit, submitDisabled: - promptMutation.isPending || + submissionMutation.isPending || switchAgentMutation.isPending || switchModelMutation.isPending || unresolvedAdmission || @@ -722,6 +815,7 @@ export function useSessionExecution({ !admissionsQuery.isSuccess || !draftReady || !enabled, + skills: skillsQuery.data?.data ?? [], switchAgent: (agent: string) => { if (!client) return; switchAgentMutation.mutate({ @@ -839,6 +933,10 @@ function currentAdmissionRevision(admissions: PromptAdmission[], admissionID: st return admissions.find((admission) => admission.id === admissionID)?.draftRevision ?? 0; } +function submissionLabel(intent: ComposerSubmitIntent) { + return intent.type === "prompt" ? "prompt" : intent.type; +} + function unresolvedPromptAdmission(admission: PromptAdmission) { if (admission.draftRevision === undefined) { throw new Error("PROMPT_ADMISSION_PERSISTENCE_FAILED"); @@ -848,6 +946,7 @@ function unresolvedPromptAdmission(admission: PromptAdmission) { draftRevision: admission.draftRevision, durable: false as const, id: admission.id, + kind: admission.kind, status: admission.status === "submitting" ? ("submitting" as const) : ("unknown-delivery" as const), submittedAtMs: admission.submittedAtMs, diff --git a/apps/mobile/src/screens/workspace-screen.tsx b/apps/mobile/src/screens/workspace-screen.tsx index 548650f..cfd303e 100644 --- a/apps/mobile/src/screens/workspace-screen.tsx +++ b/apps/mobile/src/screens/workspace-screen.tsx @@ -1,4 +1,5 @@ import { + findOpenCodeFiles, getDefaultOpenCodeLocation, getOpenCodeLocation, getOpenCodeSession, @@ -83,6 +84,7 @@ const maxTranscriptPages = 5; const iosKeyboardTransparentTopInset = 32; const liveEdgeThreshold = 2; const userScrollSettleMs = 160; +const mentionFileLimit = 20; const unresolvedLocation = { directory: "__unresolved__" } satisfies LocationRef; export function WorkspaceScreen({ navigation }: WorkspaceProps) { @@ -517,6 +519,8 @@ export function SessionScreen({ navigation, route }: SessionProps) { const [composerDockHeight, setComposerDockHeight] = useState(66); const [composerDockScreenBottom, setComposerDockScreenBottom] = useState(0); const [composerKeyboardOffset, setComposerKeyboardOffset] = useState(0); + const [mentionSearch, setMentionSearch] = useState(); + const deferredMentionSearch = useDeferredValue(mentionSearch); const composerDockRef = useRef(null); const measuredComposerDockScreenHeightRef = useRef(undefined); const transcriptListRef = useRef>(null); @@ -564,6 +568,24 @@ export function SessionScreen({ navigation, route }: SessionProps) { order: "desc", }), }); + const mentionFilesQuery = useQuery({ + enabled: Boolean( + client && connectionId === routeConnectionId && deferredMentionSearch !== undefined, + ), + queryFn: ({ signal }) => { + if (!client || deferredMentionSearch === undefined) throw new Error("CONNECTION_NOT_READY"); + return findOpenCodeFiles(client, sessionLocation, deferredMentionSearch, { + limit: mentionFileLimit, + signal, + }); + }, + queryKey: openCodeQueryKeys.fileFind( + routeConnectionId, + sessionLocation, + deferredMentionSearch ?? "", + mentionFileLimit, + ), + }); const currentBranch = vcsQuery.data?.data.branch.current; const branch = currentBranch ? ({ @@ -583,7 +605,7 @@ export function SessionScreen({ navigation, route }: SessionProps) { connectionId, draftReady: draft.loaded, draftRevision: draft.revision, - location, + location: sessionLocation, messages, onAdmissionConfirmed: draft.clearDraft, persistDraft: draft.persistDraft, @@ -866,6 +888,9 @@ export function SessionScreen({ navigation, route }: SessionProps) { active={execution.active} agent={execution.selectedAgent} agents={execution.agents} + commands={execution.commands} + completionLoading={execution.completionLoading} + completionUnavailable={execution.completionUnavailable} delivery={execution.delivery} disabled={execution.submitDisabled || !draft.loaded} draft={draft.draft} @@ -873,13 +898,27 @@ export function SessionScreen({ navigation, route }: SessionProps) { error={execution.error ?? draft.error} focusOnMount={focusComposer} largeText={largeText} + location={sessionLocation} + mentionAgents={execution.mentionAgents} + mentionFiles={ + deferredMentionSearch === mentionSearch ? (mentionFilesQuery.data?.data ?? []) : [] + } + mentionLoading={ + execution.mentionLoading || + (mentionSearch !== undefined && + (mentionFilesQuery.isPending || deferredMentionSearch !== mentionSearch)) + } + mentions={draft.mentions} + mentionUnavailable={execution.mentionUnavailable || mentionFilesQuery.isError} model={execution.selectedModel} models={execution.models} onAgentChange={execution.switchAgent} onDeliveryChange={execution.setDelivery} onDraftChange={draft.setDraft} onModelChange={execution.switchModel} - onSubmit={() => execution.submit(draft.draft)} + onMentionSearchChange={setMentionSearch} + onSubmit={(intent) => execution.submit(draft.draft, intent)} + skills={execution.skills} /> ); diff --git a/apps/mobile/src/state/connection-event-query-bridge.test.ts b/apps/mobile/src/state/connection-event-query-bridge.test.ts index ec9b789..4871f30 100644 --- a/apps/mobile/src/state/connection-event-query-bridge.test.ts +++ b/apps/mobile/src/state/connection-event-query-bridge.test.ts @@ -106,6 +106,32 @@ test("reconciles one transcript after execution completes", () => { queryClient.clear(); }); +test.each(["session.step.streamed", "session.message.content.updated"] as const)( + "%s reconciles only the affected transcript", + (type) => { + const queryClient = new QueryClient(); + const invalidate = jest.spyOn(queryClient, "invalidateQueries"); + const bridge = new ConnectionEventQueryBridge(queryClient, "connection-1", (callback) => + callback(), + ); + const location = { directory: "/workspace" }; + const affectedKey = openCodeQueryKeys.messages("connection-1", location, "session-1", {}); + const otherKey = openCodeQueryKeys.messages("connection-1", location, "session-2", {}); + queryClient.setQueryData(affectedKey, []); + queryClient.setQueryData(otherKey, []); + + bridge.apply(messageReconciliationEvent(type)); + + expect(invalidate).toHaveBeenCalledTimes(1); + const predicate = invalidate.mock.calls[0]?.[0]?.predicate; + const affected = queryClient.getQueryCache().find({ queryKey: affectedKey }); + const other = queryClient.getQueryCache().find({ queryKey: otherKey }); + expect(affected && predicate?.(affected)).toBe(true); + expect(other && predicate?.(other)).toBe(false); + queryClient.clear(); + }, +); + test("does not write active-session state for transcript deltas", () => { const queryClient = new QueryClient(); const setQueryData = jest.spyOn(queryClient, "setQueryData"); @@ -512,6 +538,22 @@ function sessionExecutionSucceededEvent(id: string) { } satisfies OpenCodeEvent; } +function messageReconciliationEvent( + type: "session.step.streamed" | "session.message.content.updated", +) { + return { + created: 1, + data: + type === "session.step.streamed" + ? { assistantMessageID: "message-1", sessionID: "session-1" } + : { content: [], messageID: "message-1", sessionID: "session-1" }, + durable: { aggregateID: "session-1", seq: 1, version: 1 as const }, + id: `event-${type}`, + location: { directory: "/workspace" }, + type, + } as OpenCodeEvent; +} + function transcriptEvent(id: string, type: string, data: Record) { return { created: 1, diff --git a/apps/mobile/src/state/connection-event-query-bridge.ts b/apps/mobile/src/state/connection-event-query-bridge.ts index bf5c25e..bb31e35 100644 --- a/apps/mobile/src/state/connection-event-query-bridge.ts +++ b/apps/mobile/src/state/connection-event-query-bridge.ts @@ -339,9 +339,11 @@ const messageReconciliationEventTypes = new Set([ "session.execution.interrupted", "session.compaction.ended", "session.compaction.failed", + "session.message.content.updated", "session.revert.staged", "session.revert.cleared", "session.revert.committed", + "session.step.streamed", ]); function addActiveSession(sessions: Record, sessionId: string) { diff --git a/apps/mobile/src/state/open-code-query-keys.test.ts b/apps/mobile/src/state/open-code-query-keys.test.ts index 6ec47ad..86aab58 100644 --- a/apps/mobile/src/state/open-code-query-keys.test.ts +++ b/apps/mobile/src/state/open-code-query-keys.test.ts @@ -34,6 +34,37 @@ test("does not collide across connections or workspace locations", () => { expect(first).not.toEqual(workspace); }); +test("scopes composer catalogs and file searches to the exact location", () => { + const location = { directory: "/workspace", workspaceID: "wrk_test" }; + + expect(openCodeQueryKeys.commands("connection-1", location)).toEqual([ + "opencode", + "connection-1", + "location", + "/workspace", + "wrk_test", + "commands", + ]); + expect(openCodeQueryKeys.skills("connection-1", location)).toEqual([ + "opencode", + "connection-1", + "location", + "/workspace", + "wrk_test", + "skills", + ]); + expect(openCodeQueryKeys.fileFind("connection-1", location, "src/index", 20)).toEqual([ + "opencode", + "connection-1", + "location", + "/workspace", + "wrk_test", + "file-find", + "src/index", + 20, + ]); +}); + test("separates working-tree and branch diffs within one location", () => { const location = { directory: "/workspace", workspaceID: "wrk_test" }; expect(openCodeQueryKeys.vcsDiff("connection-1", location, "working")).toEqual([ diff --git a/apps/mobile/src/state/open-code-query-keys.ts b/apps/mobile/src/state/open-code-query-keys.ts index abdebd1..342d500 100644 --- a/apps/mobile/src/state/open-code-query-keys.ts +++ b/apps/mobile/src/state/open-code-query-keys.ts @@ -19,6 +19,9 @@ export const openCodeQueryKeys = { agents(connectionId: string, location: LocationRef) { return [...locationKey(connectionId, location), "agents"] as const; }, + commands(connectionId: string, location: LocationRef) { + return [...locationKey(connectionId, location), "commands"] as const; + }, connection(connectionId: string) { return connectionKey(connectionId); }, @@ -28,6 +31,9 @@ export const openCodeQueryKeys = { forms(connectionId: string, location: LocationRef) { return [...locationKey(connectionId, location), "forms"] as const; }, + fileFind(connectionId: string, location: LocationRef, query: string, limit: number) { + return [...locationKey(connectionId, location), "file-find", query, limit] as const; + }, followedProjectSessions( connectionId: string, projectIds: readonly string[], @@ -159,6 +165,9 @@ export const openCodeQueryKeys = { : parameters.parentID, ] as const; }, + skills(connectionId: string, location: LocationRef) { + return [...locationKey(connectionId, location), "skills"] as const; + }, }; function connectionKey(connectionId: string) { diff --git a/apps/mobile/src/storage/database.test.ts b/apps/mobile/src/storage/database.test.ts index 59f3ceb..31bb99d 100644 --- a/apps/mobile/src/storage/database.test.ts +++ b/apps/mobile/src/storage/database.test.ts @@ -12,7 +12,7 @@ test("creates the current mobile database schema", async () => { await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(9); + expect(execAsync).toHaveBeenCalledTimes(11); expect(execAsync.mock.calls[1]?.[0]).toContain("CREATE TABLE IF NOT EXISTS connection_profiles"); expect(execAsync.mock.calls[2]?.[0]).toContain("CREATE TABLE IF NOT EXISTS app_preferences"); expect(execAsync.mock.calls[3]?.[0]).toContain("CREATE TABLE IF NOT EXISTS session_drafts"); @@ -31,6 +31,10 @@ test("creates the current mobile database schema", async () => { expect(execAsync.mock.calls[7]?.[0]).toContain("PRAGMA user_version = 7"); expect(execAsync.mock.calls[8]?.[0]).toContain("pending_notification_revocations"); expect(execAsync.mock.calls[8]?.[0]).toContain("PRAGMA user_version = 8"); + expect(execAsync.mock.calls[9]?.[0]).toContain("ADD COLUMN payload_version"); + expect(execAsync.mock.calls[9]?.[0]).toContain("PRAGMA user_version = 9"); + expect(execAsync.mock.calls[10]?.[0]).toContain("ADD COLUMN submission_kind"); + expect(execAsync.mock.calls[10]?.[0]).toContain("PRAGMA user_version = 10"); }); test("migrates an existing profile database to app-lock preferences", async () => { @@ -42,7 +46,7 @@ test("migrates an existing profile database to app-lock preferences", async () = await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(8); + expect(execAsync).toHaveBeenCalledTimes(10); expect(execAsync.mock.calls[1]?.[0]).toContain("CREATE TABLE IF NOT EXISTS app_preferences"); expect(execAsync.mock.calls[1]?.[0]).not.toContain("connection_profiles"); expect(execAsync.mock.calls[2]?.[0]).toContain("CREATE TABLE IF NOT EXISTS session_drafts"); @@ -61,7 +65,7 @@ test("migrates app-lock databases to encrypted draft storage", async () => { await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(7); + expect(execAsync).toHaveBeenCalledTimes(9); expect(execAsync.mock.calls[1]?.[0]).toContain("ciphertext BLOB NOT NULL"); expect(execAsync.mock.calls[1]?.[0]).toContain("ON DELETE CASCADE"); expect(execAsync.mock.calls[1]?.[0]).not.toContain("app_preferences"); @@ -80,7 +84,7 @@ test("migrates encrypted draft databases to unresolved admission storage", async await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(6); + expect(execAsync).toHaveBeenCalledTimes(8); expect(execAsync.mock.calls[1]?.[0]).toContain("status IN ('submitting', 'unknown-delivery')"); expect(execAsync.mock.calls[1]?.[0]).toContain("ADD COLUMN revision"); expect(execAsync.mock.calls[2]?.[0]).toContain("followed_projects"); @@ -96,7 +100,7 @@ test("migrates admission databases to followed project preferences", async () => await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(5); + expect(execAsync).toHaveBeenCalledTimes(7); expect(execAsync.mock.calls[1]?.[0]).toContain("followed_project_preferences"); expect(execAsync.mock.calls[1]?.[0]).toContain("PRIMARY KEY (connection_id, project_id)"); expect(execAsync.mock.calls[1]?.[0]).toContain("UNIQUE (connection_id, position)"); @@ -112,7 +116,7 @@ test("migrates followed project databases to notification pairing storage", asyn await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(4); + expect(execAsync).toHaveBeenCalledTimes(6); expect(execAsync.mock.calls[1]?.[0]).toContain("pending_notification_secret_deletions"); expect(execAsync.mock.calls[1]?.[0]).toContain("BEGIN IMMEDIATE"); expect(execAsync.mock.calls[1]?.[0]).toContain("COMMIT"); @@ -128,7 +132,7 @@ test("migrates notification pairings to handled event replay storage", async () await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(3); + expect(execAsync).toHaveBeenCalledTimes(5); expect(execAsync.mock.calls[1]?.[0]).toContain("handled_notification_events"); expect(execAsync.mock.calls[1]?.[0]).toContain("ON DELETE CASCADE"); expect(execAsync.mock.calls[1]?.[0]).toContain("COMMIT"); @@ -144,16 +148,50 @@ test("migrates handled events to pending notification revocation storage", async await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(2); + expect(execAsync).toHaveBeenCalledTimes(4); expect(execAsync.mock.calls[1]?.[0]).toContain("pending_notification_revocations"); expect(execAsync.mock.calls[1]?.[0]).toContain("PRAGMA user_version = 8"); expect(execAsync.mock.calls[1]?.[0]).toContain("COMMIT"); }); +test("migrates legacy encrypted drafts to explicit payload versioning", async () => { + const execAsync = jest.fn<(source: string) => Promise>(async () => undefined); + const db = { + execAsync, + getFirstAsync: jest.fn(async () => ({ user_version: 8 })), + } as unknown as SQLiteDatabase; + + await migrateMobileDatabase(db); + + expect(execAsync).toHaveBeenCalledTimes(3); + expect(execAsync.mock.calls[1]?.[0]).toContain("ADD COLUMN payload_version"); + expect(execAsync.mock.calls[1]?.[0]).toContain("DEFAULT 1"); + expect(execAsync.mock.calls[1]?.[0]).toContain("BEGIN IMMEDIATE"); + expect(execAsync.mock.calls[1]?.[0]).toContain("PRAGMA user_version = 9"); + expect(execAsync.mock.calls[1]?.[0]).toContain("COMMIT"); +}); + +test("migrates admission recovery metadata to distinguish commands", async () => { + const execAsync = jest.fn<(source: string) => Promise>(async () => undefined); + const db = { + execAsync, + getFirstAsync: jest.fn(async () => ({ user_version: 9 })), + } as unknown as SQLiteDatabase; + + await migrateMobileDatabase(db); + + expect(execAsync).toHaveBeenCalledTimes(2); + expect(execAsync.mock.calls[1]?.[0]).toContain("ADD COLUMN submission_kind"); + expect(execAsync.mock.calls[1]?.[0]).toContain("DEFAULT 'prompt'"); + expect(execAsync.mock.calls[1]?.[0]).toContain("BEGIN IMMEDIATE"); + expect(execAsync.mock.calls[1]?.[0]).toContain("PRAGMA user_version = 10"); + expect(execAsync.mock.calls[1]?.[0]).toContain("COMMIT"); +}); + test("rejects a database created by a newer app", async () => { const db = { execAsync: jest.fn(async () => undefined), - getFirstAsync: jest.fn(async () => ({ user_version: 9 })), + getFirstAsync: jest.fn(async () => ({ user_version: 11 })), } as unknown as SQLiteDatabase; await expect(migrateMobileDatabase(db)).rejects.toThrow("DATABASE_VERSION_TOO_NEW"); diff --git a/apps/mobile/src/storage/database.ts b/apps/mobile/src/storage/database.ts index fac6345..8aa98c6 100644 --- a/apps/mobile/src/storage/database.ts +++ b/apps/mobile/src/storage/database.ts @@ -1,7 +1,7 @@ import type { SQLiteDatabase } from "expo-sqlite"; export const mobileDatabaseName = "opencode-mobile.db"; -export const mobileDatabaseSchemaVersion = 8; +export const mobileDatabaseSchemaVersion = 10; const maxDraftCiphertextBytes = 256 * 1024 + 16; @@ -196,4 +196,36 @@ export async function migrateMobileDatabase(db: SQLiteDatabase) { throw caught; } } + + if (version < 9) { + try { + await db.execAsync(` + BEGIN IMMEDIATE; + ALTER TABLE session_drafts + ADD COLUMN payload_version INTEGER NOT NULL DEFAULT 1 + CHECK (payload_version IN (1, 2)); + PRAGMA user_version = 9; + COMMIT; + `); + } catch (caught) { + await db.execAsync("ROLLBACK;").catch(() => undefined); + throw caught; + } + } + + if (version < 10) { + try { + await db.execAsync(` + BEGIN IMMEDIATE; + ALTER TABLE unresolved_prompt_admissions + ADD COLUMN submission_kind TEXT NOT NULL DEFAULT 'prompt' + CHECK (submission_kind IN ('command', 'prompt')); + PRAGMA user_version = 10; + COMMIT; + `); + } catch (caught) { + await db.execAsync("ROLLBACK;").catch(() => undefined); + throw caught; + } + } } diff --git a/apps/mobile/src/storage/draft-repository.test.ts b/apps/mobile/src/storage/draft-repository.test.ts index 13cc08e..84f286a 100644 --- a/apps/mobile/src/storage/draft-repository.test.ts +++ b/apps/mobile/src/storage/draft-repository.test.ts @@ -13,6 +13,7 @@ jest.mock("../security/draft-key-store", () => ({ readConnectionDraftKey: async () => mockDraftKey, })); +import { encryptSessionDraft } from "./draft-crypto"; import { cleanupPendingDraftKeyDeletions, deleteSessionDraft, @@ -39,14 +40,21 @@ test("writes only encrypted draft bytes to SQLite and reads them back", async () await writeSessionDraft(writeDb, { connectionId: "connection-1", content: "private draft", + mentions: [ + { + id: "release", + mention: { end: 8, start: 0, text: "@release" }, + type: "skill", + }, + ], revision: 7, sessionId: "session-1", }); expect(JSON.stringify(runAsync.mock.calls)).not.toContain("private draft"); const parameters = runAsync.mock.calls[0]; - const nonce = parameters?.[5]; - const ciphertext = parameters?.[6]; + const nonce = parameters?.[6]; + const ciphertext = parameters?.[7]; expect(nonce).toBeInstanceOf(Uint8Array); expect(ciphertext).toBeInstanceOf(Uint8Array); expect(String(parameters?.[0])).toContain("excluded.revision >= session_drafts.revision"); @@ -56,6 +64,7 @@ test("writes only encrypted draft bytes to SQLite and reads them back", async () getFirstAsync: jest.fn(async () => ({ ciphertext, nonce, + payload_version: 2, revision: 7, schema_version: 1, updated_at_ms: 42, @@ -63,11 +72,40 @@ test("writes only encrypted draft bytes to SQLite and reads them back", async () } as unknown as SQLiteDatabase; await expect(readSessionDraft(readDb, "connection-1", "session-1")).resolves.toEqual({ content: "private draft", + mentions: [ + { + id: "release", + mention: { end: 8, start: 0, text: "@release" }, + type: "skill", + }, + ], revision: 7, updatedAtMs: 42, }); }); +test("reads pre-mention encrypted drafts as plain text", async () => { + const legacyDraft = 'opencode-mobile-draft:2\n{"content":"still plain text"}'; + const encrypted = encryptSessionDraft(legacyDraft, mockDraftKey, "connection-1", "session-1"); + const db = { + getFirstAsync: jest.fn(async () => ({ + ciphertext: encrypted.ciphertext, + nonce: encrypted.nonce, + payload_version: 1, + revision: 3, + schema_version: 1, + updated_at_ms: 42, + })), + } as unknown as SQLiteDatabase; + + await expect(readSessionDraft(db, "connection-1", "session-1")).resolves.toEqual({ + content: legacyDraft, + mentions: [], + revision: 3, + updatedAtMs: 42, + }); +}); + test("deletes one connection/session draft", async () => { const runAsync = jest.fn(async (..._parameters: unknown[]) => ({ changes: 1, diff --git a/apps/mobile/src/storage/draft-repository.ts b/apps/mobile/src/storage/draft-repository.ts index 05fa3f4..ec91cbf 100644 --- a/apps/mobile/src/storage/draft-repository.ts +++ b/apps/mobile/src/storage/draft-repository.ts @@ -8,10 +8,12 @@ import { import { decryptSessionDraft, encryptSessionDraft } from "./draft-crypto"; const draftSchemaVersion = 1; +const draftPayloadVersion = 2; type SessionDraftRow = { ciphertext: Uint8Array; nonce: Uint8Array; + payload_version: number; revision: number; schema_version: number; updated_at_ms: number; @@ -19,13 +21,20 @@ type SessionDraftRow = { export type SessionDraft = { content: string; + mentions: SessionDraftMention[]; revision: number; updatedAtMs: number; }; +export type SessionDraftMention = + | { mention: { end: number; start: number; text: string }; path: string; type: "file" } + | { mention: { end: number; start: number; text: string }; name: string; type: "agent" } + | { id: string; mention: { end: number; start: number; text: string }; type: "skill" }; + export type WriteSessionDraftInput = { connectionId: string; content: string; + mentions?: SessionDraftMention[]; revision: number; sessionId: string; }; @@ -38,13 +47,20 @@ export async function writeSessionDraft(db: SQLiteDatabase, input: WriteSessionD if (!profile) throw new Error("CONNECTION_PROFILE_NOT_FOUND"); const key = await getOrCreateConnectionDraftKey(input.connectionId); - const encrypted = encryptSessionDraft(input.content, key, input.connectionId, input.sessionId); + const encrypted = encryptSessionDraft( + encodeDraftPayload(input.content, input.mentions ?? []), + key, + input.connectionId, + input.sessionId, + ); const result = await db.runAsync( `INSERT INTO session_drafts ( - connection_id, session_id, schema_version, revision, nonce, ciphertext, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?) + connection_id, session_id, schema_version, payload_version, revision, nonce, ciphertext, + updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(connection_id, session_id) DO UPDATE SET schema_version = excluded.schema_version, + payload_version = excluded.payload_version, revision = excluded.revision, nonce = excluded.nonce, ciphertext = excluded.ciphertext, @@ -53,6 +69,7 @@ export async function writeSessionDraft(db: SQLiteDatabase, input: WriteSessionD input.connectionId, input.sessionId, draftSchemaVersion, + draftPayloadVersion, input.revision, encrypted.nonce, encrypted.ciphertext, @@ -78,7 +95,7 @@ export async function readSessionDraft( sessionId: string, ): Promise { const row = await db.getFirstAsync( - `SELECT schema_version, revision, nonce, ciphertext, updated_at_ms + `SELECT schema_version, payload_version, revision, nonce, ciphertext, updated_at_ms FROM session_drafts WHERE connection_id = ? AND session_id = ?`, connectionId, sessionId, @@ -86,6 +103,7 @@ export async function readSessionDraft( if (!row) return undefined; if ( row.schema_version !== draftSchemaVersion || + (row.payload_version !== 1 && row.payload_version !== draftPayloadVersion) || !Number.isInteger(row.revision) || row.revision < 0 || !(row.nonce instanceof Uint8Array) || @@ -95,18 +113,71 @@ export async function readSessionDraft( } const key = await readConnectionDraftKey(connectionId); + const plaintext = decryptSessionDraft( + { ciphertext: row.ciphertext, nonce: row.nonce }, + key, + connectionId, + sessionId, + ); + const payload = + row.payload_version === draftPayloadVersion + ? decodeDraftPayload(plaintext) + : { content: plaintext, mentions: [] }; return { - content: decryptSessionDraft( - { ciphertext: row.ciphertext, nonce: row.nonce }, - key, - connectionId, - sessionId, - ), + content: payload.content, + mentions: payload.mentions, revision: row.revision, updatedAtMs: row.updated_at_ms, }; } +function encodeDraftPayload(content: string, mentions: SessionDraftMention[]) { + return JSON.stringify({ content, mentions }); +} + +function decodeDraftPayload(value: string): { content: string; mentions: SessionDraftMention[] } { + try { + const decoded: unknown = JSON.parse(value); + if ( + !isRecord(decoded) || + typeof decoded.content !== "string" || + !Array.isArray(decoded.mentions) || + !decoded.mentions.every(isDraftMention) + ) { + throw new Error("INVALID_STORED_DRAFT"); + } + return { content: decoded.content, mentions: decoded.mentions }; + } catch (caught) { + if (caught instanceof Error && caught.message === "INVALID_STORED_DRAFT") throw caught; + throw new Error("INVALID_STORED_DRAFT"); + } +} + +function isDraftMention(value: unknown): value is SessionDraftMention { + if (!isRecord(value) || !isRecord(value.mention)) return false; + const start = value.mention.start; + const end = value.mention.end; + if ( + typeof start !== "number" || + typeof end !== "number" || + !Number.isInteger(start) || + !Number.isInteger(end) || + start < 0 || + end < start || + typeof value.mention.text !== "string" + ) { + return false; + } + if (value.type === "file") return typeof value.path === "string" && Boolean(value.path); + if (value.type === "agent") return typeof value.name === "string" && Boolean(value.name); + if (value.type === "skill") return typeof value.id === "string" && Boolean(value.id); + return false; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + export async function deleteSessionDraft( db: SQLiteDatabase, connectionId: string, diff --git a/apps/mobile/src/storage/prompt-admission-repository.test.ts b/apps/mobile/src/storage/prompt-admission-repository.test.ts index b84486f..82d9586 100644 --- a/apps/mobile/src/storage/prompt-admission-repository.test.ts +++ b/apps/mobile/src/storage/prompt-admission-repository.test.ts @@ -20,6 +20,7 @@ test("stores only content-free unresolved admission metadata", async () => { draftRevision: 3, durable: false, id: "msg_test", + kind: "command", status: "unknown-delivery", submittedAtMs: 10, }); @@ -32,6 +33,7 @@ test("stores only content-free unresolved admission metadata", async () => { "unknown-delivery", "queue", 3, + "command", 10, ]); expect(String(runAsync.mock.calls[1]?.[0])).toContain("LIMIT 20"); @@ -67,6 +69,7 @@ test("decodes unresolved admission rows and deletes a confirmed ID", async () => admission_id: "msg_test", delivery: "steer", draft_revision: 2, + submission_kind: "prompt", status: "submitting", submitted_at_ms: 10, }, @@ -80,6 +83,7 @@ test("decodes unresolved admission rows and deletes a confirmed ID", async () => draftRevision: 2, durable: false, id: "msg_test", + kind: "prompt", status: "unknown-delivery", submittedAtMs: 10, }, diff --git a/apps/mobile/src/storage/prompt-admission-repository.ts b/apps/mobile/src/storage/prompt-admission-repository.ts index d42149c..a9d3a8c 100644 --- a/apps/mobile/src/storage/prompt-admission-repository.ts +++ b/apps/mobile/src/storage/prompt-admission-repository.ts @@ -5,6 +5,7 @@ export type PersistedPromptAdmission = { draftRevision: number; durable: false; id: string; + kind: "command" | "prompt"; status: "submitting" | "unknown-delivery"; submittedAtMs: number; }; @@ -13,6 +14,7 @@ type PromptAdmissionRow = { admission_id: string; delivery: "queue" | "steer" | null; draft_revision: number; + submission_kind: "command" | "prompt"; status: "submitting" | "unknown-delivery"; submitted_at_ms: number; }; @@ -23,7 +25,7 @@ export async function listUnresolvedPromptAdmissions( sessionId: string, ): Promise { const rows = await db.getAllAsync( - `SELECT admission_id, delivery, draft_revision, status, submitted_at_ms + `SELECT admission_id, delivery, draft_revision, status, submission_kind, submitted_at_ms FROM unresolved_prompt_admissions WHERE connection_id = ? AND session_id = ? ORDER BY submitted_at_ms ASC @@ -36,6 +38,7 @@ export async function listUnresolvedPromptAdmissions( draftRevision: row.draft_revision, durable: false, id: row.admission_id, + kind: row.submission_kind, status: "unknown-delivery", submittedAtMs: row.submitted_at_ms, })); @@ -51,12 +54,13 @@ export async function writeUnresolvedPromptAdmission( await txn.runAsync( `INSERT INTO unresolved_prompt_admissions ( connection_id, session_id, admission_id, schema_version, status, - delivery, draft_revision, submitted_at_ms - ) VALUES (?, ?, ?, 1, ?, ?, ?, ?) + delivery, draft_revision, submission_kind, submitted_at_ms + ) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?) ON CONFLICT(connection_id, session_id, admission_id) DO UPDATE SET status = excluded.status, delivery = excluded.delivery, draft_revision = excluded.draft_revision, + submission_kind = excluded.submission_kind, submitted_at_ms = excluded.submitted_at_ms`, connectionId, sessionId, @@ -64,6 +68,7 @@ export async function writeUnresolvedPromptAdmission( admission.status, admission.delivery ?? null, admission.draftRevision, + admission.kind, admission.submittedAtMs, ); await txn.runAsync( diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index fbca940..947093a 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -12,6 +12,39 @@ the fail-closed database backup-exclusion startup guard. Statements marked pending in older dated entries describe the status at the time of that probe; later entries supersede them. +## 2026-08-27: beta 18387 generated-client upgrade + +### Stack + +- OpenCode client, protocol, and schema: `0.0.0-beta-18387` +- OpenCode notification plugin: `0.0.0-beta-18387` +- Installed OpenCode CLI: `0.0.0-beta-18387` +- Shared service used for the catalog probe: `0.0.0-beta-18371` + +### Results + +| Probe | Result | +| --- | --- | +| Install matching client, protocol, schema, AI, and plugin packages | Pass | +| Compile the adapter, mobile app, and notification plugin against the generated types | Pass | +| List commands at the exact repository location | Pass | +| Receive built-in and configured command records with names and optional descriptions | Pass | +| Send command arguments as `text` and accept the generated client's `204` response | Pass in the deterministic fake API | +| Keep a lost command response behind an explicit duplicate-risk retry guard | Pass | +| Route beta 18387 step-streamed and message-content events to exact-session reconciliation | Pass | +| Decode the beta 18387 interrupt response | Pass in the deterministic fake API | +| Run all 292 mobile tests | Pass | +| Export iOS and Android Hermes bundles | Pass | +| Run Expo Doctor | Pass, 18/18 checks | + +Beta 18387 removes command templates from `Command.Info`. The command endpoint +also replaces `arguments`, client message IDs, and inbox responses with `text` +and a `204` response. Prompt admission IDs remain stable and reconcilable. +Command delivery cannot be identified after a response is lost, so the app +refreshes server state, preserves the draft, and requires an explicit retry. +The catalog probe recorded no address, credential, identifier, path, prompt, or +server content. + ## 2026-08-24: local beta 18050 interaction-scope probes ### Result diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md index b3bcf74..2a769d5 100644 --- a/docs/NOTIFICATIONS.md +++ b/docs/NOTIFICATIONS.md @@ -95,7 +95,7 @@ OpenCode. } ``` -The package is pinned to `@opencode-ai/plugin@0.0.0-beta-18050`. It subscribes to +The package is pinned to `@opencode-ai/plugin@0.0.0-beta-18387`. It subscribes to `permission.asked`, `permission.replied`, `form.created`, `form.replied`, `form.cancelled`, and `session.execution.succeeded`. It converts permission actions to a finite category before storing a sanitized retry queue in plugin diff --git a/docs/PUSH_AGENT_RUNBOOK.md b/docs/PUSH_AGENT_RUNBOOK.md index df8fcd9..77d23a6 100644 --- a/docs/PUSH_AGENT_RUNBOOK.md +++ b/docs/PUSH_AGENT_RUNBOOK.md @@ -77,10 +77,10 @@ host and identify the first boundary that remains unverified. - Linux with a user-level systemd session. - OpenCode V2 running under the same account as the broker. -- OpenCode `0.0.0-beta-18286`, the server version used for the latest physical - probe. The plugin dependency and mobile API contract remain pinned to beta - 18050. If the installed server differs, report it and ask whether the internal - deployment owner has approved that version before continuing. +- OpenCode `0.0.0-beta-18387`, matching the plugin dependency and mobile API + contract. The latest physical notification probe used beta 18286. If the + installed server differs, report it and ask whether the internal deployment + owner has approved that version before continuing. - Node `26.7.0`, pnpm `11.21.0`, Git, and `fnm` or another way to run the pinned Node release. - A phone-reachable broker origin. Prefer HTTPS. Private Tailscale or LAN HTTP is diff --git a/docs/SPEC.md b/docs/SPEC.md index 9820656..f0d32a0 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -21,7 +21,7 @@ contracts or create a second provider abstraction. mobile application. - The event stream is volatile. Events can be lost during disconnection, background suspension, server restart, or overflow. -- The installed client is `0.0.0-beta-18050`. Recheck the `@beta` tag before +- The installed client is `0.0.0-beta-18387`. Recheck the `@beta` tag before each integration milestone, but do not reject a server only because its application version differs. - A server reachable at `127.0.0.1` on a development computer is not reachable diff --git a/packages/opencode-adapter/package.json b/packages/opencode-adapter/package.json index 9f4bed8..fc82f42 100644 --- a/packages/opencode-adapter/package.json +++ b/packages/opencode-adapter/package.json @@ -17,7 +17,7 @@ "typecheck": "tsc -p tsconfig.json" }, "dependencies": { - "@opencode-ai/client": "0.0.0-beta-18050" + "@opencode-ai/client": "0.0.0-beta-18387" }, "devDependencies": { "@opencode2-mobile/test-fixtures": "workspace:*", diff --git a/packages/opencode-adapter/src/index.test.ts b/packages/opencode-adapter/src/index.test.ts index 6b95018..4338fcf 100644 --- a/packages/opencode-adapter/src/index.test.ts +++ b/packages/opencode-adapter/src/index.test.ts @@ -11,6 +11,7 @@ import { createOpenCodeClient, createOpenCodeSession, createRedirectSafeOpenCodeFetch, + findOpenCodeFiles, getCurrentOpenCodeProject, getDefaultOpenCodeAgent, getDefaultOpenCodeLocation, @@ -24,6 +25,7 @@ import { interruptOpenCodeSession, listActiveOpenCodeSessions, listOpenCodeAgents, + listOpenCodeCommands, listOpenCodeFormRequests, listOpenCodeMessages, listOpenCodeModels, @@ -31,6 +33,7 @@ import { listOpenCodeProjectSessions, listOpenCodeSessionInbox, listOpenCodeSessions, + listOpenCodeSkills, normalizeOpenCodeBaseUrl, openEventStreamGeneration, probeEventStream, @@ -42,6 +45,7 @@ import { renameOpenCodeSession, replyOpenCodeForm, replyOpenCodePermissionRequest, + runOpenCodeSessionCommand, startEventStreamProbe, steerOpenCodeSessionInboxItem, switchOpenCodeSessionAgent, @@ -480,6 +484,155 @@ it("validates and forwards location-scoped agent and model choices", async () => ); }); +it("lists location-scoped commands and skills through the generated client", async () => { + const api = createFakeOpenCodeApi({ + commands: [{ description: "Review changes", name: "review" }], + location: { + directory: "/workspace", + project: { canonical: "/workspace", directory: "/workspace", id: "project-1" }, + workspaceID: "wrk_test", + }, + skills: [ + { + content: "Skill content", + id: "release", + location: "/workspace/.opencode/skills/release.md", + name: "Release", + slash: true, + }, + ], + }); + const client = createOpenCodeClient({ baseUrl: "https://fake.invalid", fetch: api.fetch }); + const location = { directory: "/workspace", workspaceID: "wrk_test" }; + + await expect(listOpenCodeCommands(client, location)).resolves.toMatchObject({ + data: [{ name: "review" }], + }); + await expect(listOpenCodeSkills(client, location)).resolves.toMatchObject({ + data: [{ id: "release", slash: true }], + }); + expect(api.requests.slice(-2).map((request) => request.query)).toEqual([ + { + "location[directory]": ["/workspace"], + "location[workspace]": ["wrk_test"], + }, + { + "location[directory]": ["/workspace"], + "location[workspace]": ["wrk_test"], + }, + ]); + + await runOpenCodeSessionCommand(client, "ses_test", { + command: "review", + delivery: "queue", + text: "src/index.ts", + }); + expect(api.requests.at(-1)).toMatchObject({ + jsonBody: { + command: "review", + delivery: "queue", + text: "src/index.ts", + }, + path: "/api/session/ses_test/command", + }); + + await expect(interruptOpenCodeSession(client, "ses_test", false)).resolves.toEqual({ + interrupted: true, + }); + expect(api.requests.at(-1)).toMatchObject({ + path: "/api/session/ses_test/interrupt", + query: { continue: ["false"] }, + }); +}); + +it("finds bounded files at an exact location", async () => { + const api = createFakeOpenCodeApi({ + files: [{ path: "src/index.ts", type: "file" }], + }); + const client = createOpenCodeClient({ baseUrl: "https://fake.invalid", fetch: api.fetch }); + + await expect( + findOpenCodeFiles(client, { directory: "/workspace", workspaceID: "wrk_test" }, "index", { + limit: 20, + }), + ).resolves.toMatchObject({ data: [{ path: "src/index.ts" }] }); + expect(api.requests.at(-1)).toMatchObject({ + path: "/api/fs/find", + query: { + limit: ["20"], + "location[directory]": ["/workspace"], + "location[workspace]": ["wrk_test"], + query: ["index"], + type: ["file"], + }, + }); +}); + +it("rejects malformed command and skill catalogs", async () => { + const location = { + directory: "/workspace", + project: { canonical: "/workspace", directory: "/workspace", id: "project-1" }, + }; + const client = { + command: { list: vi.fn(async () => ({ data: [{ name: "" }], location })) }, + skill: { + list: vi.fn(async () => ({ data: [{ id: "release", name: "Release" }], location })), + }, + } as unknown as ReturnType; + + await expect(listOpenCodeCommands(client, { directory: "/workspace" })).rejects.toThrow( + "MALFORMED_COMMAND_LIST", + ); + await expect(listOpenCodeSkills(client, { directory: "/workspace" })).rejects.toThrow( + "MALFORMED_SKILL_LIST", + ); +}); + +it("rejects malformed or over-limit file search results", async () => { + const location = { + directory: "/workspace", + project: { canonical: "/workspace", directory: "/workspace", id: "project-1" }, + }; + const client = { + file: { + find: vi + .fn() + .mockResolvedValueOnce({ data: [{ path: "src" }], location }) + .mockResolvedValueOnce({ + data: [ + { path: "one.ts", type: "file" }, + { path: "two.ts", type: "file" }, + ], + location, + }), + }, + } as unknown as ReturnType; + + await expect(findOpenCodeFiles(client, { directory: "/workspace" }, "src")).rejects.toThrow( + "MALFORMED_FILE_FIND", + ); + await expect( + findOpenCodeFiles(client, { directory: "/workspace" }, "src", { limit: 1 }), + ).rejects.toThrow("MALFORMED_FILE_FIND"); +}); + +it.each(["../secret.txt", "src/../../secret.txt", "/etc/passwd", "C:/outside.txt", "//host/share"])( + "rejects an out-of-location file search path: %s", + async (path) => { + const location = { + directory: "/workspace", + project: { canonical: "/workspace", directory: "/workspace", id: "project-1" }, + }; + const client = { + file: { find: vi.fn(async () => ({ data: [{ path, type: "file" }], location })) }, + } as unknown as ReturnType; + + await expect(findOpenCodeFiles(client, { directory: "/workspace" }, "file")).rejects.toThrow( + "MALFORMED_FILE_FIND", + ); + }, +); + it("resolves the highest-priority configured default agent", async () => { const api = createFakeOpenCodeApi({ configEntries: [ @@ -517,7 +670,7 @@ it("returns null when no config document defines a default agent", async () => { await expect(getDefaultOpenCodeAgent(client, { directory: "/workspace" })).resolves.toBeNull(); }); -it("forwards composer and execution operations and returns generated inbox values", async () => { +it("forwards composer and execution operations", async () => { const item = { delivery: "queue" as const, id: "msg_admission", @@ -529,6 +682,7 @@ it("forwards composer and execution operations and returns generated inbox value const switchAgent = vi.fn(async () => undefined); const switchModel = vi.fn(async () => undefined); const prompt = vi.fn(async () => item); + const command = vi.fn(async () => undefined); const permissionReply = vi.fn(async () => undefined); const list = vi.fn(async () => [item]); const projectedMessage = { @@ -541,13 +695,14 @@ it("forwards composer and execution operations and returns generated inbox value const cancel = vi.fn(async () => undefined); const steer = vi.fn(async () => undefined); const queue = vi.fn(async () => undefined); - const interrupt = vi.fn(async () => undefined); + const interrupt = vi.fn(async () => ({ interrupted: true })); const background = vi.fn(async () => undefined); const wait = vi.fn(async () => undefined); const client = { permission: { reply: permissionReply }, session: { background, + command, inbox: { cancel, list, queue, steer }, interrupt, message, @@ -570,6 +725,18 @@ it("forwards composer and execution operations and returns generated inbox value options, ), ).resolves.toBe(item); + await expect( + runOpenCodeSessionCommand( + client, + "ses_test", + { + command: "review", + delivery: "queue", + text: "src unicode-æ", + }, + options, + ), + ).resolves.toBeUndefined(); await expect(listOpenCodeSessionInbox(client, "ses_test", options)).resolves.toEqual([item]); await expect( getOpenCodeSessionMessage(client, "ses_test", "msg_admission", options), @@ -577,7 +744,9 @@ it("forwards composer and execution operations and returns generated inbox value await cancelOpenCodeSessionInboxItem(client, "ses_test", "msg_admission", options); await steerOpenCodeSessionInboxItem(client, "ses_test", "msg_admission", options); await queueOpenCodeSessionInboxItem(client, "ses_test", "msg_admission", options); - await interruptOpenCodeSession(client, "ses_test", true, options); + await expect(interruptOpenCodeSession(client, "ses_test", true, options)).resolves.toEqual({ + interrupted: true, + }); await backgroundOpenCodeSession(client, "ses_test", options); await waitForOpenCodeSession(client, "ses_test", options); await replyOpenCodePermissionRequest(client, "ses_test", "per_test", "once", options); @@ -588,6 +757,15 @@ it("forwards composer and execution operations and returns generated inbox value { delivery: "queue", id: "msg_admission", sessionID: "ses_test", text: "Hello" }, options, ); + expect(command).toHaveBeenCalledWith( + { + command: "review", + delivery: "queue", + sessionID: "ses_test", + text: "src unicode-æ", + }, + options, + ); expect(list).toHaveBeenCalledWith({ sessionID: "ses_test" }, options); expect(message).toHaveBeenCalledWith( { messageID: "msg_admission", sessionID: "ses_test" }, @@ -785,9 +963,24 @@ it("recognizes an unauthorized error wrapped by the generated client", () => { expect( classifyOpenCodeError(new Error("transport", { cause: { _tag: "SessionNotFoundError" } })), ).toBe("NOT_FOUND"); + expect( + classifyOpenCodeError(new Error("transport", { cause: { _tag: "ProjectNotFoundError" } })), + ).toBe("NOT_FOUND"); expect( classifyOpenCodeError(new Error("transport", { cause: { _tag: "FormNotFoundError" } })), ).toBe("NOT_FOUND"); + expect( + classifyOpenCodeError(new Error("transport", { cause: { _tag: "CommandNotFoundError" } })), + ).toBe("NOT_FOUND"); + expect( + classifyOpenCodeError(new Error("transport", { cause: { _tag: "SkillNotFoundError" } })), + ).toBe("NOT_FOUND"); + expect( + classifyOpenCodeError(new Error("transport", { cause: { _tag: "CommandEvaluationError" } })), + ).toBe("INVALID_REQUEST"); + expect( + classifyOpenCodeError(new Error("transport", { cause: { _tag: "CommandExecutionError" } })), + ).toBe("INVALID_REQUEST"); expect( classifyOpenCodeError(new Error("transport", { cause: { _tag: "MessageNotFoundError" } })), ).toBe("MESSAGE_NOT_FOUND"); diff --git a/packages/opencode-adapter/src/index.ts b/packages/opencode-adapter/src/index.ts index 180c83a..3f93302 100644 --- a/packages/opencode-adapter/src/index.ts +++ b/packages/opencode-adapter/src/index.ts @@ -1,5 +1,7 @@ import { + type CommandInfo, type FileDiffInfo, + type FileSystemEntry, type FormAnswer, type FormState, type LocationGetOutput, @@ -15,9 +17,10 @@ import { type SessionMessageInfo, type SessionMessagesResponse, type SessionsResponse, + type SkillInfo, } from "@opencode-ai/client"; -export const openCodeClientContractVersion = "0.0.0-beta-18050"; +export const openCodeClientContractVersion = "0.0.0-beta-18387"; export type OpenCodeClientOptions = { authorization?: string; @@ -150,6 +153,13 @@ export type OpenCodeSessionPromptOptions = Omit< delivery: SessionInboxDelivery; id: string; }; +type GeneratedSessionCommandInput = Parameters[0]; +export type OpenCodeSessionCommandOptions = Omit< + GeneratedSessionCommandInput, + "command" | "sessionID" +> & { + command: string; +}; export async function getDefaultOpenCodeLocation( client: OpenCodeClient, @@ -231,6 +241,19 @@ export async function listOpenCodeAgents( return output; } +export async function listOpenCodeCommands( + client: OpenCodeClient, + location: LocationRef, + options?: OpenCodeRequestOptions, +) { + const output = await client.command.list({ location: locationInput(location) }, options); + validateResolvedLocation(output.location); + if (!Array.isArray(output.data) || !output.data.every(isValidCommand)) { + throw new Error("MALFORMED_COMMAND_LIST"); + } + return output; +} + export async function getDefaultOpenCodeAgent( client: OpenCodeClient, location: LocationRef, @@ -271,6 +294,41 @@ export async function listOpenCodeModels( return output; } +export async function listOpenCodeSkills( + client: OpenCodeClient, + location: LocationRef, + options?: OpenCodeRequestOptions, +) { + const output = await client.skill.list({ location: locationInput(location) }, options); + validateResolvedLocation(output.location); + if (!Array.isArray(output.data) || !output.data.every(isValidSkill)) { + throw new Error("MALFORMED_SKILL_LIST"); + } + return output; +} + +export async function findOpenCodeFiles( + client: OpenCodeClient, + location: LocationRef, + query: string, + options?: OpenCodeRequestOptions & { limit?: number }, +) { + const limit = Math.min(Math.max(options?.limit ?? 20, 1), 100); + const output = await client.file.find( + { limit, location: locationInput(location), query, type: "file" }, + options?.signal ? { signal: options.signal } : undefined, + ); + validateResolvedLocation(output.location); + if ( + !Array.isArray(output.data) || + output.data.length > limit || + !output.data.every((entry) => isValidFileSystemEntry(entry) && entry.type === "file") + ) { + throw new Error("MALFORMED_FILE_FIND"); + } + return output; +} + export async function getDefaultOpenCodeModel( client: OpenCodeClient, location: LocationRef, @@ -444,6 +502,15 @@ export async function promptOpenCodeSession( return inbox; } +export async function runOpenCodeSessionCommand( + client: OpenCodeClient, + sessionID: string, + input: OpenCodeSessionCommandOptions, + options?: OpenCodeRequestOptions, +) { + await client.session.command({ ...input, sessionID }, options); +} + export async function listOpenCodeSessionInbox( client: OpenCodeClient, sessionID: string, @@ -925,6 +992,37 @@ function isValidAgent(agent: unknown) { ); } +function isValidCommand(command: unknown): command is CommandInfo { + return ( + isRecord(command) && + typeof command.name === "string" && + Boolean(command.name) && + isOptionalString(command.description) && + Object.keys(command).every((key) => key === "name" || key === "description") + ); +} + +function isValidFileSystemEntry(entry: unknown): entry is FileSystemEntry { + return ( + isRecord(entry) && + typeof entry.path === "string" && + isSafeRelativeFilePath(entry.path) && + (entry.type === "file" || entry.type === "directory") + ); +} + +function isSafeRelativeFilePath(path: string) { + const normalized = path.replaceAll("\\", "/"); + return ( + Boolean(normalized) && + !normalized.startsWith("/") && + !/^[A-Za-z]:\//.test(normalized) && + normalized + .split("/") + .every((segment) => Boolean(segment) && segment !== "." && segment !== "..") + ); +} + function isValidModel(model: unknown) { return ( isRecord(model) && @@ -945,6 +1043,21 @@ function isValidModel(model: unknown) { ); } +function isValidSkill(skill: unknown): skill is SkillInfo { + return ( + isRecord(skill) && + typeof skill.id === "string" && + Boolean(skill.id) && + typeof skill.name === "string" && + Boolean(skill.name) && + isOptionalString(skill.description) && + (skill.slash === undefined || typeof skill.slash === "boolean") && + (skill.autoinvoke === undefined || typeof skill.autoinvoke === "boolean") && + typeof skill.location === "string" && + typeof skill.content === "string" + ); +} + function isValidSessionInboxInfo(value: unknown): value is SessionInboxInfo { if ( !isRecord(value) || @@ -1164,7 +1277,7 @@ function assertSessionAndFormIds(sessionID: string, formID: string) { } function isValidFormOwner(value: unknown) { - // Beta 18050 temporarily uses "global" for MCP elicitations without a session owner. + // MCP elicitations can use "global" when no session owns the form. return typeof value === "string" && (value === "global" || /^ses/.test(value)); } @@ -1227,9 +1340,21 @@ export function classifyOpenCodeError(error: unknown) { ) { return "INVALID_REQUEST" as const; } - if (hasTagInCause(error, "SessionNotFoundError") || hasTagInCause(error, "FormNotFoundError")) { + if ( + hasTagInCause(error, "SessionNotFoundError") || + hasTagInCause(error, "ProjectNotFoundError") || + hasTagInCause(error, "FormNotFoundError") || + hasTagInCause(error, "CommandNotFoundError") || + hasTagInCause(error, "SkillNotFoundError") + ) { return "NOT_FOUND" as const; } + if ( + hasTagInCause(error, "CommandEvaluationError") || + hasTagInCause(error, "CommandExecutionError") + ) { + return "INVALID_REQUEST" as const; + } if (hasTagInCause(error, "MessageNotFoundError")) return "MESSAGE_NOT_FOUND" as const; if (hasAbortCause(error)) return "TIMEOUT" as const; if (hasTlsFailure(error)) return "TLS" as const; @@ -1694,7 +1819,9 @@ export type OpenCodeClient = ReturnType; export type { AgentInfo, AgentListOutput, + CommandInfo, FileDiffInfo, + FileSystemEntry, FormAnswer, FormField, FormInfo, @@ -1720,5 +1847,6 @@ export type { SessionMessageInfo, SessionMessagesResponse, SessionsResponse, + SkillInfo, } from "@opencode-ai/client"; export type { OpenCodeEvent }; diff --git a/packages/opencode-notification-plugin/package.json b/packages/opencode-notification-plugin/package.json index 4118255..b308f9a 100644 --- a/packages/opencode-notification-plugin/package.json +++ b/packages/opencode-notification-plugin/package.json @@ -21,7 +21,7 @@ "typecheck": "tsc -p tsconfig.json" }, "dependencies": { - "@opencode-ai/plugin": "0.0.0-beta-18050", + "@opencode-ai/plugin": "0.0.0-beta-18387", "@opencode2-mobile/notification-protocol": "workspace:*" }, "devDependencies": { diff --git a/packages/test-fixtures/src/index.ts b/packages/test-fixtures/src/index.ts index bce94b3..32104d3 100644 --- a/packages/test-fixtures/src/index.ts +++ b/packages/test-fixtures/src/index.ts @@ -1,8 +1,10 @@ export type FakeOpenCodeApiOptions = { agents?: unknown[]; + commands?: unknown[]; configEntries?: unknown[]; eventFrame?: string; failures?: Record; + files?: unknown[]; forms?: unknown[]; location?: { directory: string; @@ -16,6 +18,7 @@ export type FakeOpenCodeApiOptions = { permissions?: unknown[]; projects?: unknown[]; sessions?: FakeSession[]; + skills?: unknown[]; vcs?: unknown; vcsDiff?: unknown[]; }; @@ -111,18 +114,27 @@ export function createFakeOpenCodeApi(options: FakeOpenCodeApiOptions = {}) { if (url.pathname === "/api/agent") { return json({ location: resolvedLocation(options, url), data: options.agents ?? [] }); } + if (url.pathname === "/api/command") { + return json({ location: resolvedLocation(options, url), data: options.commands ?? [] }); + } if (url.pathname === "/api/config") { return json(options.configEntries ?? []); } if (url.pathname === "/api/model") { return json({ location: resolvedLocation(options, url), data: options.models ?? [] }); } + if (url.pathname === "/api/skill") { + return json({ location: resolvedLocation(options, url), data: options.skills ?? [] }); + } if (url.pathname === "/api/permission/request") { return json({ location: resolvedLocation(options, url), data: options.permissions ?? [] }); } if (url.pathname === "/api/form/request") { return json({ location: resolvedLocation(options, url), data: pendingForms }); } + if (url.pathname === "/api/fs/find") { + return json({ location: resolvedLocation(options, url), data: options.files ?? [] }); + } const formMatch = url.pathname.match( /^\/api\/session\/([^/]+)\/form\/([^/]+)\/(state|reply|cancel)$/, ); @@ -253,6 +265,14 @@ export function createFakeOpenCodeApi(options: FakeOpenCodeApiOptions = {}) { data, }); } + const commandMatch = url.pathname.match(/^\/api\/session\/([^/]+)\/command$/); + if (commandMatch && method === "POST") { + return new Response(null, { status: 204 }); + } + const interruptMatch = url.pathname.match(/^\/api\/session\/([^/]+)\/interrupt$/); + if (interruptMatch && method === "POST") { + return json({ interrupted: true }); + } const sessionMatch = url.pathname.match(/^\/api\/session\/([^/]+)$/); if (sessionMatch && method === "GET") { const session = sessions.find((candidate) => candidate.id === sessionMatch[1]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5210420..8c39b50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,8 +197,8 @@ importers: packages/opencode-adapter: dependencies: '@opencode-ai/client': - specifier: 0.0.0-beta-18050 - version: 0.0.0-beta-18050(effect@4.0.0-rc.111) + specifier: 0.0.0-beta-18387 + version: 0.0.0-beta-18387(effect@4.0.0-rc.111) devDependencies: '@opencode2-mobile/test-fixtures': specifier: workspace:* @@ -213,8 +213,8 @@ importers: packages/opencode-notification-plugin: dependencies: '@opencode-ai/plugin': - specifier: 0.0.0-beta-18050 - version: 0.0.0-beta-18050(supports-color@8.1.1) + specifier: 0.0.0-beta-18387 + version: 0.0.0-beta-18387(supports-color@8.1.1) '@opencode2-mobile/notification-protocol': specifier: workspace:* version: link:../notification-protocol @@ -1324,11 +1324,11 @@ packages: resolution: {integrity: sha512-wH5EHOmLi0rEazphPbecAzmjd12I6/Yv/SiHdkA9LSycsQk7RuuTp7am5/o62qYr0RScE7Pc9icXGBbsr6cesA==} engines: {node: ^14.21.3 || >=16} - '@opencode-ai/ai@0.0.0-beta-18050': - resolution: {integrity: sha512-PkUeZ1eN5Z4a5lWUqsD+nBmVcsLiz5gxnG83Vicqug13U6WBC/nV80is696/QfL0C/JYFVPQ4lTeq0q5+pSK3w==} + '@opencode-ai/ai@0.0.0-beta-18387': + resolution: {integrity: sha512-EVwwZWppdj/a702ub7weQz7TLH3U7CZV6pMFW2iAyRu5/Vu8nt8jKCK+1d1th7awZcbhsowj9c/ZzLlD09nPaA==} - '@opencode-ai/client@0.0.0-beta-18050': - resolution: {integrity: sha512-zWZv5X23iyx+/mxwiAi18YY/VMjQofTaH7RyKMBt7KL6FmaKAWf9Q05zbQhrLX8DYJD3MOtbjBeQCGLsPUDU8g==} + '@opencode-ai/client@0.0.0-beta-18387': + resolution: {integrity: sha512-vr+PS4A06MLtnzG1rLY9azAOkaA2EdvsCtvGYU8DnJPQEPHQaPAUFSbAxcx9ngZW9cAImFJ52P3NDgWyA0RJHA==} peerDependencies: effect: 4.0.0-rc.111 solid-js: '>=1.9.0' @@ -1338,12 +1338,12 @@ packages: solid-js: optional: true - '@opencode-ai/plugin@0.0.0-beta-18050': - resolution: {integrity: sha512-Y5xOXdhlNSFf+AfzE0wVKcwY7jFWRS/nlhR4MOSaeFwaN34k9ZBx19jUpetNwSTMxz5K1YzJJWsLrA1NjdHe+A==} + '@opencode-ai/plugin@0.0.0-beta-18387': + resolution: {integrity: sha512-ChIMWSMzQd5Cfy7bwhj4QAM8w5sBN126yWmMNc6rYsGk4MQVgx/8ERoOD7EhUIiey3qy8qpqHXtiCVTqZsw0sg==} peerDependencies: - '@opencode-ai/theme': 0.0.0-beta-18050 - '@opentui/core': '>=0.5.7' - '@opentui/solid': '>=0.5.7' + '@opencode-ai/theme': 0.0.0-beta-18387 + '@opentui/core': '>=0.5.8' + '@opentui/solid': '>=0.5.8' solid-js: '>=1.9.0' peerDependenciesMeta: '@opencode-ai/theme': @@ -1355,11 +1355,11 @@ packages: solid-js: optional: true - '@opencode-ai/protocol@0.0.0-beta-18050': - resolution: {integrity: sha512-HDQMnvGp8IU0MdBRbEuydX1WQm09BZ4HJm9iSMQwzweJuQ2HNscgzHJPIH6P02BsbbtfJ8J7sZGPItrz1tWSgw==} + '@opencode-ai/protocol@0.0.0-beta-18387': + resolution: {integrity: sha512-8L5qRSTesAp3SMEMxIw0ahGwO1K+RirdCtWkw1wIO2UiRzhjJi4P1M/25ZvEJ8Z88zrwcxuYFMN3cwfsaCoKpw==} - '@opencode-ai/schema@0.0.0-beta-18050': - resolution: {integrity: sha512-/D6VXaWlytTXR3IOiMLIKuPcfp7FQNUzRPm9z3K7UBFd1Bw4q/WZksaf5RVcBGz+0YRxYMc1V4D7MFlceSgtyg==} + '@opencode-ai/schema@0.0.0-beta-18387': + resolution: {integrity: sha512-of3chc9a4eQ24z9gTct10kBlhycZ+cI3JeIl51ideHVySY5aQLbGBo5SL7NLiuC7pu2kUY5OdmLjPDnLXAskzg==} '@oxc-project/types@0.143.0': resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} @@ -6345,9 +6345,9 @@ snapshots: '@noble/ciphers@1.0.0': {} - '@opencode-ai/ai@0.0.0-beta-18050(supports-color@8.1.1)': + '@opencode-ai/ai@0.0.0-beta-18387(supports-color@8.1.1)': dependencies: - '@opencode-ai/schema': 0.0.0-beta-18050 + '@opencode-ai/schema': 0.0.0-beta-18387 '@smithy/eventstream-codec': 4.2.14 '@smithy/util-utf8': 4.2.2 aws4fetch: 1.0.20 @@ -6356,32 +6356,32 @@ snapshots: transitivePeerDependencies: - supports-color - '@opencode-ai/client@0.0.0-beta-18050(effect@4.0.0-rc.111)': + '@opencode-ai/client@0.0.0-beta-18387(effect@4.0.0-rc.111)': dependencies: - '@opencode-ai/protocol': 0.0.0-beta-18050 - '@opencode-ai/schema': 0.0.0-beta-18050 + '@opencode-ai/protocol': 0.0.0-beta-18387 + '@opencode-ai/schema': 0.0.0-beta-18387 optionalDependencies: effect: 4.0.0-rc.111 - '@opencode-ai/plugin@0.0.0-beta-18050(supports-color@8.1.1)': + '@opencode-ai/plugin@0.0.0-beta-18387(supports-color@8.1.1)': dependencies: '@ai-sdk/provider': 3.0.8 - '@opencode-ai/ai': 0.0.0-beta-18050(supports-color@8.1.1) - '@opencode-ai/client': 0.0.0-beta-18050(effect@4.0.0-rc.111) - '@opencode-ai/protocol': 0.0.0-beta-18050 - '@opencode-ai/schema': 0.0.0-beta-18050 + '@opencode-ai/ai': 0.0.0-beta-18387(supports-color@8.1.1) + '@opencode-ai/client': 0.0.0-beta-18387(effect@4.0.0-rc.111) + '@opencode-ai/protocol': 0.0.0-beta-18387 + '@opencode-ai/schema': 0.0.0-beta-18387 '@standard-schema/spec': 1.1.0 effect: 4.0.0-rc.111 zod: 4.1.8 transitivePeerDependencies: - supports-color - '@opencode-ai/protocol@0.0.0-beta-18050': + '@opencode-ai/protocol@0.0.0-beta-18387': dependencies: - '@opencode-ai/schema': 0.0.0-beta-18050 + '@opencode-ai/schema': 0.0.0-beta-18387 effect: 4.0.0-rc.111 - '@opencode-ai/schema@0.0.0-beta-18050': + '@opencode-ai/schema@0.0.0-beta-18387': dependencies: '@standard-schema/spec': 1.1.0 effect: 4.0.0-rc.111 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dfc185b..15b402e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,8 +24,10 @@ minimumReleaseAgeExclude: - '@biomejs/cli-linux-x64@2.5.8' - '@biomejs/cli-win32-arm64@2.5.8' - '@biomejs/cli-win32-x64@2.5.8' - - '@opencode-ai/client@0.0.0-beta-18050' - - '@opencode-ai/protocol@0.0.0-beta-18050' - - '@opencode-ai/schema@0.0.0-beta-18050' + - '@opencode-ai/client@0.0.0-beta-18387' + - '@opencode-ai/ai@0.0.0-beta-18387' + - '@opencode-ai/plugin@0.0.0-beta-18387' + - '@opencode-ai/protocol@0.0.0-beta-18387' + - '@opencode-ai/schema@0.0.0-beta-18387' - '@tanstack/query-core@5.102.2' - '@tanstack/react-query@5.102.2'