Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/app/src/utils/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,16 @@ describe("extractPromptFromMessage", () => {

expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "model text" })
})

test("restores command invocation text", () => {
const message = {
id: "msg_1",
type: "user",
text: "expanded command template",
command: { name: "command", arguments: "input" },
time: { created: 1 },
} satisfies SessionMessageUser

expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "/command input" })
})
})
4 changes: 3 additions & 1 deletion packages/app/src/utils/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ export function extractPromptFromMessage(
message: SessionMessageUser,
opts?: { directory?: string; attachmentName?: string },
): Prompt {
const text = readPromptPresentation(message.metadata)?.displayText ?? message.text
const text = message.command
? `/${message.command.name}${message.command.arguments ? ` ${message.command.arguments}` : ""}`
: (readPromptPresentation(message.metadata)?.displayText ?? message.text)
const directory = opts?.directory
const attachmentName = opts?.attachmentName ?? "attachment"
const toRelative = (path: string) => {
Expand Down
12 changes: 12 additions & 0 deletions packages/app/src/utils/session-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ describe("session message presentation", () => {
})
})

test("projects command invocation text", () => {
const message = {
id: "msg_user",
type: "user",
text: "expanded command template",
command: { name: "command", arguments: "input" },
time: { created: 1 },
} satisfies SessionMessageUser

expect(presentUserParts("ses_1", message)[0]).toMatchObject({ type: "text", text: "/command input" })
})

test("projects current assistant content for existing DOM tools", () => {
const message = {
id: "msg_assistant",
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/utils/session-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ export function presentUserMessage(

export function presentUserParts(sessionID: string, message: SessionMessageUser): Part[] {
const presentation = readPromptPresentation(message.metadata)
const text = presentation?.displayText ?? message.text
const text = message.command
? `/${message.command.name}${message.command.arguments ? ` ${message.command.arguments}` : ""}`
: (presentation?.displayText ?? message.text)
return [
...(text ? [textPart(sessionID, message.id, 0, text)] : []),
...(message.files ?? []).map(
Expand Down
8 changes: 8 additions & 0 deletions packages/client/src/promise/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export type FileDiffInfo = {
status: "added" | "deleted" | "modified"
}

export type PromptCommandInvocation = { name: string; arguments: string }

export type PromptBase64 = string

export type PromptFileSource = { type: "inline" } | { type: "uri"; uri: string }
Expand Down Expand Up @@ -1684,6 +1686,7 @@ export type SessionMessageUser = {
metadata?: { [x: string]: JsonValue }
time: { created: number }
text: string
command?: PromptCommandInvocation
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
skills?: Array<PromptSkillAttachment>
Expand All @@ -1692,6 +1695,7 @@ export type SessionMessageUser = {

export type SessionInboxUserPayload = {
text: string
command?: PromptCommandInvocation
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
skills?: Array<PromptSkillAttachment>
Expand All @@ -1700,6 +1704,7 @@ export type SessionInboxUserPayload = {

export type SessionInboxUserPayload1 = {
text: string
command?: PromptCommandInvocation
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
skills?: Array<PromptSkillAttachment>
Expand Down Expand Up @@ -2552,6 +2557,7 @@ export type SessionImportInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly text: string
readonly command?: { readonly name: string; readonly arguments: string }
readonly files?: ReadonlyArray<{
readonly data: string
readonly mime: string
Expand Down Expand Up @@ -2821,6 +2827,7 @@ export type SessionImportInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly text: string
readonly command?: { readonly name: string; readonly arguments: string }
readonly files?: ReadonlyArray<{
readonly data: string
readonly mime: string
Expand Down Expand Up @@ -3090,6 +3097,7 @@ export type SessionImportInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly text: string
readonly command?: { readonly name: string; readonly arguments: string }
readonly files?: ReadonlyArray<{
readonly data: string
readonly mime: string
Expand Down
18 changes: 11 additions & 7 deletions packages/core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ export interface Interface {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
text: string
command?: Prompt["command"]
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
Expand Down Expand Up @@ -586,11 +587,7 @@ const layer = Layer.effect(
return yield* Image.Service
}).pipe(Effect.provide(locations.get(session.location)))
const skills = Skill.Service.pipe(Effect.provide(locations.get(session.location)))
const prompt = yield* resolvePrompt(
{ text: input.text, files: input.files, agents: input.agents, skills: input.skills },
image,
skills,
).pipe(Effect.provideService(FSUtil.Service, fs))
const prompt = yield* resolvePrompt(input, image, skills).pipe(Effect.provideService(FSUtil.Service, fs))
const messageID = input.id ?? SessionMessage.ID.create()
const admittedInput = SessionInbox.Item.make({
type: "user",
Expand Down Expand Up @@ -657,6 +654,7 @@ const layer = Layer.effect(
id: input.id,
sessionID: input.sessionID,
text: evaluated.text,
command: { name: input.command, arguments: input.arguments ?? "" },
files: input.files,
agents: input.agents,
skills: input.skills,
Expand Down Expand Up @@ -964,7 +962,7 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf
}

const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
input: PromptInput.Prompt,
input: PromptInput.Prompt & Pick<Prompt, "command">,
image: Effect.Effect<Image.Interface>,
skills: Effect.Effect<Skill.Interface>,
) {
Expand All @@ -987,7 +985,13 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
})
})
})
return Prompt.make({ text: input.text, agents: input.agents, files, skills: selected?.length ? selected : undefined })
return Prompt.fromUserMessage({
text: input.text,
command: input.command,
agents: input.agents,
files,
skills: selected?.length ? selected : undefined,
})
})

const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
Expand Down
20 changes: 9 additions & 11 deletions packages/core/src/session/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Money } from "@opencode-ai/schema/money"
import { Worktree } from "@opencode-ai/schema/worktree"
import { Project } from "@opencode-ai/schema/project"
import { Prompt } from "@opencode-ai/schema/prompt"
import { AbsolutePath, RelativePath } from "../schema.js"
import type { SessionSchema } from "./schema.js"

Expand Down Expand Up @@ -526,17 +527,14 @@ const layer = Layer.effectDiscard(
yield* insertMessage(
db,
event,
input.type === "user"
? {
id: input.id,
type: "user",
metadata: input.payload.metadata,
text: input.payload.text,
files: input.payload.files,
agents: input.payload.agents,
skills: input.payload.skills,
time: { created: DateTime.makeUnsafe(event.created) },
}
input.type === "user"
? {
...Prompt.fromUserMessage(input.payload),
id: input.id,
type: "user",
metadata: input.payload.metadata,
time: { created: DateTime.makeUnsafe(event.created) },
}
: {
id: input.id,
type: "synthetic",
Expand Down
16 changes: 14 additions & 2 deletions packages/core/test/session-projector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,11 @@ describe("SessionProjector", () => {
const admitted = yield* SessionInbox.admit(db, bus, {
id,
sessionID,
item: { type: "user", payload: { text: "promote me" }, delivery: "steer" },
item: {
type: "user",
payload: { text: "expanded command template", command: { name: "command", arguments: "input" } },
delivery: "steer",
},
})
if (!admitted) return yield* Effect.die("Prompt admission failed")

Expand All @@ -337,7 +341,15 @@ describe("SessionProjector", () => {
).toBeUndefined()
expect(
yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie),
).toMatchObject({ session_id: sessionID, type: "user", seq: event.durable?.seq })
).toMatchObject({
session_id: sessionID,
type: "user",
seq: event.durable?.seq,
data: {
text: "expanded command template",
command: { name: "command", arguments: "input" },
},
})
}),
)

Expand Down
4 changes: 3 additions & 1 deletion packages/core/test/session-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,16 +235,18 @@ describe("Session.prompt", () => {
const message = yield* session.prompt({
sessionID,
text: "Fix the failing tests",
command: { name: "fix", arguments: "tests" },
resume: false,
})

expect(message.payload.text).toBe("Fix the failing tests")
expect(message.payload.command).toEqual({ name: "fix", arguments: "tests" })
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admitted(message.id)).toMatchObject({
id: message.id,
sessionID,
type: "user",
payload: { text: "Fix the failing tests" },
payload: { text: "Fix the failing tests", command: { name: "fix", arguments: "tests" } },
delivery: "steer",
})
}),
Expand Down
10 changes: 9 additions & 1 deletion packages/schema/src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,16 @@ export const SkillAttachment = Schema.Struct({
mention: PromptMention.pipe(optional),
}).annotate({ identifier: "Prompt.SkillAttachment" })

export interface CommandInvocation extends Schema.Schema.Type<typeof CommandInvocation> {}
export const CommandInvocation = Schema.Struct({
name: Schema.String,
arguments: Schema.String,
}).annotate({ identifier: "Prompt.CommandInvocation" })

export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
export const Prompt = Schema.Struct({
text: Schema.String,
command: CommandInvocation.pipe(optional),
files: Schema.Array(FileAttachment).pipe(optional),
agents: Schema.Array(AgentAttachment).pipe(optional),
skills: Schema.Array(SkillAttachment).pipe(optional),
Expand All @@ -72,9 +79,10 @@ export const Prompt = Schema.Struct({
.pipe(
statics((schema) => ({
equivalence: Schema.toEquivalence(schema),
fromUserMessage: (input: Pick<Prompt, "text" | "files" | "agents" | "skills">) =>
fromUserMessage: (input: Pick<Prompt, "text" | "command" | "files" | "agents" | "skills">) =>
schema.make({
text: input.text,
...(input.command === undefined ? {} : { command: input.command }),
...(input.files === undefined ? {} : { files: input.files }),
...(input.agents === undefined ? {} : { agents: input.agents }),
...(input.skills === undefined ? {} : { skills: input.skills }),
Expand Down
5 changes: 1 addition & 4 deletions packages/schema/src/session-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,7 @@ export const LocationSwitched = Schema.Struct({
export interface User extends Schema.Schema.Type<typeof User> {}
export const User = Schema.Struct({
...Base,
text: Prompt.fields.text,
files: Prompt.fields.files,
agents: Prompt.fields.agents,
skills: Prompt.fields.skills,
...Prompt.fields,
type: Schema.tag("user"),
}).annotate({ identifier: "Session.Message.User" })

Expand Down
13 changes: 13 additions & 0 deletions packages/tui/src/mini/command.shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { StreamCommit } from "./types"
import { commandText } from "../util/command"

export function commandCommit(messageID: string | undefined, command: { name: string; arguments: string }): StreamCommit {
return {
kind: "system",
source: "system",
messageID,
partID: "command",
text: `→ Command "${commandText(command)}"`,
phase: "start",
}
}
18 changes: 11 additions & 7 deletions packages/tui/src/mini/runtime.queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Locale } from "../util/locale"
import { isCompactCommand, isExitCommand, isNewCommand } from "./prompt.shared"
import type { FooterApi, FooterEvent, RunDelivery, RunPrompt } from "./types"
import { commandCommit } from "./command.shared"

type Trace = {
write(type: string, data?: unknown): void
Expand Down Expand Up @@ -173,13 +174,16 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
}

if (sent.mode !== "shell") {
const commit = {
kind: "user",
text: sent.text,
phase: "start",
source: "system",
messageID: sent.messageID,
} as const
const commit =
sent.command && sent.command.source !== "skill"
? commandCommit(sent.messageID, sent.command)
: ({
kind: "user",
text: sent.text,
phase: "start",
source: "system",
messageID: sent.messageID,
} as const)
input.trace?.write("ui.commit", commit)
input.footer.append(commit)
}
Expand Down
23 changes: 13 additions & 10 deletions packages/tui/src/mini/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
resolveSessionInfo,
} from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle"
import { commandCommit } from "./command.shared"
import { cycleVariant, formatModelLabel, resolveVariant } from "./variant.shared"
import type {
LocalReplayRow,
Expand Down Expand Up @@ -903,13 +904,17 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
state.shown = true
state.history.push({ ...prompt, delivery: undefined })
if (prompt.mode !== "shell" && delivery === "steer") {
rememberLocal({
kind: "user",
text: prompt.text,
phase: "start",
source: "system",
messageID: prompt.messageID,
})
rememberLocal(
prompt.command && prompt.command.source !== "skill"
? commandCommit(prompt.messageID, prompt.command)
: {
kind: "user",
text: prompt.text,
phase: "start",
source: "system",
messageID: prompt.messageID,
},
)
}
},
admit: async (prompt, delivery, signal) => {
Expand Down Expand Up @@ -1044,9 +1049,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
admitted,
)
if (prompt.messageID) {
state.localRows = state.localRows.filter(
(row) => row.commit.kind !== "user" || row.commit.messageID !== prompt.messageID,
)
state.localRows = state.localRows.filter((row) => row.commit.messageID !== prompt.messageID)
}
// Shell and skill turns never send CLI file attachments; keep them
// pending for the next prompt-shaped turn.
Expand Down
3 changes: 2 additions & 1 deletion packages/tui/src/mini/session.shared.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import { promptCopy, promptSame } from "./prompt.shared"
import type { RunInput, RunPrompt } from "./types"
import { commandText } from "../util/command"

const LIMIT = 200

Expand All @@ -22,7 +23,7 @@ export type RunSession = {

function messagePrompt(message: SessionMessageUser): RunPrompt {
return {
text: message.text,
text: message.command ? commandText(message.command) : message.text,
parts: [
...(message.files ?? []).map((file) => ({
type: "file" as const,
Expand Down
Loading
Loading