mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-27 02:57:34 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d77abb2eca | ||
|
|
c72037c7c2 | ||
|
|
ad5a7f5b12 |
@@ -222,7 +222,19 @@ export type SessionRemoveInput = { readonly sessionID: Session.ID }
|
||||
export type SessionRemoveOutput = void
|
||||
export type SessionRemoveOperation<E = never> = (input: SessionRemoveInput) => Effect.Effect<SessionRemoveOutput, E>
|
||||
|
||||
export type SessionForkInput = { readonly sessionID: Session.ID; readonly before?: SessionMessage.ID | undefined }
|
||||
export type SessionForkInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly before?: SessionMessage.ID | undefined
|
||||
readonly continuation?:
|
||||
| {
|
||||
readonly prompt: string
|
||||
readonly response: string
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
export type SessionForkOutput = Session.Info
|
||||
export type SessionForkOperation<E = never> = (input: SessionForkInput) => Effect.Effect<SessionForkOutput, E>
|
||||
|
||||
@@ -402,7 +414,12 @@ export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||
) => Effect.Effect<SessionInstructionsEntryRemoveOutput, E>
|
||||
|
||||
export type SessionGenerateInput = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type SessionGenerateOutput = { readonly text: string }
|
||||
export type SessionGenerateOutput = {
|
||||
readonly text: string
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
}
|
||||
export type SessionGenerateOperation<E = never> = (
|
||||
input: SessionGenerateInput,
|
||||
) => Effect.Effect<SessionGenerateOutput, E>
|
||||
@@ -574,6 +591,15 @@ export type SessionLogOutput =
|
||||
readonly sessionID: Session.ID
|
||||
readonly parentID: Session.ID
|
||||
readonly boundary: Session.ForkBoundary
|
||||
readonly continuation?:
|
||||
| {
|
||||
readonly prompt: string
|
||||
readonly response: string
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
}
|
||||
| undefined
|
||||
readonly instructions?:
|
||||
| { readonly [x: string & Brand.Brand<"Instruction.Key">]: string & Brand.Brand<"Instruction.Hash"> }
|
||||
| undefined
|
||||
|
||||
@@ -429,7 +429,10 @@ const EndpointSessionRemove = (raw: RawClient["server.session"]) => (input: Sess
|
||||
|
||||
const EndpointSessionFork = (raw: RawClient["server.session"]) => (input: SessionForkInput) =>
|
||||
preserveEffect<SessionForkOutput>()(
|
||||
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { before: input["before"] } }).pipe(
|
||||
raw["session.fork"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { before: input["before"], continuation: input["continuation"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
|
||||
@@ -626,7 +626,7 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/fork`,
|
||||
body: { before: input["before"] },
|
||||
body: { before: input["before"], continuation: input["continuation"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: false,
|
||||
|
||||
@@ -169,8 +169,6 @@ export type SessionInboxCompactionPayload = {}
|
||||
|
||||
export type InstructionEntryKey = string
|
||||
|
||||
export type SessionGenerateResponse = { data: { text: string } }
|
||||
|
||||
export type LocationRef = { directory: string; workspaceID?: string }
|
||||
|
||||
export type SessionInboxSyntheticPayload1 = { text: string; description?: string; metadata?: { [x: string]: any } }
|
||||
@@ -496,6 +494,23 @@ export type SessionMessageModelSelected = {
|
||||
previous?: ModelRef
|
||||
}
|
||||
|
||||
export type SessionGenerateResponse = {
|
||||
data: {
|
||||
text: string
|
||||
agent: string
|
||||
model: ModelRef
|
||||
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionForkContinuation = {
|
||||
prompt: string
|
||||
response: string
|
||||
agent: string
|
||||
model: ModelRef
|
||||
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
}
|
||||
|
||||
export type PromptFileAttachment = {
|
||||
data: PromptBase64
|
||||
mime: string
|
||||
@@ -1801,6 +1816,7 @@ export type SessionForked = {
|
||||
sessionID: string
|
||||
parentID: string
|
||||
boundary: SessionForkBoundary
|
||||
continuation?: SessionForkContinuation
|
||||
instructions?: { [x: string]: string }
|
||||
instructionEntries?: InstructionEntrySnapshot
|
||||
}
|
||||
@@ -3921,7 +3937,26 @@ export type SessionRemoveOutput = void
|
||||
|
||||
export type SessionForkInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly before?: { readonly before?: string | undefined }["before"]
|
||||
readonly before?: {
|
||||
readonly before?: string | null
|
||||
readonly continuation?: {
|
||||
readonly prompt: string
|
||||
readonly response: string
|
||||
readonly agent: string
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
} | null
|
||||
}["before"]
|
||||
readonly continuation?: {
|
||||
readonly before?: string | null
|
||||
readonly continuation?: {
|
||||
readonly prompt: string
|
||||
readonly response: string
|
||||
readonly agent: string
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
} | null
|
||||
}["continuation"]
|
||||
}
|
||||
|
||||
export type SessionForkOutput = { data: SessionInfo }["data"]
|
||||
|
||||
@@ -927,6 +927,53 @@ test("session methods use the public HTTP contract", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("session fork carries a generated continuation", async () => {
|
||||
const bodies: unknown[] = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
||||
if (typeof init?.body === "string") bodies.push(JSON.parse(init.body))
|
||||
if (url.endsWith("/generate")) {
|
||||
return Response.json({
|
||||
data: {
|
||||
text: "Side answer",
|
||||
agent: "build",
|
||||
model: { id: "claude", providerID: "anthropic" },
|
||||
finish: "stop",
|
||||
},
|
||||
})
|
||||
}
|
||||
return Response.json(session)
|
||||
},
|
||||
})
|
||||
|
||||
const generated = await client.session.generate({ sessionID: "ses_test", prompt: "Side question" })
|
||||
await client.session.fork({
|
||||
sessionID: "ses_test",
|
||||
continuation: {
|
||||
prompt: "Side question",
|
||||
response: generated.text,
|
||||
agent: generated.agent,
|
||||
model: generated.model,
|
||||
finish: generated.finish,
|
||||
},
|
||||
})
|
||||
|
||||
expect(bodies).toEqual([
|
||||
{ prompt: "Side question" },
|
||||
{
|
||||
continuation: {
|
||||
prompt: "Side question",
|
||||
response: "Side answer",
|
||||
agent: "build",
|
||||
model: { id: "claude", providerID: "anthropic" },
|
||||
finish: "stop",
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("middleware errors remain declared client errors", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
|
||||
@@ -532,7 +532,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
switchAgent: sessions.switchAgent,
|
||||
switchModel: sessions.switchModel,
|
||||
prompt: sessions.prompt,
|
||||
generate: (input) => sessions.generate(input).pipe(Effect.map((text) => ({ text }))),
|
||||
generate: sessions.generate,
|
||||
command: (input) => sessions.command({ ...input, command: input.name }),
|
||||
update: Effect.fn(function* (input) {
|
||||
yield* sessions.get(input.sessionID)
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from "./session/schema.js"
|
||||
import { Effect, Layer, Schema, Context, Stream } from "effect"
|
||||
import { LLMClient } from "@opencode/ai"
|
||||
import { ListAnchor } from "@opencode/schema/session"
|
||||
import type { SessionFork } from "@opencode/schema/session-fork"
|
||||
import { and, desc, eq } from "drizzle-orm"
|
||||
import { Project } from "./project.js"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
@@ -95,6 +96,7 @@ type CompactInput = Parameters<Session.Handle["compact"]>[0] & { sessionID: Sess
|
||||
type ForkInput = {
|
||||
sessionID: SessionSchema.ID
|
||||
before?: SessionMessage.ID
|
||||
continuation?: SessionFork.Continuation
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -182,7 +184,7 @@ export interface Interface {
|
||||
readonly generate: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
prompt: string
|
||||
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
|
||||
}) => Effect.Effect<SessionGenerate.Result, NotFoundError | SessionGenerate.Error>
|
||||
readonly command: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
command: string
|
||||
@@ -337,6 +339,7 @@ const layer = Layer.effect(
|
||||
sessionID,
|
||||
parentID: parent.id,
|
||||
boundary: { type: input.before ? "before" : "through", messageID: boundary.id },
|
||||
continuation: input.continuation,
|
||||
...inherited,
|
||||
})
|
||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export * as SessionGenerate from "./generate.js"
|
||||
|
||||
import { LLMClient, Message, type AIError } from "@opencode/ai"
|
||||
import type { Agent } from "@opencode/schema/agent"
|
||||
import type { FinishReason } from "@opencode/schema/llm"
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
import { Effect } from "effect"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Instance } from "../instance/service.js"
|
||||
@@ -16,6 +19,13 @@ import type { SessionSchema } from "./schema.js"
|
||||
|
||||
export type Error = AgentNotFoundError | Instructions.InitializationBlocked | SessionRunnerModel.Error | AIError
|
||||
|
||||
export interface Result {
|
||||
readonly text: string
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly finish: FinishReason
|
||||
}
|
||||
|
||||
/** Generates text from current Session context without mutating the Session. */
|
||||
export const generate = Effect.fn("SessionGenerate.generate")(function* (input: {
|
||||
session: SessionSchema.Info
|
||||
@@ -62,6 +72,11 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
|
||||
})
|
||||
const response = yield* llm.generate(prepared.request, prepared.options)
|
||||
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
|
||||
return response.text
|
||||
return {
|
||||
text: response.text,
|
||||
agent: selection.agent.id,
|
||||
model: model.ref,
|
||||
finish: response.finishReason.normalized,
|
||||
}
|
||||
}).pipe(instances.provide(input.session))
|
||||
})
|
||||
|
||||
@@ -55,6 +55,33 @@ const forkTitle = (value?: string) => {
|
||||
return `${value} (fork #1)`
|
||||
}
|
||||
|
||||
function forkContinuation(event: typeof SessionEvent.Forked.Type) {
|
||||
const continuation = event.data.continuation
|
||||
if (!continuation) return []
|
||||
const id = SessionMessage.ID.fromEvent(event.id)
|
||||
const created = DateTime.makeUnsafe(event.created)
|
||||
return [
|
||||
SessionMessage.User.make({
|
||||
id: SessionMessage.ID.make(`${id}_user`),
|
||||
type: "user",
|
||||
text: continuation.prompt,
|
||||
files: [],
|
||||
agents: [],
|
||||
skills: [],
|
||||
time: { created },
|
||||
}),
|
||||
SessionMessage.Assistant.make({
|
||||
id: SessionMessage.ID.make(`${id}_assistant`),
|
||||
type: "assistant",
|
||||
agent: continuation.agent,
|
||||
model: continuation.model,
|
||||
content: [SessionMessage.AssistantText.make({ type: "text", text: continuation.response })],
|
||||
finish: continuation.finish,
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usage) {
|
||||
return db
|
||||
.update(SessionTable)
|
||||
@@ -220,7 +247,29 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
|
||||
cursor = rows.at(-1)!.seq
|
||||
}
|
||||
if (copiedSeq !== undefined) yield* Bus.reserveSequence(db, event.data.sessionID, copiedSeq)
|
||||
const continuation = forkContinuation(event)
|
||||
const lastSeq = (copiedSeq ?? 0) + continuation.length
|
||||
if (continuation.length > 0) {
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values(
|
||||
continuation.map((message, index) => {
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
return {
|
||||
id: SessionMessage.ID.make(id),
|
||||
session_id: event.data.sessionID,
|
||||
type,
|
||||
seq: (copiedSeq ?? 0) + index + 1,
|
||||
time_created: event.created,
|
||||
data,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
if (lastSeq > 0) yield* Bus.reserveSequence(db, event.data.sessionID, lastSeq)
|
||||
if (event.data.instructions)
|
||||
yield* InstructionState.initialize(db, event.data.sessionID, event.durable.seq, event.data.instructions)
|
||||
})
|
||||
|
||||
@@ -327,7 +327,12 @@ it.effect(
|
||||
Effect.provideService(Instance.Service, instances),
|
||||
)
|
||||
|
||||
expect(result).toBe("Transient answer")
|
||||
expect(result).toEqual({
|
||||
text: "Transient answer",
|
||||
agent: Agent.ID.make("build"),
|
||||
model: { id: ID.make("generate-model"), providerID: Provider.ID.make("test") },
|
||||
finish: "stop",
|
||||
})
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.model).toBe(model)
|
||||
expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
|
||||
|
||||
@@ -1620,6 +1620,43 @@ describe("SessionRunnerLLM", () => {
|
||||
).toMatchObject({ current_values: { "test/context": Instructions.hash("Latest context") } })
|
||||
})
|
||||
|
||||
scenario("appends a generated continuation exchange to a fork", function* (s) {
|
||||
yield* s.runPrompt("Main question")
|
||||
const forked = yield* s.session.fork({
|
||||
sessionID,
|
||||
continuation: {
|
||||
prompt: "Side question",
|
||||
response: "Side answer",
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({
|
||||
id: Model.ID.make(s.currentModel.id),
|
||||
providerID: Provider.ID.make(s.currentModel.provider),
|
||||
}),
|
||||
finish: "stop",
|
||||
},
|
||||
})
|
||||
|
||||
const messages = yield* s.session.messages({ sessionID: forked.id, order: "asc" })
|
||||
expect(messages.at(-2)).toMatchObject({ type: "user", text: "Side question" })
|
||||
expect(messages.at(-1)).toMatchObject({
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
content: [{ type: "text", text: "Side answer" }],
|
||||
finish: "stop",
|
||||
})
|
||||
|
||||
yield* s.session.prompt({ sessionID: forked.id, text: "Continue", resume: false })
|
||||
yield* s.session.resume(forked.id)
|
||||
expect(
|
||||
s.requests
|
||||
.at(-1)
|
||||
?.messages.filter((message) => message.role === "assistant")
|
||||
.flatMap((message) => message.content)
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text),
|
||||
).toContain("Side answer")
|
||||
})
|
||||
|
||||
scenario("keeps nested forks self-contained", function* (s) {
|
||||
yield* s.runPrompt("First")
|
||||
s.systemBaseline = "Changed context"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import { PromptInput } from "@opencode/schema/prompt-input"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { SessionStats } from "@opencode/schema/session-stats"
|
||||
import { SessionFork } from "@opencode/schema/session-fork"
|
||||
import { InstructionEntry } from "@opencode/schema/instruction-entry"
|
||||
import { Project } from "@opencode/schema/project"
|
||||
import {
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { FinishReason } from "@opencode/schema/llm"
|
||||
import { Permission } from "@opencode/schema/permission"
|
||||
import { Location } from "@opencode/schema/location"
|
||||
import { SessionEvent } from "@opencode/schema/session-event"
|
||||
@@ -308,7 +310,10 @@ export const makeSessionGroup = <
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.fork", "/api/session/:sessionID/fork", {
|
||||
params: { sessionID: Session.ID },
|
||||
payload: Schema.Struct({ before: SessionMessage.ID.pipe(Schema.optional) }),
|
||||
payload: Schema.Struct({
|
||||
before: SessionMessage.ID.pipe(Schema.optional),
|
||||
continuation: SessionFork.Continuation.pipe(Schema.optional),
|
||||
}),
|
||||
success: Schema.Struct({ data: PublicSessionInfo }),
|
||||
error: [SessionNotFoundError, MessageNotFoundError, InvalidRequestError],
|
||||
})
|
||||
@@ -318,7 +323,7 @@ export const makeSessionGroup = <
|
||||
identifier: "session.fork",
|
||||
summary: "Fork session",
|
||||
description:
|
||||
"Create a child session by copying projected history before a message. Omit before to copy the full history.",
|
||||
"Create a child session by copying projected history before a message. Omit before to copy the full history; provide a continuation to append a completed user and assistant exchange.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -706,7 +711,12 @@ export const makeSessionGroup = <
|
||||
params: { sessionID: Session.ID },
|
||||
payload: Schema.Struct({ prompt: Schema.String }),
|
||||
success: Schema.Struct({
|
||||
data: Schema.Struct({ text: Schema.String }),
|
||||
data: Schema.Struct({
|
||||
text: Schema.String,
|
||||
agent: Agent.ID,
|
||||
model: Model.Ref,
|
||||
finish: FinishReason,
|
||||
}),
|
||||
}).annotate({ identifier: "SessionGenerateResponse" }),
|
||||
error: [SessionNotFoundError, ServiceUnavailableError],
|
||||
})
|
||||
@@ -715,7 +725,8 @@ export const makeSessionGroup = <
|
||||
OpenApi.annotations({
|
||||
identifier: "session.generate",
|
||||
summary: "Generate text from session context",
|
||||
description: "Generate transient text from the current session context without mutating session history.",
|
||||
description:
|
||||
"Generate transient text and its resolved agent, model, and finish metadata from the current session context without mutating session history.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -187,6 +187,7 @@ export const Forked = Event.durable({
|
||||
...Base,
|
||||
parentID: SessionID,
|
||||
boundary: SessionFork.Boundary,
|
||||
continuation: SessionFork.Continuation.pipe(optional),
|
||||
instructions: Instruction.Values.pipe(optional),
|
||||
instructionEntries: InstructionEntry.Snapshot.pipe(optional),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export * as SessionFork from "./session-fork.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Agent } from "./agent.js"
|
||||
import { FinishReason } from "./llm.js"
|
||||
import { Model } from "./model.js"
|
||||
import { SessionMessage } from "./session-message.js"
|
||||
|
||||
export const Boundary = Schema.Union([
|
||||
@@ -8,3 +11,12 @@ export const Boundary = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("through"), messageID: SessionMessage.ID }),
|
||||
]).annotate({ identifier: "Session.ForkBoundary" })
|
||||
export type Boundary = typeof Boundary.Type
|
||||
|
||||
export interface Continuation extends Schema.Schema.Type<typeof Continuation> {}
|
||||
export const Continuation = Schema.Struct({
|
||||
prompt: Schema.String,
|
||||
response: Schema.String,
|
||||
agent: Agent.ID,
|
||||
model: Model.Ref,
|
||||
finish: FinishReason,
|
||||
}).annotate({ identifier: "Session.ForkContinuation" })
|
||||
|
||||
@@ -223,14 +223,20 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
"session.fork",
|
||||
Effect.fn(function* (ctx) {
|
||||
return {
|
||||
data: yield* session.fork({ sessionID: ctx.params.sessionID, before: ctx.payload.before }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
|
||||
Effect.catchTag(
|
||||
"Session.ForkEmptyError",
|
||||
(error) => new InvalidRequestError({ message: error.message, kind: "empty_session" }),
|
||||
data: yield* session
|
||||
.fork({
|
||||
sessionID: ctx.params.sessionID,
|
||||
before: ctx.payload.before,
|
||||
continuation: ctx.payload.continuation,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
|
||||
Effect.catchTag(
|
||||
"Session.ForkEmptyError",
|
||||
(error) => new InvalidRequestError({ message: error.message, kind: "empty_session" }),
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -582,7 +588,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
.handle(
|
||||
"session.generate",
|
||||
Effect.fn(function* (ctx) {
|
||||
const text = yield* session
|
||||
const result = yield* session
|
||||
.generate({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt })
|
||||
.pipe(
|
||||
Effect.mapError((error) =>
|
||||
@@ -591,7 +597,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
: new ServiceUnavailableError({ message: error.message, service: "session generation" }),
|
||||
),
|
||||
)
|
||||
return { data: { text } }
|
||||
return { data: result }
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
|
||||
@@ -216,7 +216,12 @@ it.live(
|
||||
|
||||
for (const config of configs) {
|
||||
yield* llm.push(TestLLM.text(`generated ${config.id}`, config.tool))
|
||||
expect(yield* sessions.generate({ sessionID: config.id, prompt: "summarize" })).toBe(`generated ${config.id}`)
|
||||
expect(yield* sessions.generate({ sessionID: config.id, prompt: "summarize" })).toEqual({
|
||||
text: `generated ${config.id}`,
|
||||
agent: Agent.ID.make("build"),
|
||||
model: model.ref,
|
||||
finish: "stop",
|
||||
})
|
||||
yield* sessions.command({ sessionID: config.id, command: "instance-check", text: "" })
|
||||
expect(yield* sessions.inbox(config.id)).toMatchObject([
|
||||
{ type: "user", payload: { text: `command ${config.tool} [${config.tool}]` } },
|
||||
|
||||
@@ -69,7 +69,25 @@ export default Plugin.define({
|
||||
.generate({ sessionID: route.sessionID, prompt: [instructions, question].join("\n\n") })
|
||||
.then((result) => {
|
||||
context.ui.dialog.show(() => (
|
||||
<Answer question={question} answer={result.text.trim()} markdown={plugins.markdown} />
|
||||
<Answer
|
||||
question={question}
|
||||
answer={result.text.trim()}
|
||||
markdown={plugins.markdown}
|
||||
onFork={async () => {
|
||||
const fork = await context.client.session.fork({
|
||||
sessionID: route.sessionID,
|
||||
continuation: {
|
||||
prompt: question,
|
||||
response: result.text.trim(),
|
||||
agent: result.agent,
|
||||
model: result.model,
|
||||
finish: result.finish,
|
||||
},
|
||||
})
|
||||
context.ui.dialog.clear()
|
||||
context.ui.router.navigate({ type: "session", sessionID: fork.id })
|
||||
}}
|
||||
/>
|
||||
))
|
||||
context.ui.dialog.set({ size: "large", centered: true })
|
||||
})
|
||||
@@ -89,6 +107,7 @@ export function Answer(props: {
|
||||
question: string
|
||||
answer: string
|
||||
markdown: ReturnType<typeof usePlugin>["markdown"]
|
||||
onFork: () => Promise<void>
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
@@ -98,6 +117,7 @@ export function Answer(props: {
|
||||
const syntax = useThemes().currentSyntax
|
||||
const config = useConfig().data
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const [forking, setForking] = createSignal(false)
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
const copy = () => {
|
||||
@@ -107,9 +127,21 @@ export function Answer(props: {
|
||||
.catch(toast.error)
|
||||
}
|
||||
|
||||
const fork = async () => {
|
||||
if (forking()) return
|
||||
setForking(true)
|
||||
await props
|
||||
.onFork()
|
||||
.catch(toast.error)
|
||||
.finally(() => setForking(false))
|
||||
}
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
commands: [{ bind: "c", title: "Copy answer", group: "Dialog", run: copy }],
|
||||
commands: [
|
||||
{ bind: "c", title: "Copy answer", group: "Dialog", run: copy },
|
||||
{ bind: "f", title: "Fork session", group: "Dialog", run: fork },
|
||||
],
|
||||
}))
|
||||
|
||||
useKeyboard((event) => {
|
||||
@@ -166,6 +198,12 @@ export function Answer(props: {
|
||||
</span>
|
||||
<span style={{ fg: theme.text.muted }}>{copied() ? "" : " copy"}</span>
|
||||
</text>
|
||||
<text onMouseUp={() => void fork()}>
|
||||
<span style={{ fg: theme.text.base }}>
|
||||
<b>{forking() ? "…" : "f"}</b>
|
||||
</span>
|
||||
<span style={{ fg: theme.text.muted }}>{forking() ? " forking" : " fork session"}</span>
|
||||
</text>
|
||||
<text fg={theme.text.muted}>↑/↓ scroll</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
Reference in New Issue
Block a user