mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-01 03:24:49 +00:00
Compare commits
2 Commits
fix/sessio
...
opencode-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb05287d73 | ||
|
|
7792060bc1 |
@@ -125,7 +125,6 @@ import { DialogVariant } from "./component/dialog-variant"
|
||||
|
||||
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
|
||||
return {
|
||||
externalOutputMode: "passthrough",
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core"
|
||||
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
|
||||
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
||||
import "opentui-spinner/solid"
|
||||
import path from "path"
|
||||
@@ -809,8 +809,20 @@ export function Prompt(props: PromptProps) {
|
||||
return !!current
|
||||
})
|
||||
|
||||
const suggestion = createMemo(() => {
|
||||
if (!props.sessionID) return
|
||||
if (store.mode !== "normal") return
|
||||
if (store.prompt.input) return
|
||||
const current = status()
|
||||
if (current.type !== "idle") return
|
||||
const value = current.suggestion?.trim()
|
||||
if (!value) return
|
||||
return value
|
||||
})
|
||||
|
||||
const placeholderText = createMemo(() => {
|
||||
if (props.showPlaceholder === false) return undefined
|
||||
if (suggestion()) return suggestion()
|
||||
if (store.mode === "shell") {
|
||||
if (!shell().length) return undefined
|
||||
const example = shell()[store.placeholder % shell().length]
|
||||
@@ -898,6 +910,16 @@ export function Prompt(props: PromptProps) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (!store.prompt.input && e.name === "right" && !e.ctrl && !e.meta && !e.shift && !e.super) {
|
||||
const value = suggestion()
|
||||
if (value) {
|
||||
input.setText(value)
|
||||
setStore("prompt", "input", value)
|
||||
input.gotoBufferEnd()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
// Check clipboard for images before terminal-handled paste runs.
|
||||
// This helps terminals that forward Ctrl+V to the app; Windows
|
||||
// Terminal 1.25+ usually handles Ctrl+V before this path.
|
||||
|
||||
@@ -71,6 +71,7 @@ export namespace Flag {
|
||||
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
|
||||
export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES")
|
||||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||
export const OPENCODE_EXPERIMENTAL_NEXT_PROMPT = truthy("OPENCODE_EXPERIMENTAL_NEXT_PROMPT")
|
||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
||||
export const OPENCODE_DISABLE_EMBEDDED_WEB_UI = truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Log } from "@/util/log"
|
||||
import { Effect, Layer, Record, ServiceMap } from "effect"
|
||||
import { Cause, Effect, Layer, Record, ServiceMap } from "effect"
|
||||
import * as Queue from "effect/Queue"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
|
||||
import { mergeDeep, pipe } from "remeda"
|
||||
@@ -59,8 +60,21 @@ export namespace LLM {
|
||||
Effect.sync(() => new AbortController()),
|
||||
(ctrl) => Effect.sync(() => ctrl.abort()),
|
||||
)
|
||||
const result = yield* Effect.promise(() => LLM.stream({ ...input, abort: ctrl.signal }))
|
||||
return Stream.fromAsyncIterable(result.fullStream, (err) => err)
|
||||
const queue = yield* Queue.unbounded<Event, unknown | Cause.Done>()
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
const result = await LLM.stream({ ...input, abort: ctrl.signal })
|
||||
for await (const event of result.fullStream) {
|
||||
if (!Queue.offerUnsafe(queue, event)) break
|
||||
}
|
||||
Queue.endUnsafe(queue)
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => Effect.sync(() => void Queue.failCauseUnsafe(queue, cause))),
|
||||
Effect.onInterrupt(() => Effect.sync(() => ctrl.abort())),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return Stream.fromQueue(queue)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -30,10 +30,6 @@ export namespace SessionProcessor {
|
||||
export interface Handle {
|
||||
readonly message: MessageV2.Assistant
|
||||
readonly partFromToolCall: (toolCallID: string) => MessageV2.ToolPart | undefined
|
||||
readonly metadata: (
|
||||
toolCallID: string,
|
||||
input: { title?: string; metadata?: Record<string, any> },
|
||||
) => Effect.Effect<void>
|
||||
readonly abort: () => Effect.Effect<void>
|
||||
readonly process: (streamInput: LLM.StreamInput) => Effect.Effect<Result>
|
||||
}
|
||||
@@ -50,7 +46,6 @@ export namespace SessionProcessor {
|
||||
|
||||
interface ProcessorContext extends Input {
|
||||
toolcalls: Record<string, MessageV2.ToolPart>
|
||||
toolmeta: Record<string, { title?: string; metadata?: Record<string, any> }>
|
||||
shouldBreak: boolean
|
||||
snapshot: string | undefined
|
||||
blocked: boolean
|
||||
@@ -94,7 +89,6 @@ export namespace SessionProcessor {
|
||||
sessionID: input.sessionID,
|
||||
model: input.model,
|
||||
toolcalls: {},
|
||||
toolmeta: {},
|
||||
shouldBreak: false,
|
||||
snapshot: undefined,
|
||||
blocked: false,
|
||||
@@ -178,21 +172,13 @@ export namespace SessionProcessor {
|
||||
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
||||
}
|
||||
const match = ctx.toolcalls[value.toolCallId]
|
||||
const meta = ctx.toolmeta[value.toolCallId]
|
||||
if (!match) return
|
||||
ctx.toolcalls[value.toolCallId] = yield* session.updatePart({
|
||||
...match,
|
||||
tool: value.toolName,
|
||||
state: {
|
||||
status: "running",
|
||||
input: value.input,
|
||||
title: meta?.title,
|
||||
metadata: meta?.metadata,
|
||||
time: { start: Date.now() },
|
||||
},
|
||||
state: { status: "running", input: value.input, time: { start: Date.now() } },
|
||||
metadata: value.providerMetadata,
|
||||
} satisfies MessageV2.ToolPart)
|
||||
delete ctx.toolmeta[value.toolCallId]
|
||||
|
||||
const parts = yield* Effect.promise(() => MessageV2.parts(ctx.assistantMessage.id))
|
||||
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)
|
||||
@@ -238,7 +224,6 @@ export namespace SessionProcessor {
|
||||
},
|
||||
})
|
||||
delete ctx.toolcalls[value.toolCallId]
|
||||
delete ctx.toolmeta[value.toolCallId]
|
||||
return
|
||||
}
|
||||
|
||||
@@ -258,7 +243,6 @@ export namespace SessionProcessor {
|
||||
ctx.blocked = ctx.shouldBreak
|
||||
}
|
||||
delete ctx.toolcalls[value.toolCallId]
|
||||
delete ctx.toolmeta[value.toolCallId]
|
||||
return
|
||||
}
|
||||
|
||||
@@ -510,24 +494,6 @@ export namespace SessionProcessor {
|
||||
partFromToolCall(toolCallID: string) {
|
||||
return ctx.toolcalls[toolCallID]
|
||||
},
|
||||
metadata: Effect.fn("SessionProcessor.metadata")(function* (toolCallID, input) {
|
||||
const match = ctx.toolcalls[toolCallID]
|
||||
if (!match || match.state.status !== "running") {
|
||||
ctx.toolmeta[toolCallID] = {
|
||||
...ctx.toolmeta[toolCallID],
|
||||
...input,
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx.toolcalls[toolCallID] = yield* session.updatePart({
|
||||
...match,
|
||||
state: {
|
||||
...match.state,
|
||||
title: input.title ?? match.state.title,
|
||||
metadata: input.metadata ?? match.state.metadata,
|
||||
},
|
||||
})
|
||||
}),
|
||||
abort,
|
||||
process,
|
||||
} satisfies Handle
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Plugin } from "../plugin"
|
||||
import PROMPT_PLAN from "../session/prompt/plan.txt"
|
||||
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
|
||||
import MAX_STEPS from "../session/prompt/max-steps.txt"
|
||||
import PROMPT_SUGGEST_NEXT from "../session/prompt/suggest-next.txt"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import { Runner } from "@/effect/runner"
|
||||
import { MCP } from "../mcp"
|
||||
@@ -243,6 +244,77 @@ export namespace SessionPrompt {
|
||||
)
|
||||
})
|
||||
|
||||
const suggest = Effect.fn("SessionPrompt.suggest")(function* (input: {
|
||||
session: Session.Info
|
||||
sessionID: SessionID
|
||||
message: MessageV2.WithParts
|
||||
}) {
|
||||
if (input.session.parentID) return
|
||||
const message = input.message.info
|
||||
if (message.role !== "assistant") return
|
||||
if (message.error) return
|
||||
if (!message.finish) return
|
||||
if (["tool-calls", "unknown"].includes(message.finish)) return
|
||||
if ((yield* status.get(input.sessionID)).type !== "idle") return
|
||||
|
||||
const ag = yield* agents.get("title")
|
||||
if (!ag) return
|
||||
|
||||
const model = yield* Effect.promise(async () => {
|
||||
const small = await Provider.getSmallModel(message.providerID).catch(() => undefined)
|
||||
if (small) return small
|
||||
return Provider.getModel(message.providerID, message.modelID).catch(() => undefined)
|
||||
})
|
||||
if (!model) return
|
||||
|
||||
const msgs = yield* Effect.promise(() => MessageV2.filterCompacted(MessageV2.stream(input.sessionID)))
|
||||
const history = msgs.slice(-8)
|
||||
const real = (item: MessageV2.WithParts) =>
|
||||
item.info.role === "user" && !item.parts.every((part) => "synthetic" in part && part.synthetic)
|
||||
const parent = msgs.find((item) => item.info.id === message.parentID)
|
||||
const user = parent && real(parent) ? parent.info : msgs.findLast((item) => real(item))?.info
|
||||
if (!user || user.role !== "user") return
|
||||
|
||||
const text = yield* Effect.promise(async (signal) => {
|
||||
const result = await LLM.stream({
|
||||
agent: {
|
||||
...ag,
|
||||
name: "suggest-next",
|
||||
prompt: PROMPT_SUGGEST_NEXT,
|
||||
},
|
||||
user,
|
||||
system: [],
|
||||
small: true,
|
||||
tools: {},
|
||||
model,
|
||||
abort: signal,
|
||||
sessionID: input.sessionID,
|
||||
retries: 1,
|
||||
toolChoice: "none",
|
||||
messages: await MessageV2.toModelMessages(history, model),
|
||||
})
|
||||
return result.text
|
||||
})
|
||||
|
||||
const line = text
|
||||
.replace(/<think>[\s\S]*?<\/think>\s*/g, "")
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.find((item) => item.length > 0)
|
||||
?.replace(/^["'`]+|["'`]+$/g, "")
|
||||
if (!line) return
|
||||
|
||||
const tag = line
|
||||
.toUpperCase()
|
||||
.replace(/[\s-]+/g, "_")
|
||||
.replace(/[^A-Z_]/g, "")
|
||||
if (tag === "NO_SUGGESTION") return
|
||||
|
||||
const suggestion = line.length > 240 ? line.slice(0, 237) + "..." : line
|
||||
if ((yield* status.get(input.sessionID)).type !== "idle") return
|
||||
yield* status.set(input.sessionID, { type: "idle", suggestion })
|
||||
})
|
||||
|
||||
const insertReminders = Effect.fn("SessionPrompt.insertReminders")(function* (input: {
|
||||
messages: MessageV2.WithParts[]
|
||||
agent: Agent.Info
|
||||
@@ -384,7 +456,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
model: Provider.Model
|
||||
session: Session.Info
|
||||
tools?: Record<string, boolean>
|
||||
processor: Pick<SessionProcessor.Handle, "message" | "partFromToolCall" | "metadata">
|
||||
processor: Pick<SessionProcessor.Handle, "message" | "partFromToolCall">
|
||||
bypassAgentCheck: boolean
|
||||
messages: MessageV2.WithParts[]
|
||||
}) {
|
||||
@@ -399,7 +471,23 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
|
||||
agent: input.agent.name,
|
||||
messages: input.messages,
|
||||
metadata: (val) => Effect.runPromise(input.processor.metadata(options.toolCallId, val)),
|
||||
metadata: (val) =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const match = input.processor.partFromToolCall(options.toolCallId)
|
||||
if (!match || !["running", "pending"].includes(match.state.status)) return
|
||||
yield* sessions.updatePart({
|
||||
...match,
|
||||
state: {
|
||||
title: val.title,
|
||||
metadata: val.metadata,
|
||||
status: "running",
|
||||
input: args,
|
||||
time: { start: Date.now() },
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
ask: (req) =>
|
||||
Effect.runPromise(
|
||||
permission.ask({
|
||||
@@ -1297,7 +1385,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}
|
||||
|
||||
if (input.noReply === true) return message
|
||||
return yield* loop({ sessionID: input.sessionID })
|
||||
const result = yield* loop({ sessionID: input.sessionID })
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_NEXT_PROMPT) {
|
||||
yield* suggest({
|
||||
session,
|
||||
sessionID: input.sessionID,
|
||||
message: result,
|
||||
}).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||
}
|
||||
return result
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
21
packages/opencode/src/session/prompt/suggest-next.txt
Normal file
21
packages/opencode/src/session/prompt/suggest-next.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
You are generating a suggested next user message for the current conversation.
|
||||
|
||||
Goal:
|
||||
- Suggest a useful next step that keeps momentum.
|
||||
|
||||
Rules:
|
||||
- Output exactly one line.
|
||||
- Write as the user speaking to the assistant (for example: "Can you...", "Help me...", "Let's...").
|
||||
- Match the user's tone and language; keep it natural and human.
|
||||
- Prefer a concrete action over a broad question.
|
||||
- If the conversation is vague or small-talk, steer toward a practical starter request.
|
||||
- If there is no meaningful or appropriate next step to suggest, output exactly: NO_SUGGESTION
|
||||
- Avoid corporate or robotic phrasing.
|
||||
- Avoid asking multiple discovery questions in one sentence.
|
||||
- Do not include quotes, labels, markdown, or explanations.
|
||||
|
||||
Examples:
|
||||
- Greeting context -> "Can you scan this repo and suggest the best first task to tackle?"
|
||||
- Bug-fix context -> "Can you reproduce this bug and propose the smallest safe fix?"
|
||||
- Feature context -> "Let's implement this incrementally; start with the MVP version first."
|
||||
- Conversation is complete -> "NO_SUGGESTION"
|
||||
@@ -11,6 +11,7 @@ export namespace SessionStatus {
|
||||
.union([
|
||||
z.object({
|
||||
type: z.literal("idle"),
|
||||
suggestion: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("retry"),
|
||||
|
||||
@@ -149,7 +149,6 @@ function fake(
|
||||
state: { status: "pending", input: {}, raw: "" },
|
||||
}
|
||||
},
|
||||
metadata: Effect.fn("TestSessionProcessor.metadata")(() => Effect.void),
|
||||
process: Effect.fn("TestSessionProcessor.process")(() => Effect.succeed(result)),
|
||||
} satisfies SessionProcessorModule.SessionProcessor.Handle
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test"
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { tool, type ModelMessage } from "ai"
|
||||
import { Cause, Effect, Exit, Stream } from "effect"
|
||||
import { Cause, Exit, Stream } from "effect"
|
||||
import z from "zod"
|
||||
import { makeRuntime } from "../../src/effect/run-service"
|
||||
import { LLM } from "../../src/session/llm"
|
||||
@@ -541,94 +541,6 @@ describe("session.llm.stream", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("service stream preserves fullStream backpressure", async () => {
|
||||
const release = deferred<void>()
|
||||
let pulled = false
|
||||
const mock = spyOn(LLM, "stream").mockResolvedValue({
|
||||
fullStream: {
|
||||
[Symbol.asyncIterator]() {
|
||||
let i = 0
|
||||
return {
|
||||
next: async () => {
|
||||
if (i === 0) {
|
||||
i += 1
|
||||
return { done: false, value: { type: "start" } as LLM.Event }
|
||||
}
|
||||
if (i === 1) {
|
||||
pulled = true
|
||||
await release.promise
|
||||
i += 1
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
rawFinishReason: "stop",
|
||||
totalUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
},
|
||||
} as LLM.Event,
|
||||
}
|
||||
}
|
||||
return { done: true, value: undefined }
|
||||
},
|
||||
return: async () => ({ done: true, value: undefined }),
|
||||
}
|
||||
},
|
||||
},
|
||||
} as Awaited<ReturnType<typeof LLM.stream>>)
|
||||
|
||||
await using tmp = await tmpdir()
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const sessionID = SessionID.make("session-test-service-backpressure")
|
||||
const { runPromise } = makeRuntime(LLM.Service, LLM.defaultLayer)
|
||||
await runPromise((svc) =>
|
||||
svc
|
||||
.stream({
|
||||
user: {
|
||||
id: MessageID.make("user-service-backpressure"),
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "test",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
} satisfies MessageV2.User,
|
||||
sessionID,
|
||||
model: {} as Provider.Model,
|
||||
agent: {
|
||||
name: "test",
|
||||
mode: "primary",
|
||||
options: {},
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
} satisfies Agent.Info,
|
||||
system: [],
|
||||
messages: [],
|
||||
tools: {},
|
||||
})
|
||||
.pipe(
|
||||
Stream.tap((event) =>
|
||||
event.type === "start"
|
||||
? Effect.sync(() => {
|
||||
expect(pulled).toBe(false)
|
||||
release.resolve()
|
||||
})
|
||||
: Effect.void,
|
||||
),
|
||||
Stream.runDrain,
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
mock.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps tools enabled by prompt permissions", async () => {
|
||||
const server = state.server
|
||||
if (!server) {
|
||||
|
||||
@@ -532,93 +532,6 @@ it.effect("failed subtask preserves metadata on error tool state", () =>
|
||||
),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"task tool preserves session metadata while still running",
|
||||
() =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const child = SessionID.make("task-child")
|
||||
const init = spyOn(TaskTool, "init").mockResolvedValue({
|
||||
description: "task",
|
||||
parameters: z.object({
|
||||
description: z.string(),
|
||||
prompt: z.string(),
|
||||
subagent_type: z.string(),
|
||||
task_id: z.string().optional(),
|
||||
command: z.string().optional(),
|
||||
}),
|
||||
execute: async (_args, ctx) => {
|
||||
ctx.metadata({
|
||||
title: "inspect bug",
|
||||
metadata: {
|
||||
sessionId: child,
|
||||
model: ref,
|
||||
},
|
||||
})
|
||||
return {
|
||||
title: "inspect bug",
|
||||
metadata: {
|
||||
sessionId: child,
|
||||
model: ref,
|
||||
},
|
||||
output: "",
|
||||
}
|
||||
},
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => init.mockRestore()))
|
||||
|
||||
const { test, prompt, chat } = yield* boot({ title: "Pinned" })
|
||||
yield* test.push((input) => {
|
||||
const args = {
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
subagent_type: "general",
|
||||
}
|
||||
const exec = input.tools.task?.execute
|
||||
if (!exec) throw new Error("task tool missing execute")
|
||||
|
||||
return stream(start(), toolInputStart("task-1", "task")).pipe(
|
||||
Stream.concat(
|
||||
Stream.fromEffect(
|
||||
Effect.promise(async () => {
|
||||
void exec(args, {
|
||||
toolCallId: "task-1",
|
||||
abortSignal: new AbortController().signal,
|
||||
messages: input.messages,
|
||||
})
|
||||
return toolCall("task-1", "task", args)
|
||||
}),
|
||||
),
|
||||
),
|
||||
Stream.concat(Stream.fromEffect(Effect.never)),
|
||||
)
|
||||
})
|
||||
yield* user(chat.id, "launch a subagent")
|
||||
|
||||
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
||||
const tool = yield* Effect.promise(async () => {
|
||||
const end = Date.now() + 2_000
|
||||
for (;;) {
|
||||
const msgs = await MessageV2.filterCompacted(MessageV2.stream(chat.id))
|
||||
const msg = msgs.findLast((item) => item.info.role === "assistant")
|
||||
const part = msg?.parts.find((item): item is MessageV2.ToolPart => item.type === "tool")
|
||||
if (part?.state.status === "running") return part
|
||||
if (Date.now() > end) throw new Error("timed out waiting for running task tool")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
})
|
||||
|
||||
if (tool.state.status !== "running") throw new Error("expected running task tool")
|
||||
expect(tool.state.metadata?.sessionId).toBe(child)
|
||||
|
||||
yield* Fiber.interrupt(fiber)
|
||||
}),
|
||||
{ git: true, config: cfg },
|
||||
),
|
||||
30_000,
|
||||
)
|
||||
|
||||
it.effect("loop sets status to busy then idle", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
|
||||
@@ -126,6 +126,7 @@ export type EventPermissionReplied = {
|
||||
export type SessionStatus =
|
||||
| {
|
||||
type: "idle"
|
||||
suggestion?: string
|
||||
}
|
||||
| {
|
||||
type: "retry"
|
||||
|
||||
Reference in New Issue
Block a user