chore: update merge branch with latest v2
This commit is contained in:
commit
7da1598830
45 changed files with 1062 additions and 731 deletions
|
|
@ -176,22 +176,22 @@ export async function resolveCurrentSession(
|
|||
parts: [
|
||||
...(message.files ?? []).map((file) => ({
|
||||
type: "file" as const,
|
||||
url: file.uri,
|
||||
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
mime: file.mime,
|
||||
filename: file.name,
|
||||
source: file.source
|
||||
source: file.mention
|
||||
? {
|
||||
type: "file" as const,
|
||||
path: file.name ?? file.uri,
|
||||
text: { start: file.source.start, end: file.source.end, value: file.source.text },
|
||||
path: file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment"),
|
||||
text: { start: file.mention.start, end: file.mention.end, value: file.mention.text },
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
...(message.agents ?? []).map((agent) => ({
|
||||
type: "agent" as const,
|
||||
name: agent.name,
|
||||
source: agent.source
|
||||
? { start: agent.source.start, end: agent.source.end, value: agent.source.text }
|
||||
source: agent.mention
|
||||
? { start: agent.mention.start, end: agent.mention.end, value: agent.mention.text }
|
||||
: undefined,
|
||||
})),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -42,5 +42,6 @@ export { SessionInput } from "@opencode-ai/schema/session-input"
|
|||
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
export { Skill } from "@opencode-ai/schema/skill"
|
||||
export { Prompt } from "@opencode-ai/schema/prompt"
|
||||
export { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||
export type OpenCodeClient = Effect.Success<ReturnType<typeof import("./generated/client").make>>
|
||||
|
|
|
|||
|
|
@ -477,7 +477,7 @@ export function make(options: ClientOptions) {
|
|||
path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`,
|
||||
body: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [409, 404, 400, 401],
|
||||
declaredStatuses: [409, 400, 404, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
|
|
@ -499,7 +499,7 @@ export function make(options: ClientOptions) {
|
|||
resume: input["resume"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [409, 404, 500, 400, 401],
|
||||
declaredStatuses: [409, 400, 404, 500, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
|
|
|
|||
|
|
@ -528,11 +528,11 @@ export type SessionPromptInput = {
|
|||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
}
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
|
|
@ -546,11 +546,11 @@ export type SessionPromptInput = {
|
|||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
}
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
|
|
@ -564,11 +564,11 @@ export type SessionPromptInput = {
|
|||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
}
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
|
|
@ -582,11 +582,11 @@ export type SessionPromptInput = {
|
|||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
}
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
|
|
@ -602,15 +602,16 @@ export type SessionPromptOutput = {
|
|||
readonly prompt: {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string }
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
|
|
@ -631,11 +632,11 @@ export type SessionCommandInput = {
|
|||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
|
|
@ -650,11 +651,11 @@ export type SessionCommandInput = {
|
|||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
|
|
@ -669,11 +670,11 @@ export type SessionCommandInput = {
|
|||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
|
|
@ -688,11 +689,11 @@ export type SessionCommandInput = {
|
|||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
|
|
@ -707,11 +708,11 @@ export type SessionCommandInput = {
|
|||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
|
|
@ -726,11 +727,11 @@ export type SessionCommandInput = {
|
|||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
|
|
@ -745,11 +746,11 @@ export type SessionCommandInput = {
|
|||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
|
|
@ -764,11 +765,11 @@ export type SessionCommandInput = {
|
|||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
|
|
@ -783,11 +784,11 @@ export type SessionCommandInput = {
|
|||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
|
|
@ -802,15 +803,16 @@ export type SessionCommandOutput = {
|
|||
readonly prompt: {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string }
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
|
|
@ -932,15 +934,16 @@ export type SessionContextOutput = {
|
|||
readonly time: { readonly created: number }
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string }
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly type: "user"
|
||||
}
|
||||
|
|
@ -1035,11 +1038,12 @@ export type SessionContextOutput = {
|
|||
readonly status: "completed"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly attachments?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string }
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
|
|
@ -1194,15 +1198,16 @@ export type SessionLogOutput =
|
|||
readonly prompt: {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string }
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
|
|
@ -1626,15 +1631,16 @@ export type SessionMessageOutput = {
|
|||
readonly time: { readonly created: number }
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string }
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly type: "user"
|
||||
}
|
||||
|
|
@ -1729,11 +1735,12 @@ export type SessionMessageOutput = {
|
|||
readonly status: "completed"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly attachments?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string }
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
|
|
@ -1826,15 +1833,16 @@ export type MessageListOutput = {
|
|||
readonly time: { readonly created: number }
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string }
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly type: "user"
|
||||
}
|
||||
|
|
@ -1929,11 +1937,12 @@ export type MessageListOutput = {
|
|||
readonly status: "completed"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly attachments?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string }
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
|
|
@ -4476,15 +4485,16 @@ export type EventSubscribeOutput =
|
|||
readonly prompt: {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string }
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { ProjectV2 } from "@opencode-ai/core/project"
|
|||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionInput as CoreSessionInput } from "@opencode-ai/core/session/input"
|
||||
import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Prompt as CorePrompt } from "@opencode-ai/core/session/prompt"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
|
|
@ -32,7 +31,6 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
|
|||
expect(ProjectV2.Directories).toBe(Project.Directories)
|
||||
expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted)
|
||||
expect(CoreSessionMessage.Message).toBe(SessionMessage.Message)
|
||||
expect(CorePrompt).toBe(Prompt)
|
||||
expect(Api.groups["server.session"].identifier).toBe("server.session")
|
||||
expect(Api.groups["server.project"].identifier).toBe("server.project")
|
||||
expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups))
|
||||
|
|
|
|||
34
packages/core/src/mime.ts
Normal file
34
packages/core/src/mime.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export * as Mime from "./mime.js"
|
||||
|
||||
export function detect(bytes: Uint8Array) {
|
||||
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
|
||||
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
|
||||
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
|
||||
if (startsWith(bytes, [0x42, 0x4d])) return "image/bmp"
|
||||
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
|
||||
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
|
||||
return "image/webp"
|
||||
if (
|
||||
startsWith(bytes.subarray(4), [0x66, 0x74, 0x79, 0x70]) &&
|
||||
(startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x66]) ||
|
||||
startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x73]))
|
||||
)
|
||||
return "image/avif"
|
||||
return isText(bytes) ? "text/plain" : "application/octet-stream"
|
||||
}
|
||||
|
||||
function startsWith(bytes: Uint8Array, prefix: number[]) {
|
||||
return prefix.every((value, index) => bytes[index] === value)
|
||||
}
|
||||
|
||||
function isText(bytes: Uint8Array) {
|
||||
if (bytes.length === 0) return true
|
||||
if (bytes.includes(0)) return false
|
||||
try {
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(bytes, { stream: true })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const controls = bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0)
|
||||
return controls / bytes.length <= 0.3
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import { WorkspaceV2 } from "./workspace"
|
|||
import { ModelV2 } from "./model"
|
||||
import { Location } from "./location"
|
||||
import { SessionMessage } from "./session/message"
|
||||
import { Prompt } from "./session/prompt"
|
||||
import { Base64, FileAttachment, Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { EventV2 } from "./event"
|
||||
import { Database } from "./database/database"
|
||||
|
|
@ -37,6 +37,7 @@ import { SessionCompaction } from "./session/compaction"
|
|||
import { SessionRevert } from "./session/revert"
|
||||
import { Revert } from "@opencode-ai/schema/revert"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Mime } from "./mime"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { Job } from "./job"
|
||||
|
|
@ -44,6 +45,7 @@ import { CommandV2 } from "./command"
|
|||
import { Shell } from "./shell"
|
||||
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
export const RevertState = Revert.State
|
||||
export type RevertState = Revert.State
|
||||
|
|
@ -119,6 +121,10 @@ export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictE
|
|||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
}) {}
|
||||
export class AttachmentError extends Schema.TaggedErrorClass<AttachmentError>()("Session.AttachmentError", {
|
||||
uri: Schema.String,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
|
|
@ -133,6 +139,7 @@ export type Error =
|
|||
| MessageDecodeError
|
||||
| OperationUnavailableError
|
||||
| PromptConflictError
|
||||
| AttachmentError
|
||||
| BusyError
|
||||
| SkillNotFoundError
|
||||
| CommandV2.NotFoundError
|
||||
|
|
@ -187,7 +194,7 @@ export interface Interface {
|
|||
prompt: PromptInput.Prompt
|
||||
delivery?: SessionInput.Delivery
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
|
||||
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError | AttachmentError>
|
||||
readonly command: (input: {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
|
|
@ -201,7 +208,7 @@ export interface Interface {
|
|||
resume?: boolean
|
||||
}) => Effect.Effect<
|
||||
SessionInput.Admitted,
|
||||
NotFoundError | PromptConflictError | CommandV2.NotFoundError | CommandV2.EvaluationError
|
||||
NotFoundError | PromptConflictError | AttachmentError | CommandV2.NotFoundError | CommandV2.EvaluationError
|
||||
>
|
||||
readonly shell: (input: {
|
||||
id?: EventV2.ID
|
||||
|
|
@ -251,6 +258,7 @@ const layer = Layer.effect(
|
|||
const execution = yield* SessionExecution.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const jobs = yield* Job.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const activeShells = new Set<SessionSchema.ID>()
|
||||
|
|
@ -456,7 +464,7 @@ const layer = Layer.effect(
|
|||
// continues from the reverted boundary rather than stale post-boundary history.
|
||||
if (session.revert)
|
||||
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
||||
const prompt = resolvePrompt(input.prompt)
|
||||
const prompt = yield* resolvePrompt(input.prompt).pipe(Effect.provideService(FSUtil.Service, fs))
|
||||
const messageID = input.id ?? SessionMessage.ID.create()
|
||||
const delivery = input.delivery ?? "steer"
|
||||
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
|
||||
|
|
@ -713,19 +721,110 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf
|
|||
}
|
||||
}
|
||||
|
||||
const resolvePrompt = (input: PromptInput.Prompt) =>
|
||||
Prompt.make({
|
||||
text: input.text,
|
||||
agents: input.agents,
|
||||
files: input.files?.map((file) => {
|
||||
const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1]
|
||||
const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri)
|
||||
return {
|
||||
...file,
|
||||
mime: dataMime ?? (target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target)),
|
||||
const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (input: PromptInput.Prompt) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const files = input.files
|
||||
? yield* Effect.forEach(
|
||||
input.files,
|
||||
(file) => materializeAttachment(fs, file),
|
||||
{ concurrency: 8 },
|
||||
)
|
||||
: undefined
|
||||
return Prompt.make({ text: input.text, agents: input.agents, files })
|
||||
})
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(function* (
|
||||
fs: FSUtil.Interface,
|
||||
input: PromptInput.FileAttachment,
|
||||
) {
|
||||
const resolved = input.uri.startsWith("data:")
|
||||
? {
|
||||
bytes: yield* decodeDataURL(input.uri),
|
||||
source: { type: "inline" as const },
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
name: undefined,
|
||||
}
|
||||
}),
|
||||
: yield* readFileAttachment(fs, input.uri)
|
||||
if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES)
|
||||
return yield* new AttachmentError({
|
||||
uri: input.uri,
|
||||
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${input.uri}`,
|
||||
})
|
||||
|
||||
const mime = Mime.detect(resolved.bytes)
|
||||
const content =
|
||||
mime === "text/plain" && resolved.start !== undefined
|
||||
? Buffer.from(
|
||||
Buffer.from(resolved.bytes).toString("utf8").split("\n").slice(resolved.start - 1, resolved.end).join("\n"),
|
||||
)
|
||||
: resolved.bytes
|
||||
return FileAttachment.create({
|
||||
data: Base64.make(Buffer.from(content).toString("base64")),
|
||||
mime,
|
||||
source: resolved.source,
|
||||
name: input.name ?? resolved.name,
|
||||
description: input.description,
|
||||
mention: input.mention,
|
||||
})
|
||||
})
|
||||
|
||||
const readFileAttachment = Effect.fn("V2Session.readFileAttachment")(function* (fs: FSUtil.Interface, uri: string) {
|
||||
const url = yield* Effect.try({
|
||||
try: () => new URL(uri),
|
||||
catch: () => new AttachmentError({ uri, message: `Invalid attachment URI: ${uri}` }),
|
||||
})
|
||||
if (url.protocol !== "file:")
|
||||
return yield* new AttachmentError({ uri, message: `Unsupported attachment URI: ${uri}` })
|
||||
const start = positiveInt(url.searchParams.get("start"))
|
||||
const end = positiveInt(url.searchParams.get("end"))
|
||||
const target = yield* Effect.try({
|
||||
try: () => {
|
||||
url.search = ""
|
||||
url.hash = ""
|
||||
return fileURLToPath(url)
|
||||
},
|
||||
catch: () => new AttachmentError({ uri, message: `Invalid file URI: ${uri}` }),
|
||||
})
|
||||
const info = yield* fs.stat(target).pipe(
|
||||
Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })),
|
||||
)
|
||||
if (info.type !== "File") return yield* new AttachmentError({ uri, message: `Attachment is not a file: ${uri}` })
|
||||
if (Number(info.size) > MAX_ATTACHMENT_BYTES)
|
||||
return yield* new AttachmentError({
|
||||
uri,
|
||||
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${uri}`,
|
||||
})
|
||||
const bytes = yield* fs.readFile(target).pipe(
|
||||
Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })),
|
||||
)
|
||||
return { bytes, source: { type: "uri" as const, uri }, start, end, name: path.basename(target) }
|
||||
})
|
||||
|
||||
function decodeDataURL(uri: string) {
|
||||
return Effect.try({
|
||||
try: () => {
|
||||
const comma = uri.indexOf(",")
|
||||
if (comma === -1) throw new Error("Invalid data URL")
|
||||
const metadata = uri.slice(5, comma)
|
||||
const payload = uri.slice(comma + 1)
|
||||
if (!metadata.split(";").some((part) => part.toLowerCase() === "base64"))
|
||||
return Buffer.from(decodeURIComponent(payload))
|
||||
const bytes = Buffer.from(payload, "base64")
|
||||
if (bytes.toString("base64") !== payload) throw new Error("Non-canonical base64")
|
||||
return bytes
|
||||
},
|
||||
catch: () => new AttachmentError({ uri, message: "Invalid attachment data URL" }),
|
||||
})
|
||||
}
|
||||
|
||||
function positiveInt(value: string | null) {
|
||||
if (value === null) return
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
|
||||
}
|
||||
|
||||
// Mirrors the shell tool's in-memory preview safety limit.
|
||||
const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
|
||||
|
|
@ -742,5 +841,6 @@ export const node = makeGlobalNode({
|
|||
SessionStore.node,
|
||||
LocationServiceMap.node,
|
||||
SessionProjector.node,
|
||||
FSUtil.node,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -94,7 +94,11 @@ export const serializeToolContent = (content: SessionMessage.ToolStateCompleted[
|
|||
|
||||
const serialize = (message: SessionMessage.Message) => {
|
||||
if (message.type === "user") {
|
||||
const files = message.files?.map((file) => `[Attached ${file.mime}: ${file.name ?? file.uri}]`) ?? []
|
||||
const files =
|
||||
message.files?.map(
|
||||
(file) =>
|
||||
`[Attached ${file.mime}: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}]`,
|
||||
) ?? []
|
||||
return [`[User]: ${message.text}`, ...files].join("\n")
|
||||
}
|
||||
if (message.type === "assistant") {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type { Database } from "../database/database"
|
|||
import type { EventV2 } from "../event"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
import { Prompt } from "./prompt"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionInputTable, SessionMessageTable } from "./sql"
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/schema/prompt"
|
||||
|
|
@ -9,16 +9,38 @@ import {
|
|||
} from "@opencode-ai/llm"
|
||||
import { Option, Schema } from "effect"
|
||||
import { SessionMessage } from "../message"
|
||||
import type { FileAttachment } from "../prompt"
|
||||
import type { FileAttachment } from "@opencode-ai/schema/prompt"
|
||||
|
||||
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
|
||||
|
||||
const media = (file: FileAttachment): ContentPart => ({
|
||||
type: "media",
|
||||
mediaType: file.mime,
|
||||
data: file.uri,
|
||||
data: file.data,
|
||||
filename: file.name,
|
||||
metadata: file.description === undefined ? undefined : { description: file.description },
|
||||
})
|
||||
|
||||
const textAttachment = (file: FileAttachment) =>
|
||||
Message.make({
|
||||
role: "user",
|
||||
content: [
|
||||
`Attached file: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}`,
|
||||
file.description === undefined ? undefined : `Description: ${file.description}`,
|
||||
"",
|
||||
Buffer.from(file.data, "base64").toString("utf8"),
|
||||
]
|
||||
.filter((line): line is string => line !== undefined)
|
||||
.join("\n"),
|
||||
metadata: {
|
||||
attachment: {
|
||||
source: file.source,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
|
||||
const toolInput = (tool: SessionMessage.AssistantTool) =>
|
||||
|
|
@ -117,11 +139,18 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
|
|||
case "model-switched":
|
||||
return []
|
||||
case "user":
|
||||
const files = message.files ?? []
|
||||
return [
|
||||
...files
|
||||
.filter((file) => file.mime === "text/plain")
|
||||
.map(textAttachment),
|
||||
Message.make({
|
||||
id: message.id,
|
||||
role: "user",
|
||||
content: [{ type: "text", text: message.text }, ...(message.files ?? []).map(media)],
|
||||
content: [
|
||||
{ type: "text", text: message.text },
|
||||
...files.filter((file) => imageMimes.has(file.mime)).map(media),
|
||||
],
|
||||
metadata: {
|
||||
...message.metadata,
|
||||
...(message.agents?.length ? { agents: message.agents } : {}),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from
|
|||
import { directoryColumn, pathColumn } from "../database/path"
|
||||
import { ProjectTable } from "../project/sql"
|
||||
import type { SessionMessage } from "./message"
|
||||
import type { Prompt } from "./prompt"
|
||||
import type { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import type { SessionInput } from "./input"
|
||||
import type { Snapshot } from "../snapshot"
|
||||
import { PermissionV1 } from "../v1/permission"
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
|||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
|
|
@ -192,7 +192,7 @@ describe("SessionV2.create", () => {
|
|||
const parent = yield* session.create({ location, title: "Parent" })
|
||||
const admitted = yield* session.prompt({
|
||||
sessionID: parent.id,
|
||||
prompt: Prompt.make({ text: "First" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "First" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
||||
|
|
@ -224,9 +224,9 @@ describe("SessionV2.create", () => {
|
|||
promotedSeq: 2,
|
||||
})
|
||||
|
||||
yield* session.prompt({ sessionID: parent.id, prompt: Prompt.make({ text: "Parent changed" }), resume: false })
|
||||
yield* session.prompt({ sessionID: parent.id, prompt: PromptInput.Prompt.make({ text: "Parent changed" }), resume: false })
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
||||
yield* session.prompt({ sessionID: forked.id, prompt: Prompt.make({ text: "Child continues" }), resume: false })
|
||||
yield* session.prompt({ sessionID: forked.id, prompt: PromptInput.Prompt.make({ text: "Child continues" }), resume: false })
|
||||
yield* SessionInput.promoteSteers(db, events, forked.id)
|
||||
|
||||
expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
|
||||
|
|
@ -249,13 +249,13 @@ describe("SessionV2.create", () => {
|
|||
const parent = yield* session.create({ location })
|
||||
const first = yield* session.prompt({
|
||||
sessionID: parent.id,
|
||||
prompt: Prompt.make({ text: "First" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "First" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
||||
const second = yield* session.prompt({
|
||||
sessionID: parent.id,
|
||||
prompt: Prompt.make({ text: "Second" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Second" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
||||
|
|
@ -373,7 +373,7 @@ describe("SessionV2.create", () => {
|
|||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.prompt({ sessionID: created.id, prompt: Prompt.make({ text: "Hello" }), resume: false })
|
||||
yield* session.prompt({ sessionID: created.id, prompt: PromptInput.Prompt.make({ text: "Hello" }), resume: false })
|
||||
yield* SessionInput.promoteSteers(db, events, created.id)
|
||||
|
||||
expect(
|
||||
|
|
@ -393,7 +393,7 @@ describe("SessionV2.create", () => {
|
|||
const created = yield* session.create({ id: SessionV2.ID.make("ses_fresh_target_replay"), location })
|
||||
const admitted = yield* session.prompt({
|
||||
sessionID: created.id,
|
||||
prompt: Prompt.make({ text: "Replay lifecycle" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Replay lifecycle" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
|||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { mkdtemp, rm } from "fs/promises"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
|
|
@ -13,7 +17,7 @@ import { Project } from "@opencode-ai/core/project"
|
|||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
|
|
@ -169,7 +173,7 @@ describe("SessionV2.prompt", () => {
|
|||
|
||||
const message = yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Fix the failing tests" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }),
|
||||
resume: false,
|
||||
})
|
||||
|
||||
|
|
@ -193,7 +197,7 @@ describe("SessionV2.prompt", () => {
|
|||
|
||||
const boundary = yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "boundary" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "boundary" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
||||
|
|
@ -205,7 +209,7 @@ describe("SessionV2.prompt", () => {
|
|||
})
|
||||
expect((yield* session.get(sessionID)).revert?.messageID).toBe(boundary.id)
|
||||
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "after revert" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "after revert" }), resume: false })
|
||||
|
||||
expect((yield* session.get(sessionID)).revert).toBeUndefined()
|
||||
expect(
|
||||
|
|
@ -221,23 +225,161 @@ describe("SessionV2.prompt", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const uri =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
|
||||
const message = yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: {
|
||||
text: "Inspect this image",
|
||||
files: [{ uri: "data:image/png;base64,aGVsbG8=", name: "image.png" }],
|
||||
files: [{ uri, name: "image.png", mention: { start: 8, end: 17, text: "[Image 1]" } }],
|
||||
},
|
||||
resume: false,
|
||||
})
|
||||
|
||||
expect(message.prompt.files).toEqual([
|
||||
{ uri: "data:image/png;base64,aGVsbG8=", name: "image.png", mime: "image/png" },
|
||||
{
|
||||
data: uri.slice(uri.indexOf(",") + 1),
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
mention: { start: 8, end: 17, text: "[Image 1]" },
|
||||
},
|
||||
])
|
||||
expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("materializes selected source file content", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const directory = import.meta.dir
|
||||
const source = path.join(directory, "session-prompt.test.ts")
|
||||
const sourceUri = pathToFileURL(source)
|
||||
sourceUri.searchParams.set("start", "1")
|
||||
sourceUri.searchParams.set("end", "1")
|
||||
|
||||
const message = yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: {
|
||||
text: "Inspect this",
|
||||
files: [{ uri: sourceUri.href, name: "main.ts" }],
|
||||
},
|
||||
resume: false,
|
||||
})
|
||||
|
||||
expect(message.prompt.files).toEqual([
|
||||
{
|
||||
data: Buffer.from('import { describe, expect } from "bun:test"').toString("base64"),
|
||||
mime: "text/plain",
|
||||
source: { type: "uri", uri: sourceUri.href },
|
||||
name: "main.ts",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects directories as file attachments", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const uri = pathToFileURL(import.meta.dir).href
|
||||
|
||||
const error = yield* session
|
||||
.prompt({
|
||||
sessionID,
|
||||
prompt: { text: "Inspect this", files: [{ uri, name: "source" }] },
|
||||
resume: false,
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "Session.AttachmentError",
|
||||
uri,
|
||||
message: `Attachment is not a file: ${uri}`,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("materializes local image content before admission", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const directory = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => mkdtemp(path.join(tmpdir(), "opencode-session-prompt-"))),
|
||||
(directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })),
|
||||
)
|
||||
const source = path.join(directory, "image.png")
|
||||
const bytes = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
)
|
||||
yield* Effect.promise(() => Bun.write(source, bytes))
|
||||
|
||||
const message = yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: { text: "Inspect this image", files: [{ uri: pathToFileURL(source).href }] },
|
||||
resume: false,
|
||||
})
|
||||
|
||||
expect(message.prompt.files).toEqual([
|
||||
{
|
||||
data: bytes.toString("base64"),
|
||||
mime: "image/png",
|
||||
source: { type: "uri", uri: pathToFileURL(source).href },
|
||||
name: "image.png",
|
||||
},
|
||||
])
|
||||
expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sniffs data URL content instead of trusting its declared MIME", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const uri = `data:video/mp2t;base64,${Buffer.from("export const value = 1\n").toString("base64")}`
|
||||
|
||||
const message = yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: { text: "Inspect this", files: [{ uri, name: "main.ts" }] },
|
||||
resume: false,
|
||||
})
|
||||
|
||||
expect(message.prompt.files).toEqual([
|
||||
{
|
||||
data: Buffer.from("export const value = 1\n").toString("base64"),
|
||||
mime: "text/plain",
|
||||
source: { type: "inline" },
|
||||
name: "main.ts",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects malformed base64 data URLs", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const uri = "data:image/png;base64,not-base64"
|
||||
|
||||
const error = yield* session
|
||||
.prompt({
|
||||
sessionID,
|
||||
prompt: { text: "Inspect this", files: [{ uri, name: "image.png" }] },
|
||||
resume: false,
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "Session.AttachmentError",
|
||||
uri,
|
||||
message: "Invalid attachment data URL",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("streams durable Session events after an aggregate sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
|
@ -251,8 +393,8 @@ describe("SessionV2.prompt", () => {
|
|||
const fiber = yield* publicEvents({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
||||
const streamed = Array.from(yield* Fiber.join(fiber))
|
||||
|
||||
|
|
@ -276,7 +418,7 @@ describe("SessionV2.prompt", () => {
|
|||
const session = yield* SessionV2.Service
|
||||
const message = yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Fix the failing tests" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }),
|
||||
resume: false,
|
||||
})
|
||||
|
||||
|
|
@ -295,7 +437,7 @@ describe("SessionV2.prompt", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const input = { sessionID, prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false }
|
||||
const input = { sessionID, prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), resume: false }
|
||||
|
||||
const first = yield* session.prompt(input)
|
||||
const second = yield* session.prompt(input)
|
||||
|
|
@ -313,7 +455,7 @@ describe("SessionV2.prompt", () => {
|
|||
const input = {
|
||||
sessionID,
|
||||
id: messageID,
|
||||
prompt: Prompt.make({ text: "Fix the failing tests" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }),
|
||||
resume: false,
|
||||
}
|
||||
|
||||
|
|
@ -333,7 +475,7 @@ describe("SessionV2.prompt", () => {
|
|||
const input = {
|
||||
sessionID,
|
||||
id: messageID,
|
||||
prompt: Prompt.make({ text: "Recover committed prompt" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Recover committed prompt" }),
|
||||
resume: false,
|
||||
}
|
||||
const first = yield* session.prompt(input)
|
||||
|
|
@ -354,13 +496,13 @@ describe("SessionV2.prompt", () => {
|
|||
yield* session.prompt({
|
||||
sessionID,
|
||||
id: messageID,
|
||||
prompt: Prompt.make({ text: "Fix the failing tests" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }),
|
||||
})
|
||||
const failure = yield* session
|
||||
.prompt({
|
||||
sessionID,
|
||||
id: messageID,
|
||||
prompt: Prompt.make({ text: "Delete the failing tests" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Delete the failing tests" }),
|
||||
resume: false,
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
|
@ -379,14 +521,14 @@ describe("SessionV2.prompt", () => {
|
|||
yield* session.prompt({
|
||||
id: messageID,
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Fix the failing tests" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }),
|
||||
resume: false,
|
||||
})
|
||||
const failure = yield* session
|
||||
.prompt({
|
||||
id: messageID,
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Fix the failing tests" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }),
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
|
|
@ -403,7 +545,7 @@ describe("SessionV2.prompt", () => {
|
|||
const input = {
|
||||
sessionID,
|
||||
id: messageID,
|
||||
prompt: Prompt.make({ text: "Fix the failing tests" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }),
|
||||
resume: false,
|
||||
}
|
||||
|
||||
|
|
@ -422,7 +564,7 @@ describe("SessionV2.prompt", () => {
|
|||
const { db } = yield* Database.Service
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Promote once" }), resume: false })
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "Promote once" }), resume: false })
|
||||
|
||||
yield* Effect.all(
|
||||
[SessionInput.promoteSteers(db, events, sessionID), SessionInput.promoteSteers(db, events, sessionID)],
|
||||
|
|
@ -447,7 +589,7 @@ describe("SessionV2.prompt", () => {
|
|||
yield* session.prompt({
|
||||
id: messageID,
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Replay pending" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Replay pending" }),
|
||||
resume: false,
|
||||
})
|
||||
const recorded = yield* db
|
||||
|
|
@ -500,7 +642,7 @@ describe("SessionV2.prompt", () => {
|
|||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const prompt = Prompt.make({ text: "Fix the failing tests" })
|
||||
const prompt = PromptInput.Prompt.make({ text: "Fix the failing tests" })
|
||||
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
|
||||
const failure = yield* session
|
||||
|
|
@ -534,7 +676,7 @@ describe("SessionV2.prompt", () => {
|
|||
.pipe(Effect.orDie)
|
||||
|
||||
const failure = yield* session
|
||||
.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Conflicting prompt" }), resume: false })
|
||||
.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "Conflicting prompt" }), resume: false })
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID })
|
||||
|
|
@ -549,7 +691,7 @@ describe("SessionV2.prompt", () => {
|
|||
executionCalls.length = 0
|
||||
wakeCalls.length = 0
|
||||
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run by default" }) })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run by default" }) })
|
||||
|
||||
expect(executionCalls).toEqual([])
|
||||
expect(wakeCalls).toEqual([sessionID])
|
||||
|
|
@ -565,7 +707,7 @@ describe("SessionV2.prompt", () => {
|
|||
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Run explicitly" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Run explicitly" }),
|
||||
resume: true,
|
||||
})
|
||||
|
||||
|
|
@ -581,7 +723,7 @@ describe("SessionV2.prompt", () => {
|
|||
executionCalls.length = 0
|
||||
wakeCalls.length = 0
|
||||
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Do not run" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Do not run" }), resume: false })
|
||||
|
||||
expect(executionCalls).toEqual([])
|
||||
expect(wakeCalls).toEqual([])
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
|||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { AgentAttachment, FileAttachment } from "@opencode-ai/core/session/prompt"
|
||||
import { AgentAttachment, Base64, FileAttachment } from "@opencode-ai/schema/prompt"
|
||||
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
|
|
@ -49,7 +49,12 @@ describe("toLLMMessages", () => {
|
|||
})
|
||||
|
||||
test("maps every top-level V2 Session message type", () => {
|
||||
const file = FileAttachment.make({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
|
||||
const file = FileAttachment.make({
|
||||
data: Base64.make("aGVsbG8="),
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "hello.png",
|
||||
})
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.AgentSelected.make({
|
||||
|
|
@ -122,7 +127,7 @@ describe("toLLMMessages", () => {
|
|||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Inspect this image" },
|
||||
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" },
|
||||
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "hello.png" },
|
||||
],
|
||||
metadata: { agents: [{ name: "build" }] },
|
||||
}),
|
||||
|
|
@ -149,6 +154,102 @@ Recent work
|
|||
])
|
||||
})
|
||||
|
||||
test("lowers text attachments as separate user messages", () => {
|
||||
const file = FileAttachment.make({
|
||||
data: Base64.make(Buffer.from("export const value = 1").toString("base64")),
|
||||
mime: "text/plain",
|
||||
source: { type: "uri", uri: "file:///project/main.ts" },
|
||||
name: "main.ts",
|
||||
})
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-text-file"),
|
||||
type: "user",
|
||||
text: "Review this file",
|
||||
files: [file],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages).toHaveLength(2)
|
||||
expect(messages[0]).toMatchObject({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Attached file: main.ts\n\nexport const value = 1",
|
||||
},
|
||||
],
|
||||
metadata: { attachment: { source: file.source, name: "main.ts" } },
|
||||
})
|
||||
expect(messages[1]).toMatchObject({
|
||||
id: id("user-text-file"),
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Review this file" }],
|
||||
})
|
||||
})
|
||||
|
||||
test("decodes inline text attachment content", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-data-file"),
|
||||
type: "user",
|
||||
text: "Review this file",
|
||||
files: [
|
||||
FileAttachment.make({
|
||||
data: Base64.make(Buffer.from("inline content").toString("base64")),
|
||||
mime: "text/plain",
|
||||
source: { type: "inline" },
|
||||
name: "inline.txt",
|
||||
}),
|
||||
],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Attached file: inline.txt\n\ninline content",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("uses materialized image data as provider media and drops unsupported attachments", () => {
|
||||
const data = Base64.make("AAECAw==")
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-local-image"),
|
||||
type: "user",
|
||||
text: "Inspect this image",
|
||||
files: [
|
||||
FileAttachment.make({ data, mime: "image/png", source: { type: "inline" }, name: "image.png" }),
|
||||
FileAttachment.make({
|
||||
data: Base64.make("JVBERg=="),
|
||||
mime: "application/pdf",
|
||||
source: { type: "inline" },
|
||||
name: "document.pdf",
|
||||
}),
|
||||
],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Inspect this image" },
|
||||
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
|
||||
])
|
||||
})
|
||||
|
||||
test("replays durable tool media into canonical tool messages without structured base64", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||
import { SessionTitle } from "@opencode-ai/core/session/title"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
|
||||
|
|
@ -172,7 +172,7 @@ describe("SessionRunnerLLM recorded", () => {
|
|||
const session = yield* SessionV2.Service
|
||||
const prompt = yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Say hello in one short sentence." }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Say hello in one short sentence." }),
|
||||
resume: false,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
|||
import { SessionTitle } from "@opencode-ai/core/session/title"
|
||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
|
||||
|
|
@ -375,7 +375,7 @@ const setupOverflowRecovery = Effect.gen(function* () {
|
|||
response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Earlier question ".repeat(700) }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Earlier question ".repeat(700) }),
|
||||
resume: false,
|
||||
})
|
||||
yield* session.resume(sessionID)
|
||||
|
|
@ -516,7 +516,7 @@ const verifyEphemeralDeltas = (kind: FragmentKind) =>
|
|||
const chunks = Array.from({ length: 32 }, (_, index) => `${index},`)
|
||||
const fixture = fragmentFixture(kind, fragmentID(kind, "many"), chunks)
|
||||
const expectedContext = [{ type: "user", text: prompt }, fixture.expectedAssistant]
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: prompt }), resume: false })
|
||||
const events = yield* EventV2.Service
|
||||
const live = yield* events.subscribe(fixture.delta).pipe(Stream.take(32), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
|
@ -547,7 +547,7 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) =>
|
|||
const prompt = `Fail after ${kind}`
|
||||
const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"])
|
||||
const failure = providerUnavailable()
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: prompt }), resume: false })
|
||||
responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure))
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
||||
|
|
@ -569,7 +569,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
|
|||
const prompt = `Interrupt after ${kind}`
|
||||
const fixture = fragmentFixture(kind, fragmentID(kind, "interrupted"), ["Partial"])
|
||||
const streamed = yield* Deferred.make<void>()
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: prompt }), resume: false })
|
||||
responseStream = Stream.concat(
|
||||
Stream.fromIterable(fixture.partialEvents),
|
||||
Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)),
|
||||
|
|
@ -613,7 +613,7 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
}),
|
||||
})
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Use application context" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Use application context" }), resume: false })
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
|
|
@ -661,7 +661,7 @@ describe("SessionRunnerLLM", () => {
|
|||
streamStarted = undefined
|
||||
response = []
|
||||
|
||||
const message = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run automatically" }) })
|
||||
const message = yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run automatically" }) })
|
||||
yield* session.wait(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
|
|
@ -675,8 +675,8 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = undefined
|
||||
|
|
@ -703,7 +703,7 @@ describe("SessionRunnerLLM", () => {
|
|||
const { db } = yield* Database.Service
|
||||
const messageID = SessionMessage.ID.create()
|
||||
systemUnavailable = true
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
requests.length = 0
|
||||
|
||||
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
|
||||
|
|
@ -721,7 +721,7 @@ describe("SessionRunnerLLM", () => {
|
|||
).toBeUndefined()
|
||||
|
||||
systemUnavailable = false
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }) })
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "First" }) })
|
||||
yield* session.wait(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
|
|
@ -735,7 +735,7 @@ describe("SessionRunnerLLM", () => {
|
|||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
requests.length = 0
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
|
|
@ -752,7 +752,7 @@ describe("SessionRunnerLLM", () => {
|
|||
.get(),
|
||||
).toBeUndefined()
|
||||
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false })
|
||||
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
|
|
@ -766,7 +766,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
|
|
@ -795,7 +795,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
yield* db
|
||||
|
|
@ -804,7 +804,7 @@ describe("SessionRunnerLLM", () => {
|
|||
.where(eq(InstructionCheckpointTable.session_id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false })
|
||||
requests.length = 0
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
|
@ -828,13 +828,13 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
systemBaseline = "Changed context"
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
|
|
@ -863,7 +863,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* setup
|
||||
currentModel = Model.make({ id: "gpt-5", provider: "openai", route: OpenAIChat.route })
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = fragmentFixture("text", "text-provider-prompt", ["Done"]).completeEvents
|
||||
|
|
@ -888,7 +888,7 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = fragmentFixture("text", "text-empty-agent-system", ["Done"]).completeEvents
|
||||
|
|
@ -912,7 +912,7 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = fragmentFixture("text", "text-build", ["Done"]).completeEvents
|
||||
|
|
@ -938,7 +938,7 @@ describe("SessionRunnerLLM", () => {
|
|||
editor.default(AgentV2.ID.make("reviewer"))
|
||||
})
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = fragmentFixture("text", "text-reviewer", ["Done"]).completeEvents
|
||||
|
|
@ -960,7 +960,7 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = fragmentFixture("text", "text-no-system", ["Done"]).completeEvents
|
||||
|
|
@ -988,7 +988,7 @@ describe("SessionRunnerLLM", () => {
|
|||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = fragmentFixture("text", "text-selected", ["Done"]).completeEvents
|
||||
|
|
@ -1005,7 +1005,7 @@ describe("SessionRunnerLLM", () => {
|
|||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
skillBaselines.set(AgentV2.ID.make("build"), "Build skills")
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
|
|
@ -1015,7 +1015,7 @@ describe("SessionRunnerLLM", () => {
|
|||
sessionID,
|
||||
agent: "reviewer",
|
||||
})
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
|
|
@ -1044,7 +1044,7 @@ describe("SessionRunnerLLM", () => {
|
|||
})
|
||||
.pipe(Effect.asVoid)
|
||||
})
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
|
|
@ -1072,7 +1072,7 @@ describe("SessionRunnerLLM", () => {
|
|||
})
|
||||
.pipe(Effect.asVoid)
|
||||
})
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
|
|
@ -1088,13 +1088,13 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
systemRemoved = true
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
||||
|
|
@ -1111,7 +1111,7 @@ describe("SessionRunnerLLM", () => {
|
|||
const session = yield* SessionV2.Service
|
||||
const contextEntries = yield* InstructionEntry.Service
|
||||
yield* contextEntries.put({ sessionID, key: "deploy-target", value: "production" })
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
|
|
@ -1125,7 +1125,7 @@ describe("SessionRunnerLLM", () => {
|
|||
|
||||
// Non-string JSON pretty-prints; the change narrates as a System update.
|
||||
yield* contextEntries.put({ sessionID, key: "deploy-target", value: { region: "us-east-1" } })
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
||||
|
|
@ -1146,7 +1146,7 @@ describe("SessionRunnerLLM", () => {
|
|||
|
||||
// Deleting the row announces removal through the stored removal text.
|
||||
yield* contextEntries.remove({ sessionID, key: "deploy-target" })
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "system", "user", "system", "user"])
|
||||
|
|
@ -1162,20 +1162,20 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
systemBaseline = "Changed context"
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
yield* events.publish(SessionEvent.ModelSelected, {
|
||||
sessionID,
|
||||
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
systemBaseline = "Replacement context"
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
|
|
@ -1195,7 +1195,7 @@ describe("SessionRunnerLLM", () => {
|
|||
])
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(6)
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fourth" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fourth" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
}),
|
||||
)
|
||||
|
|
@ -1205,7 +1205,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
|
|
@ -1215,11 +1215,11 @@ describe("SessionRunnerLLM", () => {
|
|||
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
systemUnavailable = true
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
systemUnavailable = false
|
||||
systemBaseline = "Replacement context"
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
|
|
@ -1235,7 +1235,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
|
|
@ -1252,7 +1252,7 @@ describe("SessionRunnerLLM", () => {
|
|||
recent: "",
|
||||
})
|
||||
systemBaseline = "Replacement context"
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
|
|
@ -1260,7 +1260,7 @@ describe("SessionRunnerLLM", () => {
|
|||
[defaultSystem, "Replacement context"],
|
||||
])
|
||||
yield* replaySessionProjection(sessionID)
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
}),
|
||||
)
|
||||
|
|
@ -1272,7 +1272,7 @@ describe("SessionRunnerLLM", () => {
|
|||
response = fragmentFixture("text", "text-first", ["Earlier answer"]).completeEvents
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Earlier question ".repeat(180) }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Earlier question ".repeat(180) }),
|
||||
resume: false,
|
||||
})
|
||||
yield* session.resume(sessionID)
|
||||
|
|
@ -1285,7 +1285,7 @@ describe("SessionRunnerLLM", () => {
|
|||
]
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Recent exact request ".repeat(180) }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Recent exact request ".repeat(180) }),
|
||||
resume: false,
|
||||
})
|
||||
yield* session.resume(sessionID)
|
||||
|
|
@ -1311,7 +1311,7 @@ describe("SessionRunnerLLM", () => {
|
|||
]
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Newest exact request ".repeat(180) }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Newest exact request ".repeat(180) }),
|
||||
resume: false,
|
||||
})
|
||||
yield* session.resume(sessionID)
|
||||
|
|
@ -1339,7 +1339,7 @@ describe("SessionRunnerLLM", () => {
|
|||
fragmentFixture("text", "text-summary", ["## Objective\n- Recover overflow"]).completeEvents,
|
||||
fragmentFixture("text", "text-final", ["Recovered"]).completeEvents,
|
||||
]
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
|
|
@ -1369,7 +1369,7 @@ describe("SessionRunnerLLM", () => {
|
|||
fragmentFixture("text", "text-summary", ["## Objective\n- Recover once"]).completeEvents,
|
||||
overflow(),
|
||||
]
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
|
|
@ -1397,7 +1397,7 @@ describe("SessionRunnerLLM", () => {
|
|||
fragmentFixture("text", "text-summary", ["## Objective\n- Recover raw overflow"]).completeEvents,
|
||||
fragmentFixture("text", "text-final", ["Recovered"]).completeEvents,
|
||||
]
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
|
|
@ -1415,7 +1415,7 @@ describe("SessionRunnerLLM", () => {
|
|||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
[LLMEvent.providerError({ message: "summary unavailable" })],
|
||||
]
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
|
|
@ -1438,7 +1438,7 @@ describe("SessionRunnerLLM", () => {
|
|||
const firstGate = yield* Deferred.make<void>()
|
||||
const summaryGate = yield* Deferred.make<void>()
|
||||
streamGate = firstGate
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false })
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
while (requests.length < 1) yield* Effect.yieldNow
|
||||
streamGate = summaryGate
|
||||
|
|
@ -1458,13 +1458,13 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
systemBaseline = "Changed context"
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
const compactionID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||
|
|
@ -1478,7 +1478,7 @@ describe("SessionRunnerLLM", () => {
|
|||
recent: "",
|
||||
})
|
||||
systemUnavailable = true
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
// The rebaseline proceeds while the source is unobservable, restating the model's belief.
|
||||
|
|
@ -1491,7 +1491,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Use tools" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Use tools" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = undefined
|
||||
|
|
@ -1589,7 +1589,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo this" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo this" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
authorizations.length = 0
|
||||
|
|
@ -1648,7 +1648,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo this" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo this" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = [
|
||||
|
|
@ -1690,7 +1690,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Think first" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Think first" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = [
|
||||
|
|
@ -1728,7 +1728,7 @@ describe("SessionRunnerLLM", () => {
|
|||
},
|
||||
])
|
||||
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false })
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
|
|
@ -1747,7 +1747,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Search first" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Search first" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = [
|
||||
|
|
@ -1772,7 +1772,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* session.resume(sessionID)
|
||||
yield* replaySessionProjection(sessionID)
|
||||
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false })
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
|
|
@ -1802,7 +1802,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo five times" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo five times" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
executions.length = 0
|
||||
|
|
@ -1863,7 +1863,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo twice" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo twice" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
executions.length = 0
|
||||
|
|
@ -1951,7 +1951,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run once" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run once" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = undefined
|
||||
|
|
@ -1990,7 +1990,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = [
|
||||
|
|
@ -2010,7 +2010,7 @@ describe("SessionRunnerLLM", () => {
|
|||
|
||||
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Change direction" }) })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Change direction" }) })
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* Fiber.join(first)
|
||||
streamGate = undefined
|
||||
|
|
@ -2033,7 +2033,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = [
|
||||
|
|
@ -2061,7 +2061,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* Deferred.await(streamStarted)
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Wait until continuation ends" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Wait until continuation ends" }),
|
||||
delivery: "queue",
|
||||
})
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
|
|
@ -2081,7 +2081,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt current work" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt current work" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = [
|
||||
|
|
@ -2099,7 +2099,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* Deferred.await(streamStarted)
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Run after interrupt" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Run after interrupt" }),
|
||||
delivery: "queue",
|
||||
})
|
||||
yield* session.interrupt(sessionID)
|
||||
|
|
@ -2124,7 +2124,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt current work" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt current work" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = [
|
||||
|
|
@ -2142,7 +2142,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* Deferred.await(streamStarted)
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Steer after interrupt" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Steer after interrupt" }),
|
||||
})
|
||||
yield* session.interrupt(sessionID)
|
||||
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
||||
|
|
@ -2166,7 +2166,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = [
|
||||
|
|
@ -2191,8 +2191,8 @@ describe("SessionRunnerLLM", () => {
|
|||
|
||||
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue first" }), delivery: "queue" })
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue second" }), delivery: "queue" })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue first" }), delivery: "queue" })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue second" }), delivery: "queue" })
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* Fiber.join(first)
|
||||
streamGate = undefined
|
||||
|
|
@ -2209,10 +2209,10 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start steering" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start steering" }), resume: false })
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Queue for later" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Queue for later" }),
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
|
|
@ -2243,7 +2243,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = [
|
||||
|
|
@ -2274,13 +2274,13 @@ describe("SessionRunnerLLM", () => {
|
|||
|
||||
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
while (requests.length < 1) yield* Effect.yieldNow
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue first" }), delivery: "queue" })
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue second" }), delivery: "queue" })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue first" }), delivery: "queue" })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue second" }), delivery: "queue" })
|
||||
streamGate = secondGate
|
||||
yield* Deferred.succeed(firstGate, undefined)
|
||||
while (requests.length < 2) yield* Effect.yieldNow
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Steer before next queued input" }) })
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Also steer before next queued input" }) })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Steer before next queued input" }) })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Also steer before next queued input" }) })
|
||||
yield* Deferred.succeed(secondGate, undefined)
|
||||
yield* Fiber.join(first)
|
||||
streamGate = undefined
|
||||
|
|
@ -2308,7 +2308,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = [
|
||||
|
|
@ -2328,8 +2328,8 @@ describe("SessionRunnerLLM", () => {
|
|||
|
||||
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First steer" }) })
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second steer" }) })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First steer" }) })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second steer" }) })
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* Fiber.join(first)
|
||||
streamGate = undefined
|
||||
|
|
@ -2348,7 +2348,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = undefined
|
||||
|
|
@ -2359,7 +2359,7 @@ describe("SessionRunnerLLM", () => {
|
|||
|
||||
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover with this" }) })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Recover with this" }) })
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(streamFailure)
|
||||
|
||||
|
|
@ -2378,7 +2378,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover interrupted tool" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Recover interrupted tool" }), resume: false })
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
|
|
@ -2436,7 +2436,7 @@ describe("SessionRunnerLLM", () => {
|
|||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Recover interrupted hosted tool" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Recover interrupted hosted tool" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||
|
|
@ -2492,7 +2492,7 @@ describe("SessionRunnerLLM", () => {
|
|||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Recover interrupted tool input" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Recover interrupted tool input" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||
|
|
@ -2528,7 +2528,7 @@ describe("SessionRunnerLLM", () => {
|
|||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Wait in queue" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Wait in queue" }),
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
|
|
@ -2550,7 +2550,7 @@ describe("SessionRunnerLLM", () => {
|
|||
const defect = new Error("fail after prompt promotion")
|
||||
let fail = true
|
||||
yield* events.project(SessionEvent.PromptPromoted, () => (fail ? Effect.die(defect) : Effect.void))
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover promoted input" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Recover promoted input" }), resume: false })
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
|
||||
fail = false
|
||||
|
|
@ -2580,7 +2580,7 @@ describe("SessionRunnerLLM", () => {
|
|||
)
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Run committed promotion" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Run committed promotion" }),
|
||||
resume: false,
|
||||
})
|
||||
|
||||
|
|
@ -2597,8 +2597,8 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* setup
|
||||
yield* insertSession(otherSessionID)
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run first" }), resume: false })
|
||||
yield* session.prompt({ sessionID: otherSessionID, prompt: Prompt.make({ text: "Run second" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run first" }), resume: false })
|
||||
yield* session.prompt({ sessionID: otherSessionID, prompt: PromptInput.Prompt.make({ text: "Run second" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = undefined
|
||||
|
|
@ -2635,12 +2635,12 @@ describe("SessionRunnerLLM", () => {
|
|||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({
|
||||
sessionID: longSessionID,
|
||||
prompt: Prompt.make({ text: "Run long session" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Run long session" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* session.prompt({
|
||||
sessionID: otherLongSessionID,
|
||||
prompt: Prompt.make({ text: "Run other long session" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Run other long session" }),
|
||||
resume: false,
|
||||
})
|
||||
|
||||
|
|
@ -2659,7 +2659,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Retry after failure" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Retry after failure" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = undefined
|
||||
|
|
@ -2690,7 +2690,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call missing" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call missing" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = [
|
||||
|
|
@ -2736,7 +2736,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call defect" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call defect" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = [
|
||||
|
|
@ -2926,7 +2926,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call storefail" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call storefail" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = [
|
||||
|
|
@ -2978,7 +2978,7 @@ describe("SessionRunnerLLM", () => {
|
|||
execute: () => Effect.die(new QuestionTool.CancelledError()),
|
||||
}),
|
||||
})
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Ask then stop" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = [
|
||||
|
|
@ -3017,7 +3017,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Settle before failing" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Settle before failing" }), resume: false })
|
||||
const failure = providerUnavailable()
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
responseStream = Stream.concat(
|
||||
|
|
@ -3051,7 +3051,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt blocked tool" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt blocked tool" }), resume: false })
|
||||
executions.length = 0
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
responseStream = Stream.concat(
|
||||
|
|
@ -3101,7 +3101,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt provider" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt provider" }), resume: false })
|
||||
requests.length = 0
|
||||
response = []
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
|
|
@ -3129,7 +3129,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt tool settlement" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt tool settlement" }), resume: false })
|
||||
executions.length = 0
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
toolExecutionsStarted = yield* Deferred.make<void>()
|
||||
|
|
@ -3179,7 +3179,7 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Finish at the limit" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Finish at the limit" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
executions.length = 0
|
||||
|
|
@ -3227,7 +3227,7 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start work" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start work" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
executions.length = 0
|
||||
|
|
@ -3255,7 +3255,7 @@ describe("SessionRunnerLLM", () => {
|
|||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Change direction" }) })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Change direction" }) })
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* Fiber.join(run)
|
||||
streamGate = undefined
|
||||
|
|
@ -3273,7 +3273,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail durably" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail durably" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = undefined
|
||||
|
|
@ -3295,7 +3295,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail before step" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail before step" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = [LLMEvent.providerError({ message: "Provider unavailable" })]
|
||||
|
|
@ -3314,7 +3314,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail after output" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail after output" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = [
|
||||
|
|
@ -3343,7 +3343,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail raw stream durably" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail raw stream durably" }), resume: false })
|
||||
const failure = providerUnavailable()
|
||||
responseStream = Stream.fail(failure)
|
||||
|
||||
|
|
@ -3362,7 +3362,7 @@ describe("SessionRunnerLLM", () => {
|
|||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Do not continue failed provider" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Do not continue failed provider" }),
|
||||
resume: false,
|
||||
})
|
||||
|
||||
|
|
@ -3385,7 +3385,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail hosted tool durably" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail hosted tool durably" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = [
|
||||
|
|
@ -3416,7 +3416,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail hosted tool at EOF" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail hosted tool at EOF" }), resume: false })
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({
|
||||
|
|
@ -3443,7 +3443,7 @@ describe("SessionRunnerLLM", () => {
|
|||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Fail hosted tool on raw failure" }),
|
||||
prompt: PromptInput.Prompt.make({ text: "Fail hosted tool on raw failure" }),
|
||||
resume: false,
|
||||
})
|
||||
const failure = providerUnavailable()
|
||||
|
|
@ -3478,7 +3478,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Two blocks" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Two blocks" }), resume: false })
|
||||
|
||||
responses = undefined
|
||||
streamGate = undefined
|
||||
|
|
@ -3542,7 +3542,7 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call provider tool" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call provider tool" }), resume: false })
|
||||
|
||||
responses = undefined
|
||||
streamGate = undefined
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/schema/prompt"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
|
||||
import { PermissionV1 } from "@opencode-ai/schema/permission-v1"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionInput } from "@opencode-ai/schema/session-input"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
|
|
@ -47,7 +47,6 @@ test("Core reuses the canonical shared schemas", async () => {
|
|||
coreSessionInput,
|
||||
coreSessionMessage,
|
||||
coreSessionTodo,
|
||||
corePrompt,
|
||||
coreSkill,
|
||||
coreV2Schema,
|
||||
coreSchema,
|
||||
|
|
@ -69,7 +68,6 @@ test("Core reuses the canonical shared schemas", async () => {
|
|||
import("@opencode-ai/core/session/input"),
|
||||
import("@opencode-ai/core/session/message"),
|
||||
import("@opencode-ai/core/session/todo"),
|
||||
import("@opencode-ai/core/session/prompt"),
|
||||
import("@opencode-ai/core/skill"),
|
||||
import("@opencode-ai/core/v2-schema"),
|
||||
import("@opencode-ai/core/schema"),
|
||||
|
|
@ -164,10 +162,6 @@ test("Core reuses the canonical shared schemas", async () => {
|
|||
[coreSessionMessage.Message, SessionMessage.Message],
|
||||
[coreSessionTodo.Info, SessionTodo.Info],
|
||||
[coreSessionTodo.Event, SessionTodo.Event],
|
||||
[corePrompt.Source, Source],
|
||||
[corePrompt.FileAttachment, FileAttachment],
|
||||
[corePrompt.AgentAttachment, AgentAttachment],
|
||||
[corePrompt.Prompt, Prompt],
|
||||
[coreSkill.DirectorySource, Skill.DirectorySource],
|
||||
[coreSkill.UrlSource, Skill.UrlSource],
|
||||
[coreSkill.EmbeddedSource, Skill.EmbeddedSource],
|
||||
|
|
|
|||
|
|
@ -2,3 +2,7 @@
|
|||
title: "Config"
|
||||
description: "Configure OpenCode."
|
||||
---
|
||||
|
||||
<Tip>
|
||||
You shouldn't have to configure OpenCode manually. Ask OpenCode to update its configuration for you.
|
||||
</Tip>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import type {
|
||||
AgentPart,
|
||||
OpencodeClient,
|
||||
V2Event,
|
||||
FilePart,
|
||||
LspStatus,
|
||||
McpStatus,
|
||||
Todo,
|
||||
|
|
@ -13,9 +11,10 @@ import type {
|
|||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
TextPart,
|
||||
Config as SdkConfig,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import type { PromptInput } from "@opencode-ai/schema"
|
||||
import type { Types } from "effect"
|
||||
import type { CliRenderer, KeyEvent, RGBA, Renderable, SlotMode } from "@opentui/core"
|
||||
import type { Binding, Keymap } from "@opentui/keymap"
|
||||
import {
|
||||
|
|
@ -180,22 +179,16 @@ export type TuiDialogSelectProps<Value = unknown> = {
|
|||
current?: Value
|
||||
}
|
||||
|
||||
export type TuiPromptInfo = {
|
||||
input: string
|
||||
export type TuiPromptInfo = Types.DeepMutable<PromptInput.Prompt> & {
|
||||
pasted: {
|
||||
text: string
|
||||
source: {
|
||||
start: number
|
||||
end: number
|
||||
text: string
|
||||
}
|
||||
}[]
|
||||
mode?: "normal" | "shell"
|
||||
parts: (
|
||||
| Omit<FilePart, "id" | "messageID" | "sessionID">
|
||||
| Omit<AgentPart, "id" | "messageID" | "sessionID">
|
||||
| (Omit<TextPart, "id" | "messageID" | "sessionID"> & {
|
||||
source?: {
|
||||
text: {
|
||||
start: number
|
||||
end: number
|
||||
value: string
|
||||
}
|
||||
}
|
||||
})
|
||||
)[]
|
||||
}
|
||||
|
||||
export type TuiPromptRef = {
|
||||
|
|
|
|||
|
|
@ -264,7 +264,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
|||
resume: Schema.Boolean.pipe(Schema.optional),
|
||||
}),
|
||||
success: Schema.Struct({ data: SessionInput.Admitted }),
|
||||
error: [ConflictError, SessionNotFoundError],
|
||||
error: [ConflictError, InvalidRequestError, SessionNotFoundError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
|
|
@ -290,7 +290,13 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
|||
resume: Schema.Boolean.pipe(Schema.optional),
|
||||
}),
|
||||
success: Schema.Struct({ data: SessionInput.Admitted }),
|
||||
error: [ConflictError, SessionNotFoundError, CommandNotFoundError, CommandEvaluationError],
|
||||
error: [
|
||||
ConflictError,
|
||||
InvalidRequestError,
|
||||
SessionNotFoundError,
|
||||
CommandNotFoundError,
|
||||
CommandEvaluationError,
|
||||
],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
|
|
|
|||
|
|
@ -27,6 +27,6 @@ export { Pty } from "./pty.js"
|
|||
export { PtyTicket } from "./pty-ticket.js"
|
||||
export { Question } from "./question.js"
|
||||
export { Workspace } from "./workspace.js"
|
||||
export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt.js"
|
||||
export { Prompt, PromptMention, FileSource, FileAttachment, AgentAttachment } from "./prompt.js"
|
||||
export { PromptInput } from "./prompt-input.js"
|
||||
export * from "./schema.js"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export * as PromptInput from "./prompt-input.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { AgentAttachment, Source } from "./prompt.js"
|
||||
import { AgentAttachment, PromptMention } from "./prompt.js"
|
||||
import { optional, statics } from "./schema.js"
|
||||
|
||||
export interface FileAttachment extends Schema.Schema.Type<typeof FileAttachment> {}
|
||||
|
|
@ -9,7 +9,7 @@ export const FileAttachment = Schema.Struct({
|
|||
uri: Schema.String,
|
||||
name: Schema.String.pipe(optional),
|
||||
description: Schema.String.pipe(optional),
|
||||
source: Source.pipe(optional),
|
||||
mention: PromptMention.pipe(optional),
|
||||
})
|
||||
.annotate({ identifier: "PromptInput.FileAttachment" })
|
||||
.pipe(
|
||||
|
|
|
|||
|
|
@ -2,31 +2,46 @@ import { Schema } from "effect"
|
|||
import { optional } from "./schema.js"
|
||||
import { statics } from "./schema.js"
|
||||
|
||||
export interface Source extends Schema.Schema.Type<typeof Source> {}
|
||||
export const Source = Schema.Struct({
|
||||
export interface PromptMention extends Schema.Schema.Type<typeof PromptMention> {}
|
||||
export const PromptMention = Schema.Struct({
|
||||
start: Schema.Finite,
|
||||
end: Schema.Finite,
|
||||
text: Schema.String,
|
||||
}).annotate({ identifier: "Prompt.Source" })
|
||||
}).annotate({ identifier: "Prompt.Mention" })
|
||||
|
||||
export const FileSource = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("inline") }),
|
||||
Schema.Struct({ type: Schema.Literal("uri"), uri: Schema.String }),
|
||||
])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Prompt.FileSource" })
|
||||
export type FileSource = typeof FileSource.Type
|
||||
|
||||
export const Base64 = Schema.String.check(
|
||||
Schema.isPattern(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/),
|
||||
).annotate({ identifier: "Prompt.Base64" })
|
||||
export type Base64 = typeof Base64.Type
|
||||
|
||||
export interface FileAttachment extends Schema.Schema.Type<typeof FileAttachment> {}
|
||||
export const FileAttachment = Schema.Struct({
|
||||
uri: Schema.String,
|
||||
data: Base64,
|
||||
mime: Schema.String,
|
||||
source: FileSource,
|
||||
name: Schema.String.pipe(optional),
|
||||
description: Schema.String.pipe(optional),
|
||||
source: Source.pipe(optional),
|
||||
mention: PromptMention.pipe(optional),
|
||||
})
|
||||
.annotate({ identifier: "Prompt.FileAttachment" })
|
||||
.pipe(
|
||||
statics((schema) => ({
|
||||
create: (input: FileAttachment) =>
|
||||
schema.make({
|
||||
uri: input.uri,
|
||||
data: input.data,
|
||||
mime: input.mime,
|
||||
source: input.source,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
source: input.source,
|
||||
mention: input.mention,
|
||||
}),
|
||||
})),
|
||||
)
|
||||
|
|
@ -34,7 +49,7 @@ export const FileAttachment = Schema.Struct({
|
|||
export interface AgentAttachment extends Schema.Schema.Type<typeof AgentAttachment> {}
|
||||
export const AgentAttachment = Schema.Struct({
|
||||
name: Schema.String,
|
||||
source: Source.pipe(optional),
|
||||
mention: PromptMention.pipe(optional),
|
||||
}).annotate({ identifier: "Prompt.AgentAttachment" })
|
||||
|
||||
export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export {
|
|||
Location,
|
||||
Model,
|
||||
Prompt,
|
||||
PromptInput,
|
||||
Provider,
|
||||
RelativePath,
|
||||
Session,
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ it.live(
|
|||
const active = yield* opencode.sessions.active()
|
||||
const admitted = yield* opencode.sessions.prompt({
|
||||
sessionID: id,
|
||||
prompt: fixture.sdk.Prompt.make({ text: "Do not run" }),
|
||||
prompt: fixture.sdk.PromptInput.Prompt.make({ text: "Do not run" }),
|
||||
resume: false,
|
||||
})
|
||||
const context = yield* opencode.sessions.context({ sessionID: id })
|
||||
|
|
@ -192,7 +192,7 @@ it.live(
|
|||
const remainingContextEntries = yield* opencode.sessions.instructions.entry.list({ sessionID: id })
|
||||
const wake = yield* opencode.sessions.prompt({
|
||||
sessionID: id,
|
||||
prompt: fixture.sdk.Prompt.make({ text: "Promote this input" }),
|
||||
prompt: fixture.sdk.PromptInput.Prompt.make({ text: "Promote this input" }),
|
||||
})
|
||||
const prompted = yield* opencode.sessions.log({ sessionID: id, follow: true }).pipe(
|
||||
Stream.filter((event) => event.type === "session.prompt.promoted" && event.data.inputID === wake.id),
|
||||
|
|
@ -282,7 +282,7 @@ it.live(
|
|||
yield* opencode.sessions.create({ id, location: location(fixture) })
|
||||
yield* opencode.sessions.prompt({
|
||||
sessionID: id,
|
||||
prompt: fixture.sdk.Prompt.make({ text: "Observe this input" }),
|
||||
prompt: fixture.sdk.PromptInput.Prompt.make({ text: "Observe this input" }),
|
||||
})
|
||||
|
||||
const event = yield* Deferred.await(prompted).pipe(Effect.timeout("4 seconds"))
|
||||
|
|
|
|||
|
|
@ -3255,23 +3255,35 @@ export type LocationRef = {
|
|||
workspaceID?: string
|
||||
}
|
||||
|
||||
export type PromptSource = {
|
||||
export type PromptBase64 = string
|
||||
|
||||
export type PromptFileSource =
|
||||
| {
|
||||
type: "inline"
|
||||
}
|
||||
| {
|
||||
type: "uri"
|
||||
uri: string
|
||||
}
|
||||
|
||||
export type PromptMention = {
|
||||
start: number
|
||||
end: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export type PromptFileAttachment = {
|
||||
uri: string
|
||||
data: PromptBase64
|
||||
mime: string
|
||||
source: PromptFileSource
|
||||
name?: string
|
||||
description?: string
|
||||
source?: PromptSource
|
||||
mention?: PromptMention
|
||||
}
|
||||
|
||||
export type PromptAgentAttachment = {
|
||||
name: string
|
||||
source?: PromptSource
|
||||
mention?: PromptMention
|
||||
}
|
||||
|
||||
export type SessionErrorUnknown = {
|
||||
|
|
@ -4278,7 +4290,7 @@ export type PromptInputFileAttachment = {
|
|||
uri: string
|
||||
name?: string
|
||||
description?: string
|
||||
source?: PromptSource
|
||||
mention?: PromptMention
|
||||
}
|
||||
|
||||
export type SessionInputAdmitted = {
|
||||
|
|
@ -7876,7 +7888,7 @@ export type MessageNotFoundErrorV2 = {
|
|||
message: string
|
||||
}
|
||||
|
||||
export type PromptSource2 = {
|
||||
export type PromptMention2 = {
|
||||
start: number
|
||||
end: number
|
||||
text: string
|
||||
|
|
@ -7886,12 +7898,12 @@ export type PromptInputFileAttachment2 = {
|
|||
uri: string
|
||||
name?: string
|
||||
description?: string
|
||||
source?: PromptSource2
|
||||
mention?: PromptMention2
|
||||
}
|
||||
|
||||
export type PromptAgentAttachment2 = {
|
||||
name: string
|
||||
source?: PromptSource2
|
||||
mention?: PromptMention2
|
||||
}
|
||||
|
||||
export type PromptInputV2 = {
|
||||
|
|
@ -7900,12 +7912,24 @@ export type PromptInputV2 = {
|
|||
agents?: Array<PromptAgentAttachment2>
|
||||
}
|
||||
|
||||
export type PromptBase642 = string
|
||||
|
||||
export type PromptFileSource2 =
|
||||
| {
|
||||
type: "inline"
|
||||
}
|
||||
| {
|
||||
type: "uri"
|
||||
uri: string
|
||||
}
|
||||
|
||||
export type PromptFileAttachment2 = {
|
||||
uri: string
|
||||
data: PromptBase642
|
||||
mime: string
|
||||
source: PromptFileSource2
|
||||
name?: string
|
||||
description?: string
|
||||
source?: PromptSource2
|
||||
mention?: PromptMention2
|
||||
}
|
||||
|
||||
export type PromptV2 = {
|
||||
|
|
@ -15963,7 +15987,7 @@ export type V2SessionPromptErrors = {
|
|||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestErrorV2
|
||||
400: InvalidRequestError1 | InvalidRequestErrorV2
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
|
|
@ -16014,7 +16038,7 @@ export type V2SessionCommandErrors = {
|
|||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestErrorV2
|
||||
400: InvalidRequestError1 | InvalidRequestErrorV2
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
CommandEvaluationError,
|
||||
CommandNotFoundError,
|
||||
InvalidCursorError,
|
||||
InvalidRequestError,
|
||||
MessageNotFoundError,
|
||||
ServiceUnavailableError,
|
||||
SessionBusyError,
|
||||
|
|
@ -213,6 +214,9 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||
}),
|
||||
),
|
||||
),
|
||||
Effect.catchTag("Session.AttachmentError", (error) =>
|
||||
Effect.fail(new InvalidRequestError({ message: error.message, field: "prompt.files" })),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
|
|
@ -267,6 +271,9 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||
}),
|
||||
),
|
||||
),
|
||||
Effect.catchTag("Session.AttachmentError", (error) =>
|
||||
Effect.fail(new InvalidRequestError({ message: error.message, field: "files" })),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1039,7 +1039,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
enabled: () => {
|
||||
const current = promptRef.current
|
||||
if (!current?.focused) return true
|
||||
return current.current.input === ""
|
||||
return current.current.text === ""
|
||||
},
|
||||
bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]),
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -40,9 +40,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
|||
return entries
|
||||
.map((entry, index) => {
|
||||
const isDeleting = toDelete() === index
|
||||
const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1
|
||||
const lineCount = (entry.prompt.text.match(/\n/g)?.length ?? 0) + 1
|
||||
return {
|
||||
title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.input),
|
||||
title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.prompt.text),
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
value: index,
|
||||
description: getRelativeTime(entry.timestamp),
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { useTheme, selectedForeground } from "../../context/theme"
|
|||
import { SplitBorder } from "../../ui/border"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { Locale } from "../../util/locale"
|
||||
import type { PromptInfo } from "../../prompt/history"
|
||||
import type { PromptInfo, PromptPartRef } from "../../prompt/history"
|
||||
import { useFrecency } from "../../prompt/frecency"
|
||||
import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap"
|
||||
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
|
||||
|
|
@ -76,7 +76,7 @@ export function Autocomplete(props: {
|
|||
value: string
|
||||
sessionID?: string
|
||||
setPrompt: (input: (prompt: PromptInfo) => void) => void
|
||||
setExtmark: (partIndex: number, extmarkId: number) => void
|
||||
setExtmark: (part: PromptPartRef, extmarkId: number) => void
|
||||
anchor: () => BoxRenderable
|
||||
input: () => TextareaRenderable
|
||||
ref: (ref: AutocompleteRef) => void
|
||||
|
|
@ -169,7 +169,12 @@ export function Autocomplete(props: {
|
|||
setStore("input", "keyboard")
|
||||
})
|
||||
|
||||
function insertPart(text: string, part: PromptInfo["parts"][number]) {
|
||||
function insertPart(
|
||||
text: string,
|
||||
part:
|
||||
| { type: "file"; value: NonNullable<PromptInfo["files"]>[number]; path?: string }
|
||||
| { type: "agent"; value: NonNullable<PromptInfo["agents"]>[number] },
|
||||
) {
|
||||
const input = props.input()
|
||||
const currentCursorOffset = input.cursorOffset
|
||||
|
||||
|
|
@ -189,7 +194,7 @@ export function Autocomplete(props: {
|
|||
const extmarkStart = store.index
|
||||
const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText)
|
||||
|
||||
const styleId = part.type === "file" ? props.fileStyleId : part.type === "agent" ? props.agentStyleId : undefined
|
||||
const styleId = part.type === "file" ? props.fileStyleId : props.agentStyleId
|
||||
|
||||
const extmarkId = input.extmarks.create({
|
||||
start: extmarkStart,
|
||||
|
|
@ -201,42 +206,40 @@ export function Autocomplete(props: {
|
|||
|
||||
props.setPrompt((draft) => {
|
||||
if (part.type === "file") {
|
||||
const existingIndex = draft.parts.findIndex((p) => p.type === "file" && "url" in p && p.url === part.url)
|
||||
const files = (draft.files ??= [])
|
||||
const existingIndex = files.findIndex((file) => file.uri === part.value.uri)
|
||||
if (existingIndex !== -1) {
|
||||
const existing = draft.parts[existingIndex]
|
||||
if (
|
||||
part.source?.text &&
|
||||
existing &&
|
||||
"source" in existing &&
|
||||
existing.source &&
|
||||
"text" in existing.source &&
|
||||
existing.source.text
|
||||
) {
|
||||
existing.source.text.start = extmarkStart
|
||||
existing.source.text.end = extmarkEnd
|
||||
existing.source.text.value = virtualText
|
||||
const existing = files[existingIndex]
|
||||
if (existing?.mention) {
|
||||
existing.mention.start = extmarkStart
|
||||
existing.mention.end = extmarkEnd
|
||||
existing.mention.text = virtualText
|
||||
}
|
||||
return
|
||||
}
|
||||
if (part.value.mention) {
|
||||
part.value.mention.start = extmarkStart
|
||||
part.value.mention.end = extmarkEnd
|
||||
part.value.mention.text = virtualText
|
||||
}
|
||||
const index = files.length
|
||||
files.push(part.value)
|
||||
props.setExtmark({ type: "file", index }, extmarkId)
|
||||
return
|
||||
}
|
||||
|
||||
if (part.type === "file" && part.source?.text) {
|
||||
part.source.text.start = extmarkStart
|
||||
part.source.text.end = extmarkEnd
|
||||
part.source.text.value = virtualText
|
||||
} else if (part.type === "agent" && part.source) {
|
||||
part.source.start = extmarkStart
|
||||
part.source.end = extmarkEnd
|
||||
part.source.value = virtualText
|
||||
const agents = (draft.agents ??= [])
|
||||
if (part.value.mention) {
|
||||
part.value.mention.start = extmarkStart
|
||||
part.value.mention.end = extmarkEnd
|
||||
part.value.mention.text = virtualText
|
||||
}
|
||||
const partIndex = draft.parts.length
|
||||
draft.parts.push(part)
|
||||
props.setExtmark(partIndex, extmarkId)
|
||||
const index = agents.length
|
||||
agents.push(part.value)
|
||||
props.setExtmark({ type: "agent", index }, extmarkId)
|
||||
})
|
||||
|
||||
if (part.type === "file" && part.source && part.source.type === "file") {
|
||||
frecency.updateFrecency(part.source.path)
|
||||
}
|
||||
if (part.type === "file" && part.path) frecency.updateFrecency(part.path)
|
||||
}
|
||||
|
||||
function createFilePart(
|
||||
|
|
@ -261,17 +264,11 @@ export function Autocomplete(props: {
|
|||
filename,
|
||||
part: {
|
||||
type: "file" as const,
|
||||
mime: item.type === "directory" ? "application/x-directory" : "text/plain",
|
||||
filename,
|
||||
url: urlObj.href,
|
||||
source: {
|
||||
type: "file" as const,
|
||||
text: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
value: "",
|
||||
},
|
||||
path: item.path,
|
||||
path: item.path,
|
||||
value: {
|
||||
uri: urlObj.href,
|
||||
name: filename,
|
||||
mention: { start: 0, end: 0, text: "" },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -375,18 +372,11 @@ export function Autocomplete(props: {
|
|||
onSelect: () => {
|
||||
insertPart(res.name, {
|
||||
type: "file",
|
||||
mime: res.mimeType ?? "text/plain",
|
||||
filename: res.name,
|
||||
url: res.uri,
|
||||
source: {
|
||||
type: "resource",
|
||||
text: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
value: "",
|
||||
},
|
||||
clientName: res.client,
|
||||
value: {
|
||||
uri: res.uri,
|
||||
name: res.name,
|
||||
description: res.description,
|
||||
mention: { start: 0, end: 0, text: "" },
|
||||
},
|
||||
})
|
||||
},
|
||||
|
|
@ -405,11 +395,9 @@ export function Autocomplete(props: {
|
|||
onSelect: () => {
|
||||
insertPart(agent.id, {
|
||||
type: "agent",
|
||||
name: agent.id,
|
||||
source: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
value: "",
|
||||
value: {
|
||||
name: agent.id,
|
||||
mention: { start: 0, end: 0, text: "" },
|
||||
},
|
||||
})
|
||||
},
|
||||
|
|
@ -427,13 +415,11 @@ export function Autocomplete(props: {
|
|||
onSelect: () => {
|
||||
insertPart(reference.name, {
|
||||
type: "file",
|
||||
mime: "application/x-directory",
|
||||
filename: reference.name,
|
||||
url: pathToFileURL(reference.path).href,
|
||||
source: {
|
||||
type: "file",
|
||||
text: { start: 0, end: 0, value: "" },
|
||||
path: reference.name,
|
||||
path: reference.name,
|
||||
value: {
|
||||
uri: pathToFileURL(reference.path).href,
|
||||
name: reference.name,
|
||||
mention: { start: 0, end: 0, text: "" },
|
||||
},
|
||||
})
|
||||
},
|
||||
|
|
@ -667,7 +653,7 @@ export function Autocomplete(props: {
|
|||
props.input().deleteRange(0, 0, cursor.row, cursor.col)
|
||||
// Sync the prompt store immediately since onContentChange is async
|
||||
props.setPrompt((draft) => {
|
||||
draft.input = props.input().plainText
|
||||
draft.text = props.input().plainText
|
||||
})
|
||||
}
|
||||
setStore("visible", false)
|
||||
|
|
|
|||
|
|
@ -30,14 +30,14 @@ import { normalizePromptContent, openEditor } from "../../editor"
|
|||
import { useExit } from "../../context/exit"
|
||||
import { promptOffsetWidth } from "../../prompt/display"
|
||||
import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import { usePromptHistory, type PromptInfo } from "../../prompt/history"
|
||||
import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history"
|
||||
import { computePromptTraits } from "../../prompt/traits"
|
||||
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
|
||||
import { usePromptStash } from "../../prompt/stash"
|
||||
import { DialogStash } from "../dialog-stash"
|
||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import type { AssistantMessage, FilePart, SessionV2Info, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import type { AssistantMessage, SessionV2Info, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { createColors, createFrames } from "../../ui/spinner"
|
||||
|
|
@ -313,17 +313,14 @@ export function Prompt(props: PromptProps) {
|
|||
const [store, setStore] = createStore<{
|
||||
prompt: PromptInfo
|
||||
mode: "normal" | "shell"
|
||||
extmarkToPartIndex: Map<number, number>
|
||||
extmarkToPart: Map<number, PromptPartRef>
|
||||
interrupt: number
|
||||
placeholder: number
|
||||
}>({
|
||||
placeholder: randomIndex(list().length),
|
||||
prompt: {
|
||||
input: "",
|
||||
parts: [],
|
||||
},
|
||||
prompt: emptyPrompt(),
|
||||
mode: "normal",
|
||||
extmarkToPartIndex: new Map(),
|
||||
extmarkToPart: new Map(),
|
||||
interrupt: 0,
|
||||
})
|
||||
|
||||
|
|
@ -400,8 +397,7 @@ export function Prompt(props: PromptProps) {
|
|||
if (content?.mime.startsWith("image/")) {
|
||||
await pasteAttachment({
|
||||
filename: "clipboard",
|
||||
mime: content.mime,
|
||||
content: content.data,
|
||||
uri: `data:${content.mime};base64,${content.data}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
|
@ -467,14 +463,10 @@ export function Prompt(props: PromptProps) {
|
|||
dialog.clear()
|
||||
|
||||
// replace summarized text parts with the actual text
|
||||
const text = store.prompt.parts
|
||||
.filter((p) => p.type === "text")
|
||||
.reduce((acc, p) => {
|
||||
if (!p.source) return acc
|
||||
return acc.replace(p.source.text.value, p.text)
|
||||
}, store.prompt.input)
|
||||
|
||||
const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text")
|
||||
const text = store.prompt.pasted.reduce(
|
||||
(result, part) => result.replace(part.source.text, part.text),
|
||||
store.prompt.text,
|
||||
)
|
||||
|
||||
const value = text
|
||||
const content = await openEditor({
|
||||
|
|
@ -490,63 +482,23 @@ export function Prompt(props: PromptProps) {
|
|||
|
||||
input.setText(normalized)
|
||||
|
||||
// Update positions for nonTextParts based on their location in new content
|
||||
// Filter out parts whose virtual text was deleted
|
||||
// Update attachment positions and drop virtual text deleted in the editor.
|
||||
// this handles a case where the user edits the text in the editor
|
||||
// such that the virtual text moves around or is deleted
|
||||
const updatedNonTextParts = nonTextParts
|
||||
.map((part) => {
|
||||
let virtualText = ""
|
||||
if (part.type === "file" && part.source?.text) {
|
||||
virtualText = part.source.text.value
|
||||
} else if (part.type === "agent" && part.source) {
|
||||
virtualText = part.source.value
|
||||
}
|
||||
|
||||
if (!virtualText) return part
|
||||
|
||||
const newStart = normalized.indexOf(virtualText)
|
||||
// if the virtual text is deleted, remove the part
|
||||
if (newStart === -1) return null
|
||||
|
||||
const newEnd = newStart + virtualText.length
|
||||
|
||||
if (part.type === "file" && part.source?.text) {
|
||||
return {
|
||||
...part,
|
||||
source: {
|
||||
...part.source,
|
||||
text: {
|
||||
...part.source.text,
|
||||
start: newStart,
|
||||
end: newEnd,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (part.type === "agent" && part.source) {
|
||||
return {
|
||||
...part,
|
||||
source: {
|
||||
...part.source,
|
||||
start: newStart,
|
||||
end: newEnd,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return part
|
||||
})
|
||||
.filter((part) => part !== null)
|
||||
const moveMention = <Part extends { mention?: { start: number; end: number; text: string } }>(part: Part) => {
|
||||
if (!part.mention?.text) return part
|
||||
const start = normalized.indexOf(part.mention.text)
|
||||
if (start === -1) return
|
||||
return { ...part, mention: { ...part.mention, start, end: start + part.mention.text.length } }
|
||||
}
|
||||
|
||||
setStore("prompt", {
|
||||
input: normalized,
|
||||
// keep only the non-text parts because the text parts were
|
||||
// already expanded inline
|
||||
parts: updatedNonTextParts,
|
||||
text: normalized,
|
||||
files: store.prompt.files?.map(moveMention).filter((part) => part !== undefined),
|
||||
agents: store.prompt.agents?.map(moveMention).filter((part) => part !== undefined),
|
||||
pasted: [],
|
||||
})
|
||||
restoreExtmarksFromParts(updatedNonTextParts)
|
||||
restoreExtmarksFromPrompt(store.prompt)
|
||||
input.cursorOffset = Bun.stringWidth(normalized)
|
||||
},
|
||||
},
|
||||
|
|
@ -562,8 +514,8 @@ export function Prompt(props: PromptProps) {
|
|||
onSelect={(skill) => {
|
||||
input.setText(`/${skill} `)
|
||||
setStore("prompt", {
|
||||
input: `/${skill} `,
|
||||
parts: [],
|
||||
...emptyPrompt(),
|
||||
text: `/${skill} `,
|
||||
})
|
||||
input.gotoBufferEnd()
|
||||
}}
|
||||
|
|
@ -633,19 +585,16 @@ export function Prompt(props: PromptProps) {
|
|||
input.blur()
|
||||
},
|
||||
set(prompt) {
|
||||
input.setText(prompt.input)
|
||||
input.setText(prompt.text)
|
||||
setStore("prompt", prompt)
|
||||
restoreExtmarksFromParts(prompt.parts)
|
||||
restoreExtmarksFromPrompt(prompt)
|
||||
input.gotoBufferEnd()
|
||||
},
|
||||
reset() {
|
||||
input.clear()
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", {
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
setStore("prompt", emptyPrompt())
|
||||
setStore("extmarkToPart", new Map())
|
||||
},
|
||||
submit() {
|
||||
void submit()
|
||||
|
|
@ -655,17 +604,17 @@ export function Prompt(props: PromptProps) {
|
|||
onMount(() => {
|
||||
const saved = stashed
|
||||
stashed = undefined
|
||||
if (store.prompt.input) return
|
||||
if (saved && saved.prompt.input) {
|
||||
input.setText(saved.prompt.input)
|
||||
if (store.prompt.text) return
|
||||
if (saved && saved.prompt.text) {
|
||||
input.setText(saved.prompt.text)
|
||||
setStore("prompt", saved.prompt)
|
||||
restoreExtmarksFromParts(saved.prompt.parts)
|
||||
restoreExtmarksFromPrompt(saved.prompt)
|
||||
input.cursorOffset = saved.cursor
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (store.prompt.input) {
|
||||
if (store.prompt.text) {
|
||||
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
||||
}
|
||||
setInputTarget(undefined)
|
||||
|
|
@ -695,44 +644,40 @@ export function Prompt(props: PromptProps) {
|
|||
}
|
||||
})
|
||||
|
||||
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
|
||||
function restoreExtmarksFromPrompt(prompt: PromptInfo) {
|
||||
input.extmarks.clear()
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
setStore("extmarkToPart", new Map())
|
||||
|
||||
parts.forEach((part, partIndex) => {
|
||||
let start = 0
|
||||
let end = 0
|
||||
let virtualText = ""
|
||||
let styleId: number | undefined
|
||||
const parts = [
|
||||
...(prompt.files ?? []).map((part, index) => ({
|
||||
mention: part.mention,
|
||||
ref: { type: "file" as const, index },
|
||||
styleId: fileStyleId,
|
||||
})),
|
||||
...(prompt.agents ?? []).map((part, index) => ({
|
||||
mention: part.mention,
|
||||
ref: { type: "agent" as const, index },
|
||||
styleId: agentStyleId,
|
||||
})),
|
||||
...prompt.pasted.map((part, index) => ({
|
||||
mention: part.source,
|
||||
ref: { type: "pasted" as const, index },
|
||||
styleId: pasteStyleId,
|
||||
})),
|
||||
]
|
||||
|
||||
if (part.type === "file" && part.source?.text) {
|
||||
start = part.source.text.start
|
||||
end = part.source.text.end
|
||||
virtualText = part.source.text.value
|
||||
styleId = fileStyleId
|
||||
} else if (part.type === "agent" && part.source) {
|
||||
start = part.source.start
|
||||
end = part.source.end
|
||||
virtualText = part.source.value
|
||||
styleId = agentStyleId
|
||||
} else if (part.type === "text" && part.source?.text) {
|
||||
start = part.source.text.start
|
||||
end = part.source.text.end
|
||||
virtualText = part.source.text.value
|
||||
styleId = pasteStyleId
|
||||
}
|
||||
|
||||
if (virtualText) {
|
||||
parts.forEach(({ mention, ref, styleId }) => {
|
||||
if (mention?.text) {
|
||||
const extmarkId = input.extmarks.create({
|
||||
start,
|
||||
end,
|
||||
start: mention.start,
|
||||
end: mention.end,
|
||||
virtual: true,
|
||||
styleId,
|
||||
typeId: promptPartTypeId,
|
||||
})
|
||||
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
|
||||
setStore("extmarkToPart", (map: Map<number, PromptPartRef>) => {
|
||||
const newMap = new Map(map)
|
||||
newMap.set(extmarkId, partIndex)
|
||||
newMap.set(extmarkId, ref)
|
||||
return newMap
|
||||
})
|
||||
}
|
||||
|
|
@ -743,32 +688,47 @@ export function Prompt(props: PromptProps) {
|
|||
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const newMap = new Map<number, number>()
|
||||
const newParts: typeof draft.prompt.parts = []
|
||||
const newMap = new Map<number, PromptPartRef>()
|
||||
const files: NonNullable<PromptInfo["files"]> = []
|
||||
const agents: NonNullable<PromptInfo["agents"]> = []
|
||||
const pasted: PromptInfo["pasted"] = []
|
||||
|
||||
for (const extmark of allExtmarks) {
|
||||
const partIndex = draft.extmarkToPartIndex.get(extmark.id)
|
||||
if (partIndex !== undefined) {
|
||||
const part = draft.prompt.parts[partIndex]
|
||||
if (part) {
|
||||
if (part.type === "agent" && part.source) {
|
||||
part.source.start = extmark.start
|
||||
part.source.end = extmark.end
|
||||
} else if (part.type === "file" && part.source?.text) {
|
||||
part.source.text.start = extmark.start
|
||||
part.source.text.end = extmark.end
|
||||
} else if (part.type === "text" && part.source?.text) {
|
||||
part.source.text.start = extmark.start
|
||||
part.source.text.end = extmark.end
|
||||
}
|
||||
newMap.set(extmark.id, newParts.length)
|
||||
newParts.push(part)
|
||||
}
|
||||
const ref = draft.extmarkToPart.get(extmark.id)
|
||||
if (!ref) continue
|
||||
if (ref.type === "file") {
|
||||
const part = draft.prompt.files?.[ref.index]
|
||||
if (!part?.mention) continue
|
||||
part.mention.start = extmark.start
|
||||
part.mention.end = extmark.end
|
||||
const index = files.length
|
||||
files.push(part)
|
||||
newMap.set(extmark.id, { type: "file", index })
|
||||
continue
|
||||
}
|
||||
if (ref.type === "agent") {
|
||||
const part = draft.prompt.agents?.[ref.index]
|
||||
if (!part?.mention) continue
|
||||
part.mention.start = extmark.start
|
||||
part.mention.end = extmark.end
|
||||
const index = agents.length
|
||||
agents.push(part)
|
||||
newMap.set(extmark.id, { type: "agent", index })
|
||||
continue
|
||||
}
|
||||
const part = draft.prompt.pasted[ref.index]
|
||||
if (!part) continue
|
||||
part.source.start = extmark.start
|
||||
part.source.end = extmark.end
|
||||
const index = pasted.length
|
||||
pasted.push(part)
|
||||
newMap.set(extmark.id, { type: "pasted", index })
|
||||
}
|
||||
|
||||
draft.extmarkToPartIndex = newMap
|
||||
draft.prompt.parts = newParts
|
||||
draft.extmarkToPart = newMap
|
||||
draft.prompt.files = files
|
||||
draft.prompt.agents = agents
|
||||
draft.prompt.pasted = pasted
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -779,17 +739,14 @@ export function Prompt(props: PromptProps) {
|
|||
title: "Stash prompt",
|
||||
name: "prompt.stash",
|
||||
category: "Prompt",
|
||||
enabled: !!store.prompt.input,
|
||||
enabled: !!store.prompt.text,
|
||||
run: () => {
|
||||
if (!store.prompt.input) return
|
||||
stash.push({
|
||||
input: store.prompt.input,
|
||||
parts: store.prompt.parts,
|
||||
})
|
||||
if (!store.prompt.text) return
|
||||
stash.push({ prompt: store.prompt })
|
||||
input.extmarks.clear()
|
||||
input.clear()
|
||||
setStore("prompt", { input: "", parts: [] })
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
setStore("prompt", emptyPrompt())
|
||||
setStore("extmarkToPart", new Map())
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
|
|
@ -801,9 +758,9 @@ export function Prompt(props: PromptProps) {
|
|||
run: () => {
|
||||
const entry = stash.pop()
|
||||
if (entry) {
|
||||
input.setText(entry.input)
|
||||
setStore("prompt", { input: entry.input, parts: entry.parts })
|
||||
restoreExtmarksFromParts(entry.parts)
|
||||
input.setText(entry.prompt.text)
|
||||
setStore("prompt", entry.prompt)
|
||||
restoreExtmarksFromPrompt(entry.prompt)
|
||||
input.gotoBufferEnd()
|
||||
}
|
||||
dialog.clear()
|
||||
|
|
@ -818,9 +775,9 @@ export function Prompt(props: PromptProps) {
|
|||
dialog.replace(() => (
|
||||
<DialogStash
|
||||
onSelect={(entry) => {
|
||||
input.setText(entry.input)
|
||||
setStore("prompt", { input: entry.input, parts: entry.parts })
|
||||
restoreExtmarksFromParts(entry.parts)
|
||||
input.setText(entry.prompt.text)
|
||||
setStore("prompt", entry.prompt)
|
||||
restoreExtmarksFromPrompt(entry.prompt)
|
||||
input.gotoBufferEnd()
|
||||
}}
|
||||
/>
|
||||
|
|
@ -848,7 +805,7 @@ export function Prompt(props: PromptProps) {
|
|||
useBindings(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "",
|
||||
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
|
||||
bindings: tuiConfig.keybinds.get("prompt.clear"),
|
||||
}
|
||||
})
|
||||
|
|
@ -919,10 +876,10 @@ export function Prompt(props: PromptProps) {
|
|||
|
||||
const item = history.move(-1, input.plainText)
|
||||
if (!item) return false
|
||||
input.setText(item.input)
|
||||
input.setText(item.text)
|
||||
setStore("prompt", item)
|
||||
setStore("mode", item.mode ?? "normal")
|
||||
restoreExtmarksFromParts(item.parts)
|
||||
restoreExtmarksFromPrompt(item)
|
||||
input.cursorOffset = 0
|
||||
},
|
||||
},
|
||||
|
|
@ -955,10 +912,10 @@ export function Prompt(props: PromptProps) {
|
|||
|
||||
const item = history.move(1, input.plainText)
|
||||
if (!item) return false
|
||||
input.setText(item.input)
|
||||
input.setText(item.text)
|
||||
setStore("prompt", item)
|
||||
setStore("mode", item.mode ?? "normal")
|
||||
restoreExtmarksFromParts(item.parts)
|
||||
restoreExtmarksFromPrompt(item)
|
||||
input.cursorOffset = input.plainText.length
|
||||
},
|
||||
},
|
||||
|
|
@ -972,7 +929,7 @@ export function Prompt(props: PromptProps) {
|
|||
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
|
||||
// input's native onSubmit racing another dispatch). Without this guard,
|
||||
// a second call slips past the empty-input check before the first call
|
||||
// clears `store.prompt.input`, then awaits its own `session.create` and
|
||||
// clears `store.prompt.text`, then awaits its own `session.create` and
|
||||
// ultimately reads the now-empty store — sending a phantom empty prompt
|
||||
// to a freshly created session.
|
||||
if (submitting) return false
|
||||
|
|
@ -990,17 +947,17 @@ export function Prompt(props: PromptProps) {
|
|||
// IME: double-defer may fire before onContentChange flushes the last
|
||||
// composed character (e.g. Korean hangul) to the store, so read
|
||||
// plainText directly and sync before any downstream reads.
|
||||
if (input && !input.isDestroyed && input.plainText !== store.prompt.input) {
|
||||
setStore("prompt", "input", input.plainText)
|
||||
if (input && !input.isDestroyed && input.plainText !== store.prompt.text) {
|
||||
setStore("prompt", "text", input.plainText)
|
||||
syncExtmarksWithPromptParts()
|
||||
}
|
||||
if (props.disabled) return false
|
||||
if (workspace.creating() || move.creating()) return false
|
||||
if (auto()?.visible) return false
|
||||
if (!store.prompt.input) return false
|
||||
if (!store.prompt.text) return false
|
||||
const agent = local.agent.current()
|
||||
if (!agent) return false
|
||||
const trimmed = store.prompt.input.trim()
|
||||
const trimmed = store.prompt.text.trim()
|
||||
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
||||
void exit()
|
||||
return true
|
||||
|
|
@ -1034,7 +991,7 @@ export function Prompt(props: PromptProps) {
|
|||
const selectedWorkspace = workspace.selection()
|
||||
const workspaceID = selectedWorkspace?.type === "existing" ? selectedWorkspace.workspaceID : undefined
|
||||
|
||||
const directory = await move.getDirectory(store.prompt.input)
|
||||
const directory = await move.getDirectory(store.prompt.text)
|
||||
if (move.pending() && !directory) return false
|
||||
finishMoveProgress = Boolean(move.progress())
|
||||
const location = data.location.default()
|
||||
|
|
@ -1069,18 +1026,16 @@ export function Prompt(props: PromptProps) {
|
|||
}
|
||||
|
||||
const inputText = expandTrackedPastedText(
|
||||
store.prompt.input,
|
||||
store.prompt.text,
|
||||
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
|
||||
const partIndex = store.extmarkToPartIndex.get(extmark.id)
|
||||
const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex]
|
||||
if (part?.type !== "text") return []
|
||||
const ref = store.extmarkToPart.get(extmark.id)
|
||||
if (ref?.type !== "pasted") return []
|
||||
const part = store.prompt.pasted[ref.index]
|
||||
if (!part) return []
|
||||
return [{ start: extmark.start, end: extmark.end, text: part.text }]
|
||||
}),
|
||||
)
|
||||
|
||||
// Filter out text parts (pasted content) since they're now expanded inline
|
||||
const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
|
||||
|
||||
// Capture mode before it gets reset
|
||||
const currentMode = store.mode
|
||||
const editorSelection = editorContext()
|
||||
|
|
@ -1129,35 +1084,8 @@ export function Prompt(props: PromptProps) {
|
|||
arguments: args,
|
||||
agent: agent.id,
|
||||
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
|
||||
files: nonTextParts.flatMap((part) =>
|
||||
part.type === "file"
|
||||
? [
|
||||
{
|
||||
uri: part.url,
|
||||
name: part.filename,
|
||||
source: part.source
|
||||
? {
|
||||
start: part.source.text.start,
|
||||
end: part.source.text.end,
|
||||
text: part.source.text.value,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
agents: nonTextParts.flatMap((part) =>
|
||||
part.type === "agent"
|
||||
? [
|
||||
{
|
||||
name: part.name,
|
||||
source: part.source
|
||||
? { start: part.source.start, end: part.source.end, text: part.source.value }
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
files: store.prompt.files,
|
||||
agents: store.prompt.agents,
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
|
||||
|
|
@ -1207,35 +1135,8 @@ export function Prompt(props: PromptProps) {
|
|||
sessionID,
|
||||
prompt: {
|
||||
text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"),
|
||||
files: nonTextParts.flatMap((part) =>
|
||||
part.type === "file"
|
||||
? [
|
||||
{
|
||||
uri: part.url,
|
||||
name: part.filename,
|
||||
source: part.source
|
||||
? {
|
||||
start: part.source.text.start,
|
||||
end: part.source.text.end,
|
||||
text: part.source.text.value,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
agents: nonTextParts.flatMap((part) =>
|
||||
part.type === "agent"
|
||||
? [
|
||||
{
|
||||
name: part.name,
|
||||
source: part.source
|
||||
? { start: part.source.start, end: part.source.end, text: part.source.value }
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
files: store.prompt.files,
|
||||
agents: store.prompt.agents,
|
||||
},
|
||||
})
|
||||
.then(
|
||||
|
|
@ -1253,11 +1154,8 @@ export function Prompt(props: PromptProps) {
|
|||
mode: currentMode,
|
||||
})
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", {
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
setStore("prompt", emptyPrompt())
|
||||
setStore("extmarkToPart", new Map())
|
||||
props.onSubmit?.()
|
||||
|
||||
// temporary hack to make sure the message is sent
|
||||
|
|
@ -1292,19 +1190,12 @@ export function Prompt(props: PromptProps) {
|
|||
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const partIndex = draft.prompt.parts.length
|
||||
draft.prompt.parts.push({
|
||||
type: "text" as const,
|
||||
const index = draft.prompt.pasted.length
|
||||
draft.prompt.pasted.push({
|
||||
text,
|
||||
source: {
|
||||
text: {
|
||||
start: extmarkStart,
|
||||
end: extmarkEnd,
|
||||
value: virtualText,
|
||||
},
|
||||
},
|
||||
source: { start: extmarkStart, end: extmarkEnd, text: virtualText },
|
||||
})
|
||||
draft.extmarkToPartIndex.set(extmarkId, partIndex)
|
||||
draft.extmarkToPart.set(extmarkId, { type: "pasted", index })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -1324,9 +1215,7 @@ export function Prompt(props: PromptProps) {
|
|||
if (attachment?.type === "binary") {
|
||||
await pasteAttachment({
|
||||
filename,
|
||||
filepath,
|
||||
mime: attachment.mime,
|
||||
content: Buffer.from(attachment.content).toString("base64"),
|
||||
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
|
@ -1350,15 +1239,12 @@ export function Prompt(props: PromptProps) {
|
|||
}, 0)
|
||||
}
|
||||
|
||||
async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) {
|
||||
async function pasteAttachment(file: { filename?: string; uri: string }) {
|
||||
const currentOffset = input.cursorOffset
|
||||
const extmarkStart = currentOffset
|
||||
const pdf = file.mime === "application/pdf"
|
||||
const count = store.prompt.parts.filter((x) => {
|
||||
if (x.type !== "file") return false
|
||||
if (pdf) return x.mime === "application/pdf"
|
||||
return x.mime.startsWith("image/")
|
||||
}).length
|
||||
const pdf = file.uri.startsWith("data:application/pdf;")
|
||||
const prefix = pdf ? "data:application/pdf;" : "data:image/"
|
||||
const count = store.prompt.files?.filter((attachment) => attachment.uri.startsWith(prefix)).length ?? 0
|
||||
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
|
||||
const extmarkEnd = extmarkStart + virtualText.length
|
||||
const textToInsert = virtualText + " "
|
||||
|
|
@ -1373,33 +1259,33 @@ export function Prompt(props: PromptProps) {
|
|||
typeId: promptPartTypeId,
|
||||
})
|
||||
|
||||
const part: Omit<FilePart, "id" | "messageID" | "sessionID"> = {
|
||||
type: "file" as const,
|
||||
mime: file.mime,
|
||||
filename: file.filename,
|
||||
url: `data:${file.mime};base64,${file.content}`,
|
||||
source: {
|
||||
type: "file",
|
||||
path: file.filepath ?? file.filename ?? "",
|
||||
text: {
|
||||
start: extmarkStart,
|
||||
end: extmarkEnd,
|
||||
value: virtualText,
|
||||
},
|
||||
const part: NonNullable<PromptInfo["files"]>[number] = {
|
||||
uri: file.uri,
|
||||
name: file.filename,
|
||||
mention: {
|
||||
start: extmarkStart,
|
||||
end: extmarkEnd,
|
||||
text: virtualText,
|
||||
},
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const partIndex = draft.prompt.parts.length
|
||||
draft.prompt.parts.push(part)
|
||||
draft.extmarkToPartIndex.set(extmarkId, partIndex)
|
||||
const files = (draft.prompt.files ??= [])
|
||||
const index = files.length
|
||||
files.push(part)
|
||||
draft.extmarkToPart.set(extmarkId, { type: "file", index })
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
function clearPrompt() {
|
||||
if (store.prompt.input.trim().length >= DRAFT_RETENTION_MIN_CHARS || store.prompt.parts.length > 0) {
|
||||
if (
|
||||
store.prompt.text.trim().length >= DRAFT_RETENTION_MIN_CHARS ||
|
||||
store.prompt.pasted.length > 0 ||
|
||||
(store.prompt.files?.length ?? 0) > 0 ||
|
||||
(store.prompt.agents?.length ?? 0) > 0
|
||||
) {
|
||||
history.append({
|
||||
...store.prompt,
|
||||
mode: store.mode,
|
||||
|
|
@ -1407,11 +1293,8 @@ export function Prompt(props: PromptProps) {
|
|||
}
|
||||
input.clear()
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", {
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
setStore("prompt", emptyPrompt())
|
||||
setStore("extmarkToPart", new Map())
|
||||
}
|
||||
|
||||
const highlight = createMemo(() => {
|
||||
|
|
@ -1505,7 +1388,7 @@ export function Prompt(props: PromptProps) {
|
|||
maxHeight={maxHeight()}
|
||||
onContentChange={() => {
|
||||
const value = input.plainText
|
||||
setStore("prompt", "input", value)
|
||||
setStore("prompt", "text", value)
|
||||
auto()?.onInput(value)
|
||||
syncExtmarksWithPromptParts()
|
||||
setCursorVersion((value) => value + 1)
|
||||
|
|
@ -1550,7 +1433,7 @@ export function Prompt(props: PromptProps) {
|
|||
ref={(r: TextareaRenderable) => {
|
||||
input = r
|
||||
Object.assign(r, {
|
||||
getClipboardText: (text: string) => expandPastedTextPlaceholders(text, store.prompt.parts),
|
||||
getClipboardText: (text: string) => expandPastedTextPlaceholders(text, store.prompt.pasted),
|
||||
})
|
||||
setInputTarget(r)
|
||||
if (promptPartTypeId === 0) {
|
||||
|
|
@ -1763,14 +1646,14 @@ export function Prompt(props: PromptProps) {
|
|||
setPrompt={(cb) => {
|
||||
setStore("prompt", produce(cb))
|
||||
}}
|
||||
setExtmark={(partIndex, extmarkId) => {
|
||||
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
|
||||
setExtmark={(part, extmarkId) => {
|
||||
setStore("extmarkToPart", (map: Map<number, PromptPartRef>) => {
|
||||
const newMap = new Map(map)
|
||||
newMap.set(extmarkId, partIndex)
|
||||
newMap.set(extmarkId, part)
|
||||
return newMap
|
||||
})
|
||||
}}
|
||||
value={store.prompt.input}
|
||||
value={store.prompt.text}
|
||||
fileStyleId={fileStyleId}
|
||||
agentStyleId={agentStyleId}
|
||||
promptPartTypeId={() => promptPartTypeId}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,33 @@
|
|||
import path from "path"
|
||||
import { onMount } from "solid-js"
|
||||
import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2"
|
||||
import type { SessionPromptInput } from "@opencode-ai/client/promise"
|
||||
import type { Types } from "effect"
|
||||
import { createSimpleContext } from "../context/helper"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { appendText, readText, writeText } from "../util/persistence"
|
||||
|
||||
export type PromptInfo = {
|
||||
input: string
|
||||
mode?: "normal" | "shell"
|
||||
parts: (
|
||||
| Omit<FilePart, "id" | "messageID" | "sessionID">
|
||||
| Omit<AgentPart, "id" | "messageID" | "sessionID">
|
||||
| (Omit<TextPart, "id" | "messageID" | "sessionID"> & {
|
||||
source?: {
|
||||
text: {
|
||||
start: number
|
||||
end: number
|
||||
value: string
|
||||
}
|
||||
}
|
||||
})
|
||||
)[]
|
||||
export type PastedText = {
|
||||
text: string
|
||||
source: {
|
||||
start: number
|
||||
end: number
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
||||
export type PromptInfo = Types.DeepMutable<SessionPromptInput["prompt"]> & {
|
||||
pasted: PastedText[]
|
||||
mode?: "normal" | "shell"
|
||||
}
|
||||
|
||||
export type PromptPartRef = {
|
||||
type: "file" | "agent" | "pasted"
|
||||
index: number
|
||||
}
|
||||
|
||||
export const emptyPrompt = (): PromptInfo => ({ text: "", files: [], agents: [], pasted: [] })
|
||||
|
||||
export const MAX_HISTORY_ENTRIES = 50
|
||||
|
||||
export function parsePromptHistory(text: string) {
|
||||
|
|
@ -32,7 +36,7 @@ export function parsePromptHistory(text: string) {
|
|||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as PromptInfo
|
||||
return parsePromptInfo(JSON.parse(line))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
|
|
@ -46,6 +50,13 @@ export function isDuplicateEntry(previous: PromptInfo | undefined, next: PromptI
|
|||
return JSON.stringify(previous) === JSON.stringify(next)
|
||||
}
|
||||
|
||||
export function parsePromptInfo(value: unknown): PromptInfo | undefined {
|
||||
if (!value || typeof value !== "object") return
|
||||
const input = value as Record<string, unknown>
|
||||
if (typeof input.text !== "string" || !Array.isArray(input.pasted)) return
|
||||
return input as PromptInfo
|
||||
}
|
||||
|
||||
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
|
||||
name: "PromptHistory",
|
||||
init: () => {
|
||||
|
|
@ -70,7 +81,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
|
|||
if (!store.history.length) return undefined
|
||||
const current = store.history.at(store.index)
|
||||
if (!current) return undefined
|
||||
if (current.input !== input && input.length) return
|
||||
if (current.text !== input && input.length) return
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const next = store.index + direction
|
||||
|
|
@ -79,7 +90,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
|
|||
draft.index = next
|
||||
}),
|
||||
)
|
||||
if (store.index === 0) return { input: "", parts: [] }
|
||||
if (store.index === 0) return emptyPrompt()
|
||||
return store.history.at(store.index)
|
||||
},
|
||||
append(item: PromptInfo) {
|
||||
|
|
|
|||
|
|
@ -1,24 +1,10 @@
|
|||
import { displaySlice } from "./display"
|
||||
|
||||
export function stripPromptPartIDs<Part extends { id: string; messageID: string; sessionID: string }>(part: Part) {
|
||||
const { id: _id, messageID: _messageID, sessionID: _sessionID, ...rest } = part
|
||||
return rest
|
||||
}
|
||||
|
||||
export function expandPastedTextPlaceholders(text: string, parts: readonly unknown[]) {
|
||||
return parts.reduce<string>((result, part) => {
|
||||
if (!isPastedTextPart(part)) return result
|
||||
return result.replace(part.source.text.value, part.text)
|
||||
}, text)
|
||||
}
|
||||
|
||||
function isPastedTextPart(part: unknown): part is { type: "text"; text: string; source: { text: { value: string } } } {
|
||||
if (!part || typeof part !== "object" || !("type" in part) || part.type !== "text") return false
|
||||
if (!("text" in part) || typeof part.text !== "string" || !("source" in part)) return false
|
||||
const source = part.source
|
||||
if (!source || typeof source !== "object" || !("text" in source)) return false
|
||||
const text = source.text
|
||||
return Boolean(text && typeof text === "object" && "value" in text && typeof text.value === "string")
|
||||
export function expandPastedTextPlaceholders(
|
||||
text: string,
|
||||
pasted: readonly { text: string; source: { text: string } }[],
|
||||
) {
|
||||
return pasted.reduce((result, part) => result.replace(part.source.text, part.text), text)
|
||||
}
|
||||
|
||||
export function expandTrackedPastedText(text: string, ranges: { start: number; end: number; text: string }[]) {
|
||||
|
|
|
|||
|
|
@ -4,11 +4,10 @@ import { createStore, produce, unwrap } from "solid-js/store"
|
|||
import { createSimpleContext } from "../context/helper"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { appendText, readText, writeText } from "../util/persistence"
|
||||
import type { PromptInfo } from "./history"
|
||||
import { parsePromptInfo, type PromptInfo } from "./history"
|
||||
|
||||
export type StashEntry = {
|
||||
input: string
|
||||
parts: PromptInfo["parts"]
|
||||
prompt: PromptInfo
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
|
|
@ -20,7 +19,12 @@ export function parsePromptStash(text: string) {
|
|||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as StashEntry
|
||||
const value = JSON.parse(line) as unknown
|
||||
if (!value || typeof value !== "object") return
|
||||
const entry = value as Record<string, unknown>
|
||||
const prompt = parsePromptInfo(entry.prompt)
|
||||
if (!prompt || typeof entry.timestamp !== "number") return
|
||||
return { prompt, timestamp: entry.timestamp }
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export function Home() {
|
|||
return
|
||||
}
|
||||
if (!args.prompt) return
|
||||
r.set({ input: args.prompt, parts: [] })
|
||||
r.set({ text: args.prompt, files: [], agents: [], pasted: [] })
|
||||
once = true
|
||||
}
|
||||
|
||||
|
|
@ -62,7 +62,7 @@ export function Home() {
|
|||
if (!r) return
|
||||
if (!sync.ready || !local.model.ready) return
|
||||
if (!args.prompt) return
|
||||
if (r.current.input !== args.prompt) return
|
||||
if (r.current.text !== args.prompt) return
|
||||
sent = true
|
||||
r.submit()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@ import { Locale } from "../../util/locale"
|
|||
import { useSDK } from "../../context/sdk"
|
||||
import { useRoute } from "../../context/route"
|
||||
import { useDialog, type DialogContext } from "../../ui/dialog"
|
||||
import type { PromptInfo } from "../../component/prompt/history"
|
||||
import { stripPromptPartIDs as strip } from "../../prompt/part"
|
||||
import { emptyPrompt, type PromptInfo } from "../../component/prompt/history"
|
||||
|
||||
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID?: string) => void }) {
|
||||
const sync = useSync()
|
||||
|
|
@ -53,12 +52,25 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
|
|||
const prompt = parts.reduce(
|
||||
(agg, part) => {
|
||||
if (part.type === "text") {
|
||||
if (!part.synthetic) agg.input += part.text
|
||||
if (!part.synthetic) agg.text += part.text
|
||||
}
|
||||
if (part.type === "file") {
|
||||
const files = (agg.files ??= [])
|
||||
files.push({
|
||||
uri: part.url,
|
||||
name: part.filename,
|
||||
mention: part.source?.text
|
||||
? {
|
||||
start: part.source.text.start,
|
||||
end: part.source.text.end,
|
||||
text: part.source.text.value,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
if (part.type === "file") agg.parts.push(strip(part))
|
||||
return agg
|
||||
},
|
||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||
emptyPrompt() as PromptInfo,
|
||||
)
|
||||
route.navigate({
|
||||
sessionID: forked.data!.id,
|
||||
|
|
|
|||
|
|
@ -1420,15 +1420,15 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const directory = file.mime === "application/x-directory"
|
||||
const label = file.mime === "application/x-directory" ? "Directory" : file.mime
|
||||
return (
|
||||
<text fg={theme.text}>
|
||||
<span style={{ bg: theme.secondary, fg: theme.background }}>
|
||||
{directory ? " Directory " : " File "}
|
||||
{` ${label} `}
|
||||
</span>
|
||||
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}>
|
||||
{" "}
|
||||
{file.name ?? file.uri}{" "}
|
||||
{file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
|
||||
</span>
|
||||
</text>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import { describe, expect, test } from "bun:test"
|
|||
//
|
||||
// Before the fix, two concurrent `submit()` calls (e.g. a double-pressed
|
||||
// Enter, or the input's native onSubmit racing another dispatch) each
|
||||
// passed the `if (!store.prompt.input) return false` guard, each
|
||||
// passed the `if (!store.prompt.text) return false` guard, each
|
||||
// `await sdk.client.session.create(...)`, and each only captured
|
||||
// `inputText = store.prompt.input` AFTER that await. The first invocation
|
||||
// `inputText = store.prompt.text` AFTER that await. The first invocation
|
||||
// finished, sent the prompt, and cleared the store; the second invocation,
|
||||
// now past its await, read the cleared store and sent an empty prompt to a
|
||||
// second freshly-created session - leaving an orphaned session with the
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { isDuplicateEntry, MAX_HISTORY_ENTRIES, parsePromptHistory, type PromptInfo } from "../../src/prompt/history"
|
||||
|
||||
const entry = (input: string, parts: PromptInfo["parts"] = []): PromptInfo => ({ input, parts })
|
||||
const entry = (text: string, files: PromptInfo["files"] = []): PromptInfo => ({
|
||||
text,
|
||||
files,
|
||||
agents: [],
|
||||
pasted: [],
|
||||
})
|
||||
|
||||
describe("prompt history", () => {
|
||||
test("recovers valid JSONL entries around corruption", () => {
|
||||
|
|
@ -11,13 +16,17 @@ describe("prompt history", () => {
|
|||
])
|
||||
})
|
||||
|
||||
test("ignores the legacy parts shape", () => {
|
||||
expect(parsePromptHistory(JSON.stringify({ input: "old", parts: [] }))).toEqual([])
|
||||
})
|
||||
|
||||
test("retains only the newest entries", () => {
|
||||
const input = Array.from({ length: MAX_HISTORY_ENTRIES + 5 }, (_, index) =>
|
||||
JSON.stringify(entry(String(index))),
|
||||
).join("\n")
|
||||
const result = parsePromptHistory(input)
|
||||
expect(result).toHaveLength(MAX_HISTORY_ENTRIES)
|
||||
expect(result[0]?.input).toBe("5")
|
||||
expect(result[0]?.text).toBe("5")
|
||||
})
|
||||
|
||||
test("dedupes only identical consecutive entries", () => {
|
||||
|
|
@ -27,13 +36,10 @@ describe("prompt history", () => {
|
|||
expect(isDuplicateEntry({ ...entry("ls"), mode: "normal" }, { ...entry("ls"), mode: "shell" })).toBe(false)
|
||||
})
|
||||
|
||||
test("does not dedupe entries with different parts", () => {
|
||||
const a = entry("describe this", [
|
||||
{ type: "file", mime: "image/png", filename: "a.png", url: "data:image/png;base64,AAA" },
|
||||
])
|
||||
const b = entry("describe this", [
|
||||
{ type: "file", mime: "image/png", filename: "b.png", url: "data:image/png;base64,BBB" },
|
||||
])
|
||||
test("does not dedupe entries with different attachments", () => {
|
||||
const a = entry("describe this", [{ name: "a.png", uri: "data:image/png;base64,AAA" }])
|
||||
const b = entry("describe this", [{ name: "b.png", uri: "data:image/png;base64,BBB" }])
|
||||
expect(isDuplicateEntry(a, b)).toBe(false)
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import { MAX_STASH_ENTRIES, parsePromptStash } from "../../src/prompt/stash"
|
|||
|
||||
test("stash JSONL skips corruption and retains newest entries", () => {
|
||||
const entries = Array.from({ length: MAX_STASH_ENTRIES + 2 }, (_, index) =>
|
||||
JSON.stringify({ input: String(index), parts: [], timestamp: index }),
|
||||
JSON.stringify({ prompt: { text: String(index), files: [], agents: [], pasted: [] }, timestamp: index }),
|
||||
)
|
||||
entries.splice(2, 0, "broken")
|
||||
const result = parsePromptStash(entries.join("\n"))
|
||||
expect(result).toHaveLength(MAX_STASH_ENTRIES)
|
||||
expect(result[0]?.input).toBe("2")
|
||||
expect(result[0]?.prompt.text).toBe("2")
|
||||
})
|
||||
|
||||
test("frecency JSONL skips corruption, keeps latest path state, and limits entries", () => {
|
||||
|
|
|
|||
|
|
@ -1,26 +1,7 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { expandTrackedPastedText, stripPromptPartIDs } from "../../src/prompt/part"
|
||||
import { expandTrackedPastedText } from "../../src/prompt/part"
|
||||
|
||||
describe("prompt part", () => {
|
||||
test("strips persisted IDs from reused parts", () => {
|
||||
expect(
|
||||
stripPromptPartIDs({
|
||||
id: "prt_old",
|
||||
sessionID: "ses_old",
|
||||
messageID: "msg_old",
|
||||
type: "file" as const,
|
||||
mime: "image/png",
|
||||
filename: "tiny.png",
|
||||
url: "data:image/png;base64,abc",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
filename: "tiny.png",
|
||||
url: "data:image/png;base64,abc",
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves wide characters around pasted text", () => {
|
||||
const marker = "[Pasted ~3 lines]"
|
||||
const prefix = "你好你好\n"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue