mirror of
https://github.com/cline/cline.git
synced 2026-09-24 23:20:16 +08:00
feat: prompt queue
This commit is contained in:
@@ -204,9 +204,11 @@ function ChatThreadPane({
|
||||
error,
|
||||
summary,
|
||||
fileDiffs,
|
||||
promptsInQueue,
|
||||
pendingToolApprovals,
|
||||
setConfig,
|
||||
sendPrompt,
|
||||
steerPromptInQueue,
|
||||
approveToolApproval,
|
||||
rejectToolApproval,
|
||||
reset,
|
||||
@@ -773,6 +775,9 @@ function ChatThreadPane({
|
||||
}))
|
||||
}
|
||||
onPromptInputChange={setPromptInput}
|
||||
onSteerPromptInQueue={(promptId) => {
|
||||
void steerPromptInQueue(promptId);
|
||||
}}
|
||||
onProviderChange={(nextProvider) =>
|
||||
setConfig((prev) => {
|
||||
const selected = providerCredentials[nextProvider];
|
||||
@@ -798,6 +803,7 @@ function ChatThreadPane({
|
||||
gitBranch={gitBranch}
|
||||
model={config.model}
|
||||
mode={config.mode}
|
||||
promptsInQueue={promptsInQueue}
|
||||
promptInput={promptInput}
|
||||
provider={config.provider}
|
||||
status={status}
|
||||
|
||||
@@ -29,13 +29,13 @@ export const MemoizedMarkdown = memo(
|
||||
const blocks = useMemo(() => parseMarkdownIntoBlocks(content), [content]);
|
||||
const occurrences = new Map<string, number>();
|
||||
|
||||
return blocks.map((block) => {
|
||||
return blocks.map((block, index) => {
|
||||
const occurrence = (occurrences.get(block) ?? 0) + 1;
|
||||
occurrences.set(block, occurrence);
|
||||
return (
|
||||
<MemoizedMarkdownBlock
|
||||
content={block}
|
||||
key={`${id}-block_${occurrence}-${block.slice(0, 24)}`}
|
||||
key={`${id}-block_${index}-${occurrence}`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { useWorkspace } from "@/contexts/workspace-context";
|
||||
import type { PromptInQueue } from "@/hooks/chat-session/types";
|
||||
import type { ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
@@ -102,9 +103,11 @@ type ChatInputBarProps = {
|
||||
onSend: () => void;
|
||||
onAbort: () => void;
|
||||
onReset: () => void;
|
||||
promptsInQueue: PromptInQueue[];
|
||||
attachments: Array<{ id: string; name: string; isImage: boolean }>;
|
||||
onAttachFiles: (files: File[]) => void;
|
||||
onRemoveAttachment: (id: string) => void;
|
||||
onSteerPromptInQueue: (promptId: string) => void;
|
||||
summary: {
|
||||
toolCalls: number;
|
||||
tokensIn: number;
|
||||
@@ -129,9 +132,11 @@ export function ChatInputBar({
|
||||
onSend,
|
||||
onAbort,
|
||||
onReset,
|
||||
promptsInQueue,
|
||||
attachments,
|
||||
onAttachFiles,
|
||||
onRemoveAttachment,
|
||||
onSteerPromptInQueue,
|
||||
summary,
|
||||
}: ChatInputBarProps) {
|
||||
const {
|
||||
@@ -143,16 +148,16 @@ export function ChatInputBar({
|
||||
} = useWorkspace();
|
||||
const isBusy =
|
||||
status === "starting" || status === "running" || status === "stopping";
|
||||
const canAbort = isBusy;
|
||||
const hasDraft = promptInput.trim().length > 0 || attachments.length > 0;
|
||||
const canAbort = isBusy && !hasDraft;
|
||||
const [modelSupportsReasoning, setModelSupportsReasoning] = useState(() =>
|
||||
hasReasoningCapability(FALLBACK_PROVIDER_REASONING_MODELS, provider, model),
|
||||
);
|
||||
const canSend =
|
||||
(promptInput.trim().length > 0 || attachments.length > 0) && !isBusy;
|
||||
const canSend = hasDraft;
|
||||
const effortLevels = ["Low", "Medium", "High"] as const;
|
||||
const [effortIndex, setEffortIndex] = useState(1);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const promptInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const promptInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [cursorIndex, setCursorIndex] = useState(() => promptInput.length);
|
||||
const [mentionOpen, setMentionOpen] = useState(false);
|
||||
const [activeMention, setActiveMention] = useState<ActiveMention | null>(
|
||||
@@ -182,6 +187,20 @@ export function ChatInputBar({
|
||||
setCursorIndex((prev) => Math.min(prev, promptInput.length));
|
||||
}, [promptInput.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const input = promptInputRef.current;
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
input.style.height = "0px";
|
||||
const styles = window.getComputedStyle(input);
|
||||
const lineHeight = Number.parseFloat(styles.lineHeight) || 20;
|
||||
const maxHeight = lineHeight * 10;
|
||||
const nextHeight = Math.min(input.scrollHeight, maxHeight);
|
||||
input.style.height = `${nextHeight}px`;
|
||||
input.style.overflowY = input.scrollHeight > maxHeight ? "auto" : "hidden";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const nextMention = getActiveMention(promptInput, cursorIndex);
|
||||
setActiveMention(nextMention);
|
||||
@@ -278,6 +297,58 @@ export function ChatInputBar({
|
||||
<div className="border-t border-border bg-card">
|
||||
{/* Input area */}
|
||||
<div className="px-4 py-3">
|
||||
{promptsInQueue.length > 0 && (
|
||||
<div className="mb-3 rounded-lg border border-border bg-background/70 p-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="text-[11px] font-medium text-foreground">
|
||||
Queued for upcoming turns
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Steer runs first on the next turn
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{promptsInQueue.map((item, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start justify-between gap-3 rounded-md border px-2.5 py-2",
|
||||
item.steer
|
||||
? "border-amber-300/60 bg-amber-500/8"
|
||||
: "border-border/70 bg-muted/30",
|
||||
)}
|
||||
key={item.id}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<span>{item.steer ? "Steer" : `Queue ${index + 1}`}</span>
|
||||
{item.steer ? (
|
||||
<span className="rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[9px] font-medium text-amber-700">
|
||||
Next turn
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="line-clamp-2 text-xs text-foreground whitespace-pre-wrap wrap-break-word">
|
||||
{item.prompt}
|
||||
</div>
|
||||
</div>
|
||||
{!item.steer ? (
|
||||
<button
|
||||
className="shrink-0 rounded-md border border-border px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={() => onSteerPromptInQueue(item.id)}
|
||||
type="button"
|
||||
>
|
||||
Steer
|
||||
</button>
|
||||
) : (
|
||||
<div className="shrink-0 text-[10px] text-amber-700">
|
||||
Steering
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
{mentionOpen && (
|
||||
<div className="absolute inset-x-0 bottom-full z-50 mb-1 max-h-56 overflow-y-auto rounded-lg border border-border bg-popover p-1 shadow-xl">
|
||||
@@ -311,9 +382,9 @@ export function ChatInputBar({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-background px-3 py-2.5 focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20 transition-all">
|
||||
<input
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
||||
<div className="flex items-end gap-2 rounded-lg border border-border bg-background px-3 py-2.5 transition-all focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20">
|
||||
<textarea
|
||||
className="max-h-60 min-h-5 flex-1 resize-none bg-transparent text-sm leading-5 text-foreground placeholder:text-muted-foreground outline-none"
|
||||
onChange={(e) => {
|
||||
onPromptInputChange(e.target.value);
|
||||
setCursorIndex(
|
||||
@@ -355,10 +426,10 @@ export function ChatInputBar({
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (canAbort) {
|
||||
onAbort();
|
||||
} else if (canSend) {
|
||||
if (canSend) {
|
||||
onSend();
|
||||
} else if (canAbort) {
|
||||
onAbort();
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -369,10 +440,11 @@ export function ChatInputBar({
|
||||
}
|
||||
placeholder={
|
||||
isBusy
|
||||
? "Agent is working..."
|
||||
? "Agent is working... submit to queue another message"
|
||||
: "Enter your question or type / for workflow or @ to attach files"
|
||||
}
|
||||
ref={promptInputRef}
|
||||
rows={1}
|
||||
value={promptInput}
|
||||
/>
|
||||
</div>
|
||||
@@ -443,13 +515,13 @@ export function ChatInputBar({
|
||||
<button
|
||||
className="rounded-full bg-foreground p-1.5 text-background hover:bg-foreground/80 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={!canSend && !canAbort}
|
||||
onClick={canAbort ? onAbort : onSend}
|
||||
onClick={canSend ? onSend : onAbort}
|
||||
type="button"
|
||||
>
|
||||
{canAbort ? (
|
||||
<CircleStop className="h-4 w-4" />
|
||||
) : (
|
||||
{canSend ? (
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
) : (
|
||||
<CircleStop className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,14 @@ import {
|
||||
ShieldAlert,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ChatMessage, ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -50,6 +57,8 @@ type ToolApprovalRequestItem = {
|
||||
};
|
||||
|
||||
const IS_DEBUG = process.env.NODE_ENV === "test";
|
||||
const STICKY_BOTTOM_THRESHOLD_PX = 24;
|
||||
const SCROLL_TO_BOTTOM_BUTTON_THRESHOLD_PX = 120;
|
||||
|
||||
function ChatMessagesImpl({
|
||||
sessionId: _sessionId,
|
||||
@@ -67,7 +76,7 @@ function ChatMessagesImpl({
|
||||
onStartChat,
|
||||
}: ChatMessagesProps) {
|
||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
const hasAppliedInitialScrollRef = useRef(false);
|
||||
const shouldStickToBottomRef = useRef(true);
|
||||
const hasMessages = messages.length > 0;
|
||||
const lastErrorMessage = [...messages]
|
||||
.reverse()
|
||||
@@ -95,6 +104,7 @@ function ChatMessagesImpl({
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
shouldStickToBottomRef.current = true;
|
||||
viewport.scrollTo({ top: viewport.scrollHeight, behavior });
|
||||
setShowScrollToBottom((prev) => (prev ? false : prev));
|
||||
},
|
||||
@@ -123,7 +133,10 @@ function ChatMessagesImpl({
|
||||
const updateScrollToBottomVisibility = () => {
|
||||
const distanceFromBottom =
|
||||
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
||||
const shouldShow = distanceFromBottom > 120;
|
||||
shouldStickToBottomRef.current =
|
||||
distanceFromBottom <= STICKY_BOTTOM_THRESHOLD_PX;
|
||||
const shouldShow =
|
||||
distanceFromBottom > SCROLL_TO_BOTTOM_BUTTON_THRESHOLD_PX;
|
||||
setShowScrollToBottom((prev) =>
|
||||
prev === shouldShow ? prev : shouldShow,
|
||||
);
|
||||
@@ -137,26 +150,12 @@ function ChatMessagesImpl({
|
||||
};
|
||||
}, [getViewport]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAppliedInitialScrollRef.current) {
|
||||
useLayoutEffect(() => {
|
||||
if (!shouldStickToBottomRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasScrollableContent =
|
||||
messages.length > 0 || pendingToolApprovals.length > 0;
|
||||
if (!hasScrollableContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
scrollToBottom("auto");
|
||||
hasAppliedInitialScrollRef.current = true;
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [messages.length, pendingToolApprovals.length, scrollToBottom]);
|
||||
scrollToBottom("auto");
|
||||
}, [scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeRequestIds = new Set(
|
||||
|
||||
@@ -67,6 +67,7 @@ export type ChatWsResponseEvent = {
|
||||
sessionId?: string;
|
||||
result?: ChatApiResult;
|
||||
ok?: boolean;
|
||||
queued?: boolean;
|
||||
};
|
||||
error?: string;
|
||||
};
|
||||
@@ -98,3 +99,9 @@ export type SerializedAttachments = {
|
||||
userImages: string[];
|
||||
userFiles: SerializedAttachmentFile[];
|
||||
};
|
||||
|
||||
export type PromptInQueue = {
|
||||
id: string;
|
||||
prompt: string;
|
||||
steer: boolean;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,31 +33,37 @@ import {
|
||||
DEFAULT_RPC_CLIENT_TYPE,
|
||||
type HostContext,
|
||||
type JsonRecord,
|
||||
type LiveSession,
|
||||
type PromptInQueue,
|
||||
type QueuedChatTurn,
|
||||
type ToolApprovalRequestItem,
|
||||
} from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getNestedString(obj: unknown, ...keys: string[]): string | undefined {
|
||||
let current: unknown = obj;
|
||||
for (const key of keys) {
|
||||
if (!current || typeof current !== "object") return undefined;
|
||||
current = (current as JsonRecord)[key];
|
||||
}
|
||||
return typeof current === "string" ? current : undefined;
|
||||
}
|
||||
|
||||
function setRuntimeHomeDir(config: unknown) {
|
||||
if (!config || typeof config !== "object") {
|
||||
const homeDir = getNestedString(config, "sessions", "homeDir")?.trim();
|
||||
if (homeDir) {
|
||||
setHomeDir(homeDir);
|
||||
} else {
|
||||
setHomeDirIfUnset(homedir());
|
||||
return;
|
||||
}
|
||||
const sessions = (config as JsonRecord).sessions;
|
||||
const homeDir =
|
||||
sessions && typeof sessions === "object"
|
||||
? ((sessions as JsonRecord).homeDir as string | undefined)
|
||||
: undefined;
|
||||
const normalized = homeDir?.trim();
|
||||
if (normalized) {
|
||||
setHomeDir(normalized);
|
||||
return;
|
||||
}
|
||||
setHomeDirIfUnset(homedir());
|
||||
}
|
||||
|
||||
function addRuntimeLoggerContext(config: unknown) {
|
||||
if (!config || typeof config !== "object") {
|
||||
return;
|
||||
}
|
||||
if (!config || typeof config !== "object") return;
|
||||
|
||||
const record = config as JsonRecord;
|
||||
const existing =
|
||||
record.logger && typeof record.logger === "object"
|
||||
@@ -67,6 +73,7 @@ function addRuntimeLoggerContext(config: unknown) {
|
||||
existing.bindings && typeof existing.bindings === "object"
|
||||
? { ...(existing.bindings as JsonRecord) }
|
||||
: {};
|
||||
|
||||
record.logger = {
|
||||
...existing,
|
||||
name:
|
||||
@@ -81,30 +88,35 @@ function addRuntimeLoggerContext(config: unknown) {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bridge script resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BRIDGE_SCRIPT = "chat-runtime-bridge.ts";
|
||||
const BRIDGE_SEARCH_DIRS = [
|
||||
["apps", "code", "scripts"],
|
||||
["packages", "app", "scripts"],
|
||||
["app", "scripts"],
|
||||
];
|
||||
|
||||
function resolveChatRuntimeBridgeScriptPath(ctx: HostContext): string | null {
|
||||
const candidates = [
|
||||
join(
|
||||
ctx.workspaceRoot,
|
||||
"apps",
|
||||
"code",
|
||||
"scripts",
|
||||
"chat-runtime-bridge.ts",
|
||||
),
|
||||
join(
|
||||
ctx.workspaceRoot,
|
||||
"packages",
|
||||
"app",
|
||||
"scripts",
|
||||
"chat-runtime-bridge.ts",
|
||||
),
|
||||
join(ctx.workspaceRoot, "app", "scripts", "chat-runtime-bridge.ts"),
|
||||
join(process.cwd(), "app", "scripts", "chat-runtime-bridge.ts"),
|
||||
join(process.cwd(), "..", "scripts", "chat-runtime-bridge.ts"),
|
||||
join(process.cwd(), "scripts", "chat-runtime-bridge.ts"),
|
||||
];
|
||||
return candidates.find((candidate) => existsSync(candidate)) ?? null;
|
||||
for (const segments of BRIDGE_SEARCH_DIRS) {
|
||||
const candidate = join(ctx.workspaceRoot, ...segments, BRIDGE_SCRIPT);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
for (const base of [process.cwd()]) {
|
||||
for (const rel of [["app", "scripts"], ["..", "scripts"], ["scripts"]]) {
|
||||
const candidate = join(base, ...rel, BRIDGE_SCRIPT);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Child process line reader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readChildLines(
|
||||
stream: NodeJS.ReadableStream,
|
||||
onLine: (line: string) => void,
|
||||
@@ -112,28 +124,104 @@ function readChildLines(
|
||||
let buffer = "";
|
||||
stream.on("data", (chunk) => {
|
||||
buffer += String(chunk);
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
while (newlineIndex >= 0) {
|
||||
const line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (line) {
|
||||
onLine(line);
|
||||
}
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
let idx = buffer.indexOf("\n");
|
||||
while (idx >= 0) {
|
||||
const line = buffer.slice(0, idx).trim();
|
||||
buffer = buffer.slice(idx + 1);
|
||||
if (line) onLine(line);
|
||||
idx = buffer.indexOf("\n");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bridge lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleBridgeStdoutLine(ctx: HostContext, parsed: JsonRecord) {
|
||||
const type = String(parsed.type ?? "");
|
||||
const sessionId =
|
||||
typeof parsed.sessionId === "string" ? parsed.sessionId : "";
|
||||
|
||||
switch (type) {
|
||||
case "ready":
|
||||
ctx.bridgeReady = true;
|
||||
return;
|
||||
|
||||
case "response": {
|
||||
const requestId = String(parsed.requestId ?? "");
|
||||
const pending = ctx.pendingBridge.get(requestId);
|
||||
if (!pending) return;
|
||||
ctx.pendingBridge.delete(requestId);
|
||||
if (typeof parsed.error === "string" && parsed.error.trim()) {
|
||||
pending.reject(new Error(parsed.error));
|
||||
} else {
|
||||
pending.resolve(parsed.response ?? null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
case "chat_text":
|
||||
emitChunk(ctx, sessionId, "chat_text", String(parsed.chunk ?? ""));
|
||||
return;
|
||||
|
||||
case "tool_call_start":
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_tool_call_start",
|
||||
JSON.stringify({
|
||||
toolCallId: parsed.toolCallId,
|
||||
toolName: parsed.toolName,
|
||||
input: parsed.input,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
|
||||
case "tool_call_end":
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_tool_call_end",
|
||||
JSON.stringify({
|
||||
toolCallId: parsed.toolCallId,
|
||||
toolName: parsed.toolName,
|
||||
output: parsed.output,
|
||||
error: parsed.error,
|
||||
durationMs: parsed.durationMs,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
|
||||
case "error": {
|
||||
const message =
|
||||
typeof parsed.message === "string"
|
||||
? parsed.message
|
||||
: "chat runtime bridge error";
|
||||
if (sessionId) {
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_core_log",
|
||||
JSON.stringify({ level: "error", message }),
|
||||
);
|
||||
} else {
|
||||
console.error("[chat-runtime-bridge]", message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureBridgeStarted(ctx: HostContext) {
|
||||
if (ctx.bridgeChild && ctx.bridgeChild.exitCode === null && ctx.bridgeReady) {
|
||||
return;
|
||||
}
|
||||
if (ctx.bridgeChild?.exitCode === null && ctx.bridgeReady) return;
|
||||
|
||||
const scriptPath = resolveChatRuntimeBridgeScriptPath(ctx);
|
||||
if (!scriptPath) {
|
||||
throw new Error("chat runtime bridge script not found");
|
||||
}
|
||||
if (!scriptPath) throw new Error("chat runtime bridge script not found");
|
||||
|
||||
ctx.bridgeReady = false;
|
||||
mkdirSync(toolApprovalDir(), { recursive: true });
|
||||
|
||||
ctx.bridgeChild = spawn("bun", [scriptPath], {
|
||||
cwd: ctx.workspaceRoot,
|
||||
env: {
|
||||
@@ -146,191 +234,139 @@ export function ensureBridgeStarted(ctx: HostContext) {
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
readChildLines(ctx.bridgeChild.stdout, (line) => {
|
||||
const parsed = JSON.parse(line) as JsonRecord;
|
||||
const type = String(parsed.type ?? "");
|
||||
if (type === "ready") {
|
||||
ctx.bridgeReady = true;
|
||||
return;
|
||||
}
|
||||
if (type === "response") {
|
||||
const requestId = String(parsed.requestId ?? "");
|
||||
const pending = ctx.pendingBridge.get(requestId);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
ctx.pendingBridge.delete(requestId);
|
||||
if (typeof parsed.error === "string" && parsed.error.trim()) {
|
||||
pending.reject(new Error(parsed.error));
|
||||
return;
|
||||
}
|
||||
pending.resolve(parsed.response ?? null);
|
||||
return;
|
||||
}
|
||||
if (type === "chat_text") {
|
||||
emitChunk(
|
||||
ctx,
|
||||
String(parsed.sessionId ?? ""),
|
||||
"chat_text",
|
||||
String(parsed.chunk ?? ""),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (type === "tool_call_start") {
|
||||
emitChunk(
|
||||
ctx,
|
||||
String(parsed.sessionId ?? ""),
|
||||
"chat_tool_call_start",
|
||||
JSON.stringify({
|
||||
toolCallId: parsed.toolCallId,
|
||||
toolName: parsed.toolName,
|
||||
input: parsed.input,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (type === "tool_call_end") {
|
||||
emitChunk(
|
||||
ctx,
|
||||
String(parsed.sessionId ?? ""),
|
||||
"chat_tool_call_end",
|
||||
JSON.stringify({
|
||||
toolCallId: parsed.toolCallId,
|
||||
toolName: parsed.toolName,
|
||||
output: parsed.output,
|
||||
error: parsed.error,
|
||||
durationMs: parsed.durationMs,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (type === "error") {
|
||||
const sessionId =
|
||||
typeof parsed.sessionId === "string" ? parsed.sessionId : "";
|
||||
const message =
|
||||
typeof parsed.message === "string"
|
||||
? parsed.message
|
||||
: "chat runtime bridge error";
|
||||
if (sessionId) {
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_core_log",
|
||||
JSON.stringify({
|
||||
level: "error",
|
||||
message,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.error("[chat-runtime-bridge]", message);
|
||||
}
|
||||
handleBridgeStdoutLine(ctx, JSON.parse(line) as JsonRecord);
|
||||
});
|
||||
|
||||
readChildLines(ctx.bridgeChild.stderr, (line) => {
|
||||
console.error("[chat-runtime-bridge]", line);
|
||||
});
|
||||
|
||||
ctx.bridgeChild.on("exit", () => {
|
||||
ctx.bridgeReady = false;
|
||||
ctx.bridgeChild = null;
|
||||
for (const [requestId, pending] of ctx.pendingBridge.entries()) {
|
||||
ctx.pendingBridge.delete(requestId);
|
||||
for (const [, pending] of ctx.pendingBridge) {
|
||||
pending.reject(new Error("chat runtime bridge exited"));
|
||||
}
|
||||
ctx.pendingBridge.clear();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bridge RPC
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function runBridgeCommand(
|
||||
ctx: HostContext,
|
||||
command: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
ensureBridgeStarted(ctx);
|
||||
const child = ctx.bridgeChild;
|
||||
if (!child || !child.stdin) {
|
||||
throw new Error("chat runtime bridge unavailable");
|
||||
}
|
||||
if (!child?.stdin) throw new Error("chat runtime bridge unavailable");
|
||||
|
||||
const requestId = `bridge_${ctx.bridgeRequestId++}`;
|
||||
const envelope = JSON.stringify({
|
||||
type: "request",
|
||||
requestId,
|
||||
command,
|
||||
});
|
||||
return await new Promise((resolve, reject) => {
|
||||
const envelope = JSON.stringify({ type: "request", requestId, command });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ctx.pendingBridge.set(requestId, { resolve, reject });
|
||||
child.stdin.write(`${envelope}\n`, (error) => {
|
||||
if (!error) {
|
||||
return;
|
||||
if (error) {
|
||||
ctx.pendingBridge.delete(requestId);
|
||||
reject(error);
|
||||
}
|
||||
ctx.pendingBridge.delete(requestId);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool approval helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sendApprovalSnapshot(ctx: HostContext, sessionId: string) {
|
||||
sendEvent(ctx, "tool_approval_state", {
|
||||
sessionId,
|
||||
items: listPendingToolApprovalsForSession(sessionId, 50),
|
||||
});
|
||||
}
|
||||
|
||||
export function listPendingToolApprovalsForSession(
|
||||
sessionId: string,
|
||||
limit = 20,
|
||||
): ToolApprovalRequestItem[] {
|
||||
const dir = toolApprovalDir();
|
||||
if (!existsSync(dir)) {
|
||||
return [];
|
||||
}
|
||||
const items: ToolApprovalRequestItem[] = [];
|
||||
if (!existsSync(dir)) return [];
|
||||
|
||||
const prefix = toolApprovalRequestPrefix(sessionId);
|
||||
const items: ToolApprovalRequestItem[] = [];
|
||||
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isFile()) {
|
||||
if (
|
||||
!entry.isFile() ||
|
||||
!entry.name.startsWith(prefix) ||
|
||||
!entry.name.endsWith(".json")
|
||||
)
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.startsWith(prefix) || !entry.name.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
readFileSync(join(dir, entry.name), "utf8"),
|
||||
) as ToolApprovalRequestItem;
|
||||
items.push(parsed);
|
||||
items.push(
|
||||
JSON.parse(
|
||||
readFileSync(join(dir, entry.name), "utf8"),
|
||||
) as ToolApprovalRequestItem,
|
||||
);
|
||||
} catch {
|
||||
// Ignore malformed approval files.
|
||||
}
|
||||
}
|
||||
items.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
||||
|
||||
items.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
return items.slice(0, Math.max(1, limit));
|
||||
}
|
||||
|
||||
export function broadcastApprovalSnapshots(ctx: HostContext) {
|
||||
const dir = toolApprovalDir();
|
||||
if (!existsSync(dir)) {
|
||||
return;
|
||||
}
|
||||
if (!existsSync(dir)) return;
|
||||
|
||||
const sessionIds = new Set<string>();
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.includes(".request.")) {
|
||||
continue;
|
||||
}
|
||||
const [sessionId] = entry.name.split(".request.");
|
||||
if (sessionId?.trim()) {
|
||||
sessionIds.add(sessionId.trim());
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.includes(".request.")) continue;
|
||||
const id = entry.name.split(".request.")[0]?.trim();
|
||||
if (id) sessionIds.add(id);
|
||||
}
|
||||
|
||||
for (const sessionId of sessionIds) {
|
||||
sendEvent(ctx, "tool_approval_state", {
|
||||
sessionId,
|
||||
items: listPendingToolApprovalsForSession(sessionId, 50),
|
||||
});
|
||||
sendApprovalSnapshot(ctx, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function makeQueuedTurnId(): string {
|
||||
return `queued_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function getPromptsInQueue(session: LiveSession): PromptInQueue[] {
|
||||
return session.pendingTurns.map((turn) => ({
|
||||
id: turn.id,
|
||||
prompt: turn.prompt,
|
||||
steer: turn.steer,
|
||||
}));
|
||||
}
|
||||
|
||||
function sendPromptsInQueueSnapshot(ctx: HostContext, sessionId: string) {
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
sendEvent(ctx, "prompts_in_queue_state", {
|
||||
sessionId,
|
||||
items: session ? getPromptsInQueue(session) : [],
|
||||
});
|
||||
}
|
||||
|
||||
export function ensureApprovalWatcher(ctx: HostContext) {
|
||||
if (ctx.approvalWatcher) {
|
||||
return;
|
||||
}
|
||||
if (ctx.approvalWatcher) return;
|
||||
mkdirSync(toolApprovalDir(), { recursive: true });
|
||||
ctx.approvalWatcher = watch(toolApprovalDir(), () => {
|
||||
if (ctx.approvalBroadcastTimer) {
|
||||
clearTimeout(ctx.approvalBroadcastTimer);
|
||||
}
|
||||
ctx.approvalBroadcastTimer = setTimeout(() => {
|
||||
broadcastApprovalSnapshots(ctx);
|
||||
}, 50);
|
||||
if (ctx.approvalBroadcastTimer) clearTimeout(ctx.approvalBroadcastTimer);
|
||||
ctx.approvalBroadcastTimer = setTimeout(
|
||||
() => broadcastApprovalSnapshots(ctx),
|
||||
50,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -340,9 +376,9 @@ export async function respondToolApproval(
|
||||
) {
|
||||
const sessionId = String(args?.sessionId ?? "").trim();
|
||||
const requestId = String(args?.requestId ?? "").trim();
|
||||
if (!sessionId || !requestId) {
|
||||
if (!sessionId || !requestId)
|
||||
throw new Error("sessionId and requestId are required");
|
||||
}
|
||||
|
||||
const path = toolApprovalDecisionPath(sessionId, requestId);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(
|
||||
@@ -353,166 +389,317 @@ export async function respondToolApproval(
|
||||
ts: nowMs(),
|
||||
}),
|
||||
);
|
||||
|
||||
const requestPath = join(
|
||||
toolApprovalDir(),
|
||||
`${sessionId}.request.${requestId}.json`,
|
||||
);
|
||||
if (existsSync(requestPath)) {
|
||||
unlinkSync(requestPath);
|
||||
}
|
||||
sendEvent(ctx, "tool_approval_state", {
|
||||
sessionId,
|
||||
items: listPendingToolApprovalsForSession(sessionId, 50),
|
||||
});
|
||||
if (existsSync(requestPath)) unlinkSync(requestPath);
|
||||
|
||||
sendApprovalSnapshot(ctx, sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function handleChatSessionCommand(
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat turn execution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function executeChatTurn(
|
||||
ctx: HostContext,
|
||||
request: ChatSessionCommandRequest,
|
||||
): Promise<unknown> {
|
||||
if (request.action === "start") {
|
||||
if (!request.config) {
|
||||
throw new Error("missing config for start action");
|
||||
}
|
||||
setRuntimeHomeDir(request.config);
|
||||
addRuntimeLoggerContext(request.config);
|
||||
const response = (await runBridgeCommand(ctx, {
|
||||
action: "start",
|
||||
config: request.config,
|
||||
})) as { sessionId?: string };
|
||||
const sessionId = response.sessionId?.trim();
|
||||
if (!sessionId) {
|
||||
throw new Error("chat runtime bridge start response missing session id");
|
||||
}
|
||||
await runBridgeCommand(ctx, {
|
||||
action: "set_sessions",
|
||||
sessionIds: [sessionId],
|
||||
});
|
||||
ctx.liveSessions.set(sessionId, {
|
||||
config: request.config,
|
||||
messages: [],
|
||||
busy: false,
|
||||
startedAt: nowMs(),
|
||||
status: "idle",
|
||||
});
|
||||
return { sessionId };
|
||||
}
|
||||
sessionId: string,
|
||||
session: LiveSession,
|
||||
turn: QueuedChatTurn,
|
||||
): Promise<ChatTurnResult> {
|
||||
if (turn.config) session.config = turn.config;
|
||||
if (turn.prompt) session.prompt = turn.prompt;
|
||||
|
||||
if (request.action === "send") {
|
||||
const prompt = request.prompt?.trim() || "";
|
||||
const hasAttachments =
|
||||
(request.attachments?.userImages?.length ?? 0) > 0 ||
|
||||
(request.attachments?.userFiles?.length ?? 0) > 0;
|
||||
if (!prompt && !hasAttachments) {
|
||||
throw new Error("prompt is required for send action");
|
||||
}
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (!sessionId) {
|
||||
throw new Error("sessionId is required for send action");
|
||||
}
|
||||
session.busy = true;
|
||||
session.status = "running";
|
||||
session.endedAt = undefined;
|
||||
|
||||
let session = ctx.liveSessions.get(sessionId);
|
||||
if (!session) {
|
||||
if (!request.config) {
|
||||
throw new Error("session not found. start a new session.");
|
||||
}
|
||||
const messages = readPersistedChatMessages(sessionId);
|
||||
if (!messages) {
|
||||
throw new Error("session not found. start a new session.");
|
||||
}
|
||||
session = {
|
||||
config: request.config,
|
||||
messages,
|
||||
busy: false,
|
||||
startedAt: nowMs(),
|
||||
status: "idle",
|
||||
prompt: derivePromptFromMessages(messages),
|
||||
title: readSessionMetadataTitle(sessionId),
|
||||
};
|
||||
ctx.liveSessions.set(sessionId, session);
|
||||
}
|
||||
if (request.config) {
|
||||
session.config = request.config;
|
||||
}
|
||||
if (session.busy) {
|
||||
throw new Error("session is busy. wait for current response to finish.");
|
||||
}
|
||||
session.busy = true;
|
||||
session.status = "running";
|
||||
session.endedAt = undefined;
|
||||
if (prompt) {
|
||||
session.prompt = prompt;
|
||||
}
|
||||
setRuntimeHomeDir(session.config);
|
||||
addRuntimeLoggerContext(session.config);
|
||||
await runBridgeCommand(ctx, {
|
||||
action: "set_sessions",
|
||||
sessionIds: [sessionId],
|
||||
});
|
||||
setRuntimeHomeDir(session.config);
|
||||
addRuntimeLoggerContext(session.config);
|
||||
|
||||
await runBridgeCommand(ctx, {
|
||||
action: "set_sessions",
|
||||
sessionIds: [sessionId],
|
||||
});
|
||||
|
||||
try {
|
||||
const resultEnvelope = (await runBridgeCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
request: {
|
||||
config: session.config,
|
||||
messages: session.messages,
|
||||
prompt,
|
||||
attachments: request.attachments,
|
||||
prompt: turn.prompt,
|
||||
attachments: turn.attachments,
|
||||
},
|
||||
})) as { result?: ChatTurnResult };
|
||||
const result = resultEnvelope.result;
|
||||
if (!result) {
|
||||
throw new Error("chat runtime bridge send response missing result");
|
||||
}
|
||||
|
||||
const persistedMessages = persistUsageInMessages(
|
||||
const result = resultEnvelope.result;
|
||||
if (!result)
|
||||
throw new Error("chat runtime bridge send response missing result");
|
||||
|
||||
session.messages = persistUsageInMessages(
|
||||
(Array.isArray(result.messages) ? result.messages : []) as unknown[],
|
||||
session.config,
|
||||
result,
|
||||
);
|
||||
session.messages = persistedMessages;
|
||||
session.busy = false;
|
||||
session.status = normalizeChatFinishStatus(result.finishReason);
|
||||
session.endedAt = nowMs();
|
||||
persistSessionMessages(sessionId, persistedMessages);
|
||||
sendEvent(ctx, "tool_approval_state", {
|
||||
sessionId,
|
||||
items: listPendingToolApprovalsForSession(sessionId, 50),
|
||||
});
|
||||
return {
|
||||
sessionId,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
if (request.action === "abort") {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (sessionId) {
|
||||
await runBridgeCommand(ctx, { action: "abort", sessionId });
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (session) {
|
||||
session.busy = false;
|
||||
session.status = "cancelled";
|
||||
session.endedAt = nowMs();
|
||||
}
|
||||
}
|
||||
return {
|
||||
sessionId: request.sessionId,
|
||||
ok: true,
|
||||
};
|
||||
}
|
||||
persistSessionMessages(sessionId, session.messages);
|
||||
sendApprovalSnapshot(ctx, sessionId);
|
||||
|
||||
if (request.action === "reset") {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (sessionId) {
|
||||
ctx.liveSessions.delete(sessionId);
|
||||
await runBridgeCommand(ctx, { action: "reset", sessionId });
|
||||
}
|
||||
return {
|
||||
sessionId: request.sessionId,
|
||||
ok: true,
|
||||
};
|
||||
return result;
|
||||
} finally {
|
||||
session.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("unsupported action");
|
||||
async function drainSessionQueue(
|
||||
ctx: HostContext,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (!session || session.busy) return;
|
||||
|
||||
const nextTurn = session.pendingTurns.shift();
|
||||
if (!nextTurn) return;
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_queued_prompt_start",
|
||||
JSON.stringify({
|
||||
prompt: nextTurn.prompt,
|
||||
attachmentCount:
|
||||
(nextTurn.attachments?.userImages?.length ?? 0) +
|
||||
(nextTurn.attachments?.userFiles?.length ?? 0),
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await executeChatTurn(ctx, sessionId, session, nextTurn);
|
||||
} catch (error) {
|
||||
session.status = "error";
|
||||
session.endedAt = nowMs();
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_core_log",
|
||||
JSON.stringify({
|
||||
level: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (session.pendingTurns.length > 0) {
|
||||
void drainSessionQueue(ctx, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createLiveSession(
|
||||
config: JsonRecord,
|
||||
extra?: Partial<LiveSession>,
|
||||
): LiveSession {
|
||||
return {
|
||||
config,
|
||||
messages: [],
|
||||
pendingTurns: [],
|
||||
busy: false,
|
||||
startedAt: nowMs(),
|
||||
status: "idle",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleStart(
|
||||
ctx: HostContext,
|
||||
request: ChatSessionCommandRequest,
|
||||
) {
|
||||
if (!request.config) throw new Error("missing config for start action");
|
||||
|
||||
setRuntimeHomeDir(request.config);
|
||||
addRuntimeLoggerContext(request.config);
|
||||
|
||||
const response = (await runBridgeCommand(ctx, {
|
||||
action: "start",
|
||||
config: request.config,
|
||||
})) as { sessionId?: string };
|
||||
|
||||
const sessionId = response.sessionId?.trim();
|
||||
if (!sessionId)
|
||||
throw new Error("chat runtime bridge start response missing session id");
|
||||
|
||||
await runBridgeCommand(ctx, {
|
||||
action: "set_sessions",
|
||||
sessionIds: [sessionId],
|
||||
});
|
||||
ctx.liveSessions.set(sessionId, createLiveSession(request.config));
|
||||
|
||||
return { sessionId };
|
||||
}
|
||||
|
||||
async function handleSend(
|
||||
ctx: HostContext,
|
||||
request: ChatSessionCommandRequest,
|
||||
) {
|
||||
const prompt = request.prompt?.trim() || "";
|
||||
const hasAttachments =
|
||||
(request.attachments?.userImages?.length ?? 0) > 0 ||
|
||||
(request.attachments?.userFiles?.length ?? 0) > 0;
|
||||
if (!prompt && !hasAttachments)
|
||||
throw new Error("prompt is required for send action");
|
||||
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (!sessionId) throw new Error("sessionId is required for send action");
|
||||
|
||||
let session = ctx.liveSessions.get(sessionId);
|
||||
if (!session) {
|
||||
if (!request.config)
|
||||
throw new Error("session not found. start a new session.");
|
||||
const messages = readPersistedChatMessages(sessionId);
|
||||
if (!messages) throw new Error("session not found. start a new session.");
|
||||
|
||||
session = createLiveSession(request.config, {
|
||||
messages,
|
||||
prompt: derivePromptFromMessages(messages),
|
||||
title: readSessionMetadataTitle(sessionId),
|
||||
});
|
||||
ctx.liveSessions.set(sessionId, session);
|
||||
}
|
||||
|
||||
if (request.config) session.config = request.config;
|
||||
|
||||
const turn: QueuedChatTurn = {
|
||||
id: makeQueuedTurnId(),
|
||||
prompt,
|
||||
steer: false,
|
||||
config: request.config,
|
||||
attachments: request.attachments,
|
||||
};
|
||||
|
||||
if (session.busy) {
|
||||
session.pendingTurns.push(turn);
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
return {
|
||||
sessionId,
|
||||
ok: true,
|
||||
queued: true,
|
||||
promptsInQueue: getPromptsInQueue(session),
|
||||
};
|
||||
}
|
||||
|
||||
const result = await executeChatTurn(ctx, sessionId, session, turn);
|
||||
if (session.pendingTurns.length > 0) {
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
void drainSessionQueue(ctx, sessionId);
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
result,
|
||||
queued: false,
|
||||
promptsInQueue: getPromptsInQueue(session),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleAbort(
|
||||
ctx: HostContext,
|
||||
request: ChatSessionCommandRequest,
|
||||
) {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (sessionId) {
|
||||
await runBridgeCommand(ctx, { action: "abort", sessionId });
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (session) {
|
||||
session.busy = false;
|
||||
session.pendingTurns = [];
|
||||
session.status = "cancelled";
|
||||
session.endedAt = nowMs();
|
||||
}
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
}
|
||||
return { sessionId: request.sessionId, ok: true };
|
||||
}
|
||||
|
||||
async function handleReset(
|
||||
ctx: HostContext,
|
||||
request: ChatSessionCommandRequest,
|
||||
) {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (sessionId) {
|
||||
ctx.liveSessions.delete(sessionId);
|
||||
await runBridgeCommand(ctx, { action: "reset", sessionId });
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
}
|
||||
return { sessionId: request.sessionId, ok: true };
|
||||
}
|
||||
|
||||
async function handlePendingPrompts(
|
||||
ctx: HostContext,
|
||||
request: ChatSessionCommandRequest,
|
||||
) {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
return {
|
||||
sessionId,
|
||||
promptsInQueue: session ? getPromptsInQueue(session) : [],
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSteerPrompt(
|
||||
ctx: HostContext,
|
||||
request: ChatSessionCommandRequest,
|
||||
) {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
const promptId = request.promptId?.trim();
|
||||
if (!sessionId || !promptId) {
|
||||
throw new Error("sessionId and promptId are required");
|
||||
}
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (!session) {
|
||||
return { sessionId, promptsInQueue: [] };
|
||||
}
|
||||
const existingIndex = session.pendingTurns.findIndex(
|
||||
(turn) => turn.id === promptId,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
const [turn] = session.pendingTurns.splice(existingIndex, 1);
|
||||
session.pendingTurns.unshift({ ...turn, steer: true });
|
||||
}
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
return {
|
||||
sessionId,
|
||||
promptsInQueue: getPromptsInQueue(session),
|
||||
};
|
||||
}
|
||||
|
||||
const ACTION_HANDLERS: Record<
|
||||
string,
|
||||
(ctx: HostContext, req: ChatSessionCommandRequest) => Promise<unknown>
|
||||
> = {
|
||||
start: handleStart,
|
||||
send: handleSend,
|
||||
abort: handleAbort,
|
||||
reset: handleReset,
|
||||
pending_prompts: handlePendingPrompts,
|
||||
steer_prompt: handleSteerPrompt,
|
||||
};
|
||||
|
||||
export async function handleChatSessionCommand(
|
||||
ctx: HostContext,
|
||||
request: ChatSessionCommandRequest,
|
||||
): Promise<unknown> {
|
||||
const handler = ACTION_HANDLERS[request.action];
|
||||
if (!handler) throw new Error("unsupported action");
|
||||
return handler(ctx, request);
|
||||
}
|
||||
|
||||
@@ -28,16 +28,38 @@ export type ChatTurnResult = RpcChatTurnResult & {
|
||||
};
|
||||
|
||||
export type ChatSessionCommandRequest = {
|
||||
action: "start" | "send" | "abort" | "reset";
|
||||
action:
|
||||
| "start"
|
||||
| "send"
|
||||
| "abort"
|
||||
| "reset"
|
||||
| "pending_prompts"
|
||||
| "steer_prompt";
|
||||
sessionId?: string;
|
||||
prompt?: string;
|
||||
promptId?: string;
|
||||
config?: JsonRecord;
|
||||
attachments?: ChatTurnAttachments;
|
||||
};
|
||||
|
||||
export type QueuedChatTurn = {
|
||||
id: string;
|
||||
prompt: string;
|
||||
steer: boolean;
|
||||
config?: JsonRecord;
|
||||
attachments?: ChatTurnAttachments;
|
||||
};
|
||||
|
||||
export type PromptInQueue = {
|
||||
id: string;
|
||||
prompt: string;
|
||||
steer: boolean;
|
||||
};
|
||||
|
||||
export type LiveSession = {
|
||||
config: JsonRecord;
|
||||
messages: unknown[];
|
||||
pendingTurns: QueuedChatTurn[];
|
||||
busy: boolean;
|
||||
startedAt: number;
|
||||
endedAt?: number;
|
||||
|
||||
@@ -260,6 +260,7 @@ struct ChatSessionCommandResponse {
|
||||
session_id: Option<String>,
|
||||
result: Option<ChatTurnResult>,
|
||||
ok: Option<bool>,
|
||||
queued: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -349,6 +350,7 @@ impl ChatWsBridgeState {
|
||||
struct ChatRuntimeSession {
|
||||
config: StartSessionRequest,
|
||||
messages: Vec<Value>,
|
||||
pending_turns: Vec<ChatRunTurnRequest>,
|
||||
busy: bool,
|
||||
started_at: u64,
|
||||
ended_at: Option<u64>,
|
||||
@@ -1369,9 +1371,7 @@ fn extract_message_notice_meta(message: &Value) -> Option<HydratedChatMessageMet
|
||||
|
||||
Some(HydratedChatMessageMeta {
|
||||
tool_name: None,
|
||||
hook_event_name: message_kind
|
||||
.as_ref()
|
||||
.map(|_| "history_notice".to_string()),
|
||||
hook_event_name: message_kind.as_ref().map(|_| "history_notice".to_string()),
|
||||
message_kind,
|
||||
display_role,
|
||||
reason,
|
||||
@@ -1894,10 +1894,7 @@ fn resolve_desktop_backend_binary_path(context: &AppContext) -> Option<PathBuf>
|
||||
.map(|parent| parent.join("Resources").join("code-host"))
|
||||
}),
|
||||
];
|
||||
candidates
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|path| path.exists())
|
||||
candidates.into_iter().flatten().find(|path| path.exists())
|
||||
}
|
||||
|
||||
fn ensure_desktop_backend_started(
|
||||
@@ -3012,6 +3009,158 @@ fn run_chat_turn_via_rpc_runtime(
|
||||
.map_err(|e| format!("invalid chat runtime bridge result: {e}"))
|
||||
}
|
||||
|
||||
fn persist_chat_turn_result(
|
||||
state: &Arc<ChatSessionStore>,
|
||||
session_id: &str,
|
||||
config: &StartSessionRequest,
|
||||
result: &ChatTurnResult,
|
||||
) -> Result<(), String> {
|
||||
let mut sessions = state
|
||||
.sessions
|
||||
.lock()
|
||||
.map_err(|_| "failed to lock chat session store")?;
|
||||
if let Some(session) = sessions.get_mut(session_id) {
|
||||
let persisted_messages = persist_usage_in_messages(&result.messages, config, result);
|
||||
session.messages = persisted_messages.clone();
|
||||
session.status = normalize_chat_finish_status(result.finish_reason.as_deref());
|
||||
session.ended_at = Some(now_ms());
|
||||
if let Some(path) = shared_session_messages_write_path(session_id) {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
let body = serde_json::json!({
|
||||
"messages": persisted_messages,
|
||||
"ts": now_ms(),
|
||||
});
|
||||
if let Ok(encoded) = serde_json::to_vec(&body) {
|
||||
let _ = fs::write(path, encoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mark_chat_turn_failed(state: &Arc<ChatSessionStore>, session_id: &str) -> Result<(), String> {
|
||||
let mut sessions = state
|
||||
.sessions
|
||||
.lock()
|
||||
.map_err(|_| "failed to lock chat session store")?;
|
||||
if let Some(session) = sessions.get_mut(session_id) {
|
||||
session.status = "failed".to_string();
|
||||
session.ended_at = Some(now_ms());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dequeue_next_chat_turn(
|
||||
state: &Arc<ChatSessionStore>,
|
||||
session_id: &str,
|
||||
) -> Result<Option<(StartSessionRequest, ChatRunTurnRequest)>, String> {
|
||||
let mut sessions = state
|
||||
.sessions
|
||||
.lock()
|
||||
.map_err(|_| "failed to lock chat session store")?;
|
||||
let Some(session) = sessions.get_mut(session_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if session.pending_turns.is_empty() {
|
||||
session.busy = false;
|
||||
return Ok(None);
|
||||
}
|
||||
let mut next = session.pending_turns.remove(0);
|
||||
session.busy = true;
|
||||
session.status = "running".to_string();
|
||||
session.ended_at = None;
|
||||
if !next.prompt.trim().is_empty() {
|
||||
session.prompt = Some(next.prompt.clone());
|
||||
}
|
||||
next.config = session.config.clone();
|
||||
next.messages = session.messages.clone();
|
||||
Ok(Some((session.config.clone(), next)))
|
||||
}
|
||||
|
||||
fn queue_chat_turn(
|
||||
state: &Arc<ChatSessionStore>,
|
||||
session_id: &str,
|
||||
turn_request: ChatRunTurnRequest,
|
||||
) -> Result<(), String> {
|
||||
let mut sessions = state
|
||||
.sessions
|
||||
.lock()
|
||||
.map_err(|_| "failed to lock chat session store")?;
|
||||
let session = sessions
|
||||
.get_mut(session_id)
|
||||
.ok_or_else(|| "session not found. start a new session.".to_string())?;
|
||||
session.pending_turns.push(turn_request);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_next_queued_chat_turn(
|
||||
app: &AppHandle,
|
||||
state: &Arc<ChatSessionStore>,
|
||||
context: &AppContext,
|
||||
session_id: String,
|
||||
) {
|
||||
let next = match dequeue_next_chat_turn(state, &session_id) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
eprintln!("[chat-queue] failed to dequeue next turn for {session_id}: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some((config, turn_request)) = next else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app_for_turn = app.clone();
|
||||
let state_for_turn = state.clone();
|
||||
let context_for_turn = context.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let turn_result = tauri::async_runtime::spawn_blocking({
|
||||
let app_for_run = app_for_turn.clone();
|
||||
let state_for_run = state_for_turn.clone();
|
||||
let context_for_run = context_for_turn.clone();
|
||||
let session_id_for_run = session_id.clone();
|
||||
let request_for_run = turn_request.clone();
|
||||
move || {
|
||||
run_chat_turn_via_rpc_runtime(
|
||||
&app_for_run,
|
||||
&state_for_run,
|
||||
&context_for_run,
|
||||
&session_id_for_run,
|
||||
&request_for_run,
|
||||
)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("chat turn task failed: {e}"));
|
||||
|
||||
match turn_result {
|
||||
Ok(Ok(result)) => {
|
||||
if let Err(error) =
|
||||
persist_chat_turn_result(&state_for_turn, &session_id, &config, &result)
|
||||
{
|
||||
eprintln!(
|
||||
"[chat-queue] failed to persist turn result for {session_id}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Err(error) = mark_chat_turn_failed(&state_for_turn, &session_id) {
|
||||
eprintln!("[chat-queue] failed to mark turn failed for {session_id}: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spawn_next_queued_chat_turn(
|
||||
&app_for_turn,
|
||||
&state_for_turn,
|
||||
&context_for_turn,
|
||||
session_id,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn abort_chat_session_via_rpc_runtime(
|
||||
app: &AppHandle,
|
||||
state: &Arc<ChatSessionStore>,
|
||||
@@ -4116,6 +4265,7 @@ async fn handle_chat_session_command(
|
||||
ChatRuntimeSession {
|
||||
config,
|
||||
messages: Vec::new(),
|
||||
pending_turns: Vec::new(),
|
||||
busy: false,
|
||||
started_at: now_ms(),
|
||||
ended_at: None,
|
||||
@@ -4128,6 +4278,7 @@ async fn handle_chat_session_command(
|
||||
session_id: Some(session_id),
|
||||
result: None,
|
||||
ok: None,
|
||||
queued: None,
|
||||
})
|
||||
}
|
||||
"send" => {
|
||||
@@ -4171,6 +4322,7 @@ async fn handle_chat_session_command(
|
||||
prompt: derive_prompt_from_messages(&messages),
|
||||
title: read_session_metadata_title(&session_id),
|
||||
messages,
|
||||
pending_turns: Vec::new(),
|
||||
busy: false,
|
||||
started_at: now_ms(),
|
||||
ended_at: None,
|
||||
@@ -4192,7 +4344,20 @@ async fn handle_chat_session_command(
|
||||
session.config = next_config;
|
||||
}
|
||||
if session.busy {
|
||||
return Err("session is busy. wait for current response to finish.".to_string());
|
||||
let queued_request = ChatRunTurnRequest {
|
||||
config: session.config.clone(),
|
||||
messages: Vec::new(),
|
||||
prompt: prompt.clone(),
|
||||
attachments: attachments.clone(),
|
||||
};
|
||||
drop(sessions);
|
||||
queue_chat_turn(state, &session_id, queued_request)?;
|
||||
return Ok(ChatSessionCommandResponse {
|
||||
session_id: Some(session_id),
|
||||
result: None,
|
||||
ok: Some(true),
|
||||
queued: Some(true),
|
||||
});
|
||||
}
|
||||
session.busy = true;
|
||||
session.status = "running".to_string();
|
||||
@@ -4228,35 +4393,12 @@ async fn handle_chat_session_command(
|
||||
.await
|
||||
.map_err(|e| format!("chat turn task failed: {e}"));
|
||||
|
||||
let mut sessions = state
|
||||
.sessions
|
||||
.lock()
|
||||
.map_err(|_| "failed to lock chat session store")?;
|
||||
if let Some(session) = sessions.get_mut(&session_id) {
|
||||
session.busy = false;
|
||||
if let Ok(Ok(result)) = &turn_result {
|
||||
let persisted_messages =
|
||||
persist_usage_in_messages(&result.messages, &config, result);
|
||||
session.messages = persisted_messages.clone();
|
||||
session.status = normalize_chat_finish_status(result.finish_reason.as_deref());
|
||||
session.ended_at = Some(now_ms());
|
||||
if let Some(path) = shared_session_messages_write_path(&session_id) {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
let body = serde_json::json!({
|
||||
"messages": persisted_messages,
|
||||
"ts": now_ms(),
|
||||
});
|
||||
if let Ok(encoded) = serde_json::to_vec(&body) {
|
||||
let _ = fs::write(path, encoded);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
session.status = "failed".to_string();
|
||||
session.ended_at = Some(now_ms());
|
||||
}
|
||||
if let Ok(Ok(result)) = &turn_result {
|
||||
persist_chat_turn_result(state, &session_id, &config, result)?;
|
||||
} else {
|
||||
mark_chat_turn_failed(state, &session_id)?;
|
||||
}
|
||||
spawn_next_queued_chat_turn(app, state, context, session_id.clone());
|
||||
|
||||
let turn_result = turn_result?;
|
||||
let result = turn_result?;
|
||||
@@ -4264,6 +4406,7 @@ async fn handle_chat_session_command(
|
||||
session_id: Some(session_id),
|
||||
result: Some(result),
|
||||
ok: None,
|
||||
queued: Some(false),
|
||||
})
|
||||
}
|
||||
"abort" => Ok(ChatSessionCommandResponse {
|
||||
@@ -4276,6 +4419,7 @@ async fn handle_chat_session_command(
|
||||
.map_err(|_| "failed to lock chat session store")?;
|
||||
if let Some(session) = sessions.get_mut(&session_id) {
|
||||
session.busy = false;
|
||||
session.pending_turns.clear();
|
||||
session.status = "cancelled".to_string();
|
||||
session.ended_at = Some(now_ms());
|
||||
}
|
||||
@@ -4284,6 +4428,7 @@ async fn handle_chat_session_command(
|
||||
},
|
||||
result: None,
|
||||
ok: Some(true),
|
||||
queued: None,
|
||||
}),
|
||||
"reset" => {
|
||||
if let Some(session_id) = request.session_id.clone() {
|
||||
@@ -4304,6 +4449,7 @@ async fn handle_chat_session_command(
|
||||
session_id: request.session_id,
|
||||
result: None,
|
||||
ok: Some(true),
|
||||
queued: None,
|
||||
})
|
||||
}
|
||||
_ => Err("unsupported action".to_string()),
|
||||
|
||||
Reference in New Issue
Block a user