mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat: fork
This commit is contained in:
@@ -1042,6 +1042,50 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
const appended = logOf(sessionId).at(-1) as SessionEvent
|
||||
return ok(request, { title: normalized, seq: appended.seq })
|
||||
},
|
||||
fork: (request) => {
|
||||
const { sessionId, atSeq } = request.payload
|
||||
const source = summaryOf(sessionId)
|
||||
if (source === undefined) {
|
||||
return err(request, {
|
||||
code: 'session-not-found',
|
||||
message: `no session ${sessionId}`,
|
||||
details: { sessionId },
|
||||
})
|
||||
}
|
||||
const log = logs.get(sessionId) ?? []
|
||||
// Host-parallel boundary: first turn/end at or after atSeq, falling
|
||||
// back to the last completed turn; no completed turn = fork-unavailable.
|
||||
const boundary = (atSeq === undefined ? undefined : log.find(e => e.type === 'turn/end' && e.seq >= atSeq))
|
||||
?? log.findLast(e => e.type === 'turn/end')
|
||||
if (boundary === undefined) {
|
||||
return err(request, {
|
||||
code: 'fork-unavailable',
|
||||
message: `session ${sessionId} has no completed turn`,
|
||||
details: { sessionId },
|
||||
})
|
||||
}
|
||||
let cut = boundary.seq + 1
|
||||
while (cut < log.length && log[cut]?.type !== 'turn/start') cut++
|
||||
const child: SessionSummary = {
|
||||
sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false,
|
||||
parentSessionId: sessionId,
|
||||
...source.cwd === undefined ? {} : { cwd: source.cwd },
|
||||
}
|
||||
logs.set(child.sessionId, log.slice(0, cut))
|
||||
sessions.push(child)
|
||||
emitHost({
|
||||
type: 'host/session-added', sessionId: child.sessionId, blank: false,
|
||||
parentSessionId: sessionId,
|
||||
...source.cwd === undefined ? {} : { cwd: source.cwd },
|
||||
})
|
||||
const workspace = workspaces.find(w => w.sessionIds.includes(sessionId))
|
||||
if (workspace !== undefined) {
|
||||
workspace.sessionIds = [child.sessionId, ...workspace.sessionIds]
|
||||
workspace.updatedAt = new Date().toISOString()
|
||||
emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } })
|
||||
}
|
||||
return ok(request, { sessionId: child.sessionId })
|
||||
},
|
||||
history: async (request) => {
|
||||
const log = logs.get(request.payload.sessionId) ?? []
|
||||
// Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
|
||||
@@ -1591,6 +1635,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'session.models': return this.api.sessions.models(request)
|
||||
case 'session.selectModel': return this.api.sessions.selectModel(request)
|
||||
case 'session.rename': return this.api.sessions.rename(request)
|
||||
case 'session.fork': return this.api.sessions.fork(request)
|
||||
case 'session.prompt': return this.api.sessions.prompt(request)
|
||||
case 'session.updateQueue': return this.api.sessions.updateQueue(request)
|
||||
case 'session.cancel': return this.api.sessions.cancel(request)
|
||||
|
||||
@@ -46,6 +46,7 @@ export class FakeApiClient implements IApiClient {
|
||||
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
|
||||
() => Promise.resolve(ok({
|
||||
@@ -99,6 +100,7 @@ export class FakeApiClient implements IApiClient {
|
||||
selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
|
||||
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
||||
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
|
||||
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
|
||||
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
|
||||
@@ -29,6 +29,14 @@ export interface ISessions {
|
||||
open(id: SessionId): void
|
||||
/** Clear the current selection into the no-session view state. */
|
||||
clear(): void
|
||||
/**
|
||||
* Fork a session from a completed-turn prefix of the source; on resolution
|
||||
* the child is in the list store and `open()` can target it.
|
||||
* @param opts - source session id and the optional event seq anchoring the
|
||||
* cut (the boundary is the first turn/end at or after it).
|
||||
* @returns the child session id.
|
||||
*/
|
||||
fork(opts: { sessionId: SessionId; atSeq?: number }): Promise<SessionId>
|
||||
/**
|
||||
* Register a per-session standard-props provider (hooks become `use<Name>`
|
||||
* selector hooks on the render side; props spread verbatim).
|
||||
|
||||
@@ -289,6 +289,36 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Contract session.fork; on success merge the child into summaries
|
||||
* immediately (same synchronous-addressability guarantee as create). The
|
||||
* child carries the source's history, so it is never blank; lineage rides
|
||||
* parentSessionId so the list nests it under its source.
|
||||
* @param opts - source session and the optional seq anchoring the cut.
|
||||
* @returns the fork result (the child session id).
|
||||
*/
|
||||
async fork(
|
||||
opts: { sessionId: SessionId; atSeq?: number },
|
||||
): Promise<RpcResult<{ sessionId: SessionId }>> {
|
||||
try {
|
||||
const source = this.summaries.find(s => s.sessionId === opts.sessionId)
|
||||
const { result } = await this.api.sessions.fork({
|
||||
sessionId: opts.sessionId,
|
||||
...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq },
|
||||
})
|
||||
if (result.ok) {
|
||||
this.recordMutation({ kind: 'upsert', summary: {
|
||||
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: false,
|
||||
parentSessionId: opts.sessionId,
|
||||
...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
|
||||
} })
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
return transportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert-or-enrich a locally synthesized summary: a new id prepends; an
|
||||
* existing entry only gains fields it lacks (the session-added frame and the
|
||||
|
||||
@@ -81,6 +81,22 @@ export class SessionCreateError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Structured session-fork failure. */
|
||||
export class SessionForkError extends Error {
|
||||
override readonly name = 'SessionForkError'
|
||||
|
||||
/**
|
||||
* @param rpcError - Host business or folded transport error.
|
||||
* @param sourceSessionId - the session the fork was cut from.
|
||||
*/
|
||||
constructor(
|
||||
readonly rpcError: RpcError,
|
||||
readonly sourceSessionId: SessionId,
|
||||
) {
|
||||
super(`session fork failed: ${rpcError.code}: ${rpcError.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
|
||||
export interface SessionBinding {
|
||||
readonly sessionId: SessionId
|
||||
@@ -317,6 +333,22 @@ export class SessionsService implements ISessions {
|
||||
return result.value.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork a session from a completed-turn prefix of the source (same
|
||||
* synchronous-addressability guarantee as {@link SessionsService.create}:
|
||||
* on resolution the child is in the list store and open() can target it).
|
||||
* @param opts - source session id and the optional event seq anchoring the
|
||||
* cut (the boundary is the first turn/end at or after it).
|
||||
* @returns the child session id.
|
||||
* @throws {SessionForkError} with the source id.
|
||||
*/
|
||||
async fork(opts: { sessionId: SessionId; atSeq?: number }): Promise<SessionId> {
|
||||
const result = await this.manager.fork(opts)
|
||||
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
|
||||
this.projectList()
|
||||
return result.value.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an Agent-scoped context view (use-and-discard).
|
||||
* @param id - session id (the agent identity — 1:1 same axis).
|
||||
|
||||
@@ -64,6 +64,7 @@ export class FakeApiClient implements IApiClient {
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
@@ -118,6 +119,7 @@ export class FakeApiClient implements IApiClient {
|
||||
selectModel: (payload: { provider: string; model: string }) =>
|
||||
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
||||
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
|
||||
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
|
||||
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
|
||||
@@ -169,7 +169,7 @@ export class TestSessions implements ISessions {
|
||||
private readonly channel: SessionProvideChannel
|
||||
|
||||
/** Calls observed on the service-level face (open/clear), newest last. */
|
||||
readonly calls: { method: 'open' | 'clear'; args: unknown[] }[] = []
|
||||
readonly calls: { method: 'open' | 'clear' | 'fork'; args: unknown[] }[] = []
|
||||
|
||||
/**
|
||||
* @param stabilize - the owning runtime's act wrapper.
|
||||
@@ -392,6 +392,17 @@ export class TestSessions implements ISessions {
|
||||
this.list.update((draft) => { draft.current = undefined })
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorded fork stub: no child materializes (benches asserting the full
|
||||
* fork flow drive the production service; this face only proves the call).
|
||||
* @param opts - source session id and optional cut anchor.
|
||||
* @returns the source id (no child record is created).
|
||||
*/
|
||||
fork(opts: { sessionId: SessionId; atSeq?: number }): Promise<SessionId> {
|
||||
this.calls.push({ method: 'fork', args: [opts] })
|
||||
return Promise.resolve(opts.sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The session face of a fixture (typed view for assertions; fixture
|
||||
* behavior methods are grafted onto it).
|
||||
|
||||
@@ -262,6 +262,13 @@ export function apply(ctx: Context): void {
|
||||
})
|
||||
},
|
||||
loadOlder: () => { void scoped.loadOlder() },
|
||||
forkAt: (seq) => {
|
||||
sessions.fork({ sessionId, atSeq: seq })
|
||||
.then((childId) => { sessions.open(childId) })
|
||||
.catch(() => {
|
||||
// Fork failure keeps the source view untouched (composer-stop posture).
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
}, ChatView)
|
||||
|
||||
@@ -230,7 +230,7 @@ function StreamingTail({ useSession, onGrow }: {
|
||||
* The chat view slot entry: pure component over the composed props (tool rows
|
||||
* render through the declared keyed hole's renderSlot share).
|
||||
*/
|
||||
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) {
|
||||
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, forkAt }: ChatViewSlotProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
// Workspace root off the session list row: path summaries display relative to it.
|
||||
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
||||
@@ -385,7 +385,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
}
|
||||
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
|
||||
if (node.kind === 'tool-result') return null
|
||||
return <MessageItem key={item.key} node={node} />
|
||||
return <MessageItem key={item.key} node={node} onFork={forkAt} />
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Shared IconActions chrome for user and assistant messages: copy / branch
|
||||
// live (branch still a stub), date-aware clock, optional edit stub.
|
||||
// Shared IconActions chrome for user and assistant messages: copy live,
|
||||
// branch wired through onBranch (stub without it), date-aware clock,
|
||||
// optional edit stub.
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
@@ -18,17 +19,19 @@ export interface MessageIconActionsProps {
|
||||
clock: 'start' | 'end'
|
||||
/** When true, append the stub edit control (user bubble). */
|
||||
edit?: boolean | undefined
|
||||
/** Fork the session at this message; absent leaves the branch control a stub. */
|
||||
onBranch?: (() => void) | undefined
|
||||
/** Parent layout class composed onto the actions row. */
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy / branch (/ clock) IconActions row shared by user and assistant chrome.
|
||||
* @param props - Copy text, event time, clock side, optional edit, className.
|
||||
* @param props - Copy text, event time, clock side, optional edit, branch callback, className.
|
||||
* @returns The actions row element.
|
||||
*/
|
||||
export function MessageIconActions({
|
||||
text, time, clock, edit, className,
|
||||
text, time, clock, edit, onBranch, className,
|
||||
}: MessageIconActionsProps) {
|
||||
const day = useCalendarDay()
|
||||
const onCopy = useCallback(() => {
|
||||
@@ -48,7 +51,7 @@ export function MessageIconActions({
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="在新对话中分支" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="在新对话中分支">
|
||||
<button type="button" className={css.action} aria-label="在新对话中分支" onClick={onBranch}>
|
||||
<IconBranchOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -16,6 +16,8 @@ import css from './MessageItem.module.css'
|
||||
|
||||
export interface MessageItemProps {
|
||||
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode
|
||||
/** Fork the session through the turn containing this message (user-bubble branch action). */
|
||||
onFork?: (seq: number) => void
|
||||
}
|
||||
|
||||
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
|
||||
@@ -61,7 +63,7 @@ function projectUserText(text: string): ReactNode {
|
||||
return <>{parts}</>
|
||||
}
|
||||
|
||||
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
|
||||
export const MessageItem = memo(function MessageItem({ node, onFork }: MessageItemProps) {
|
||||
switch (node.kind) {
|
||||
case 'user': {
|
||||
const { text, rest } = contentText(node.content)
|
||||
@@ -76,6 +78,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
|
||||
time={node.time}
|
||||
clock="start"
|
||||
edit
|
||||
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
|
||||
className={css.actions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -419,6 +419,8 @@ export interface ChatViewInjected {
|
||||
*/
|
||||
openFile: (path: string) => void
|
||||
loadOlder: () => void
|
||||
/** Fork the session through the turn containing the message at `seq`, then open the child. */
|
||||
forkAt: (seq: number) => void
|
||||
}
|
||||
|
||||
/** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */
|
||||
|
||||
@@ -94,6 +94,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
const openDetails = vi.fn<(t: SelectionTarget) => void>()
|
||||
const openFile = vi.fn<(path: string) => void>()
|
||||
const loadOlder = vi.fn()
|
||||
const forkAt = vi.fn()
|
||||
// Selection rides the REAL chat store (same construction path as
|
||||
// production; the view reads it through the PropsStore useStore share).
|
||||
// renderSlot stub renders the render-site fallback (an empty keyed ledger:
|
||||
@@ -120,9 +121,10 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
openDetails,
|
||||
openFile,
|
||||
loadOlder,
|
||||
forkAt,
|
||||
}
|
||||
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, setSelection }
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, forkAt, setSelection }
|
||||
}
|
||||
|
||||
describe('chat-flow derivation', () => {
|
||||
|
||||
@@ -83,7 +83,7 @@ interface DragState {
|
||||
|
||||
type SessionTreeProps = Pick<
|
||||
WorkspaceBrowserProps,
|
||||
'useSessions' | 'startSession' | 'open' | 'insertSessionBefore'
|
||||
'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore'
|
||||
> & {
|
||||
workspaces: readonly WorkspaceView[]
|
||||
/** Live search filter owned by the browser root (the query outlives the tree). */
|
||||
@@ -98,7 +98,7 @@ type SessionTreeProps = Pick<
|
||||
|
||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||
function SessionTree({
|
||||
useSessions, startSession, open, workspaces, query,
|
||||
useSessions, startSession, open, forkSession, workspaces, query,
|
||||
onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore,
|
||||
}: SessionTreeProps) {
|
||||
const list = useSessions(s => s)
|
||||
@@ -115,6 +115,24 @@ function SessionTree({
|
||||
if (current === undefined || currentGroup === undefined) return
|
||||
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
||||
}, [current, currentGroup])
|
||||
// The selected session must be visible: unfold its ancestor chain (fork
|
||||
// lands the child under a possibly folded parent row).
|
||||
const currentAncestors = useMemo(() => {
|
||||
const chain: string[] = []
|
||||
let cursor = current === undefined ? undefined : list.byId[current]?.parentId
|
||||
while (cursor !== undefined && !chain.includes(cursor)) {
|
||||
chain.push(cursor)
|
||||
cursor = list.byId[cursor]?.parentId
|
||||
}
|
||||
return chain
|
||||
}, [current, list])
|
||||
useEffect(() => {
|
||||
if (currentAncestors.length === 0) return
|
||||
setExpandedSessions((l) => {
|
||||
const missing = currentAncestors.filter(id => !l.includes(id))
|
||||
return missing.length === 0 ? l : [...l, ...missing]
|
||||
})
|
||||
}, [currentAncestors])
|
||||
const groups = useMemo(
|
||||
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
|
||||
[list, workspaces, expandedProjects, expandedSessions, query],
|
||||
@@ -195,6 +213,7 @@ function SessionTree({
|
||||
now={now}
|
||||
onOpen={open}
|
||||
onRename={onSessionRename}
|
||||
onFork={forkSession}
|
||||
onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
|
||||
drag={dragProps}
|
||||
/>
|
||||
@@ -209,7 +228,7 @@ function SessionTree({
|
||||
}
|
||||
|
||||
/** The flat "In one list" body: every session a top-level row, newest-first. */
|
||||
function FlatList({ useSessions, open, onSessionRename, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'onSessionRename' | 'query'>) {
|
||||
function FlatList({ useSessions, open, forkSession, onSessionRename, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'query'>) {
|
||||
const list = useSessions(s => s)
|
||||
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
|
||||
const now = Date.now()
|
||||
@@ -228,6 +247,7 @@ function FlatList({ useSessions, open, onSessionRename, query }: Pick<SessionTre
|
||||
now={now}
|
||||
onOpen={open}
|
||||
onRename={onSessionRename}
|
||||
onFork={forkSession}
|
||||
/* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
|
||||
onToggle={() => {}}
|
||||
flat
|
||||
@@ -254,6 +274,7 @@ export function WorkspaceBrowser({
|
||||
startSession,
|
||||
open,
|
||||
renameSession,
|
||||
forkSession,
|
||||
renameWorkspace,
|
||||
deleteWorkspace,
|
||||
insertSessionBefore,
|
||||
@@ -460,13 +481,14 @@ export function WorkspaceBrowser({
|
||||
|
||||
{/* Always-mounted seat keeps the region's flex slot while the list
|
||||
itself is wide-only. */}
|
||||
<div className={css.listArea}>
|
||||
{wide && (groupBy === 'flat'
|
||||
? <FlatList useSessions={useSessions} open={open} onSessionRename={onSessionRename} query={query} />
|
||||
<div className={css.listArea}>
|
||||
{wide && (groupBy === 'flat'
|
||||
? <FlatList useSessions={useSessions} open={open} forkSession={forkSession} onSessionRename={onSessionRename} query={query} />
|
||||
: (
|
||||
<SessionTree
|
||||
useSessions={useSessions}
|
||||
onSessionRename={onSessionRename}
|
||||
forkSession={forkSession}
|
||||
workspaces={workspaces}
|
||||
startSession={startSession}
|
||||
open={open}
|
||||
|
||||
@@ -95,6 +95,8 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
|
||||
open: (sessionId: SessionId) => void
|
||||
/** Rename a Session (explicit user title; resolves on host acceptance). */
|
||||
renameSession: (sessionId: SessionId, title: string) => Promise<void>
|
||||
/** Fork a Session at its last completed turn and open the child. */
|
||||
forkSession: (sessionId: SessionId) => void
|
||||
/** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
|
||||
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
|
||||
/** Delete only a Host Workspace registration; directory and Session logs remain. */
|
||||
|
||||
@@ -59,6 +59,13 @@ export function apply(ctx: ClientContext): void {
|
||||
const result = await session.rename(title)
|
||||
if (!result.ok) throw new Error(result.error.message)
|
||||
},
|
||||
forkSession: (sessionId) => {
|
||||
ctx.sessions.fork({ sessionId })
|
||||
.then((childId) => { ctx.sessions.open(childId) })
|
||||
.catch(() => {
|
||||
// Fork failure keeps the list untouched (composer-stop posture).
|
||||
})
|
||||
},
|
||||
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
|
||||
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
|
||||
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
|
||||
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
|
||||
* time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
|
||||
* except workspace Rename/Delete and session Rename; the session and workspace
|
||||
* hover cards are suppressed while a menu is open.
|
||||
* except workspace Rename/Delete and session Rename/Fork; the session and
|
||||
* workspace hover cards are suppressed while a menu is open.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -184,7 +184,7 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
|
||||
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, onToggle, drag, flat = false }: {
|
||||
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, onFork, onToggle, drag, flat = false }: {
|
||||
node: SessionNode
|
||||
depth: number
|
||||
currentId: string | undefined
|
||||
@@ -192,6 +192,8 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
|
||||
onOpen: (id: SessionNode['id']) => void
|
||||
/** Open the browser-owned session rename dialog (row menu action). */
|
||||
onRename: (id: SessionNode['id'], currentTitle: string) => void
|
||||
/** Fork a session at its last completed turn (row menu action). */
|
||||
onFork: (id: SessionNode['id']) => void
|
||||
onToggle: (id: SessionNode['id']) => void
|
||||
/** Present only on draggable rows (workspace-group roots outside search). */
|
||||
drag?: RowDragProps | undefined
|
||||
@@ -259,9 +261,10 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
|
||||
open={menuOpen}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
items={SESSION_MENU_ITEMS}
|
||||
onSelect={(id) => {
|
||||
setMenuOpen(false)
|
||||
if (id === 'rename') onRename(node.id, row.title) // fork/delete stay visual-only.
|
||||
onSelect={(id) => {
|
||||
setMenuOpen(false)
|
||||
if (id === 'rename') onRename(node.id, row.title)
|
||||
if (id === 'fork') onFork(node.id) // delete stays visual-only.
|
||||
}}
|
||||
portal
|
||||
closeOnPointerLeave
|
||||
@@ -295,6 +298,7 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
|
||||
now={now}
|
||||
onOpen={onOpen}
|
||||
onRename={onRename}
|
||||
onFork={onFork}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -67,9 +67,9 @@ describe('workspace browser rows', () => {
|
||||
}
|
||||
const onOpen = vi.fn()
|
||||
const onToggle = vi.fn()
|
||||
const view = render(
|
||||
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onToggle={onToggle} />,
|
||||
const view = render(
|
||||
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={onToggle} />,
|
||||
)
|
||||
|
||||
const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
|
||||
@@ -88,9 +88,9 @@ describe('workspace browser rows', () => {
|
||||
|
||||
view.rerender(
|
||||
<SessionNodeItem
|
||||
node={{ ...parent, children: [], expanded: false, running: false }}
|
||||
depth={1} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onToggle={onToggle}
|
||||
node={{ ...parent, children: [], expanded: false, running: false }}
|
||||
depth={1} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={onToggle}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
|
||||
@@ -162,9 +162,9 @@ describe('workspace browser rows', () => {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'One', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={onRename} onToggle={vi.fn()} />)
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={onRename} onFork={vi.fn()} onToggle={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/)
|
||||
@@ -189,9 +189,9 @@ describe('workspace browser rows', () => {
|
||||
const node: SessionNode = {
|
||||
id: sid('p'), title: 'Parent', children: [], hasChildren: true,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} flat />)
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} flat />)
|
||||
expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull()
|
||||
})
|
||||
|
||||
@@ -201,9 +201,9 @@ describe('workspace browser rows', () => {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
|
||||
expanded: false, running: true, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} />)
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} />)
|
||||
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
@@ -228,9 +228,9 @@ describe('workspace browser rows', () => {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} />)
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} />)
|
||||
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('Idle')).toBeTruthy()
|
||||
@@ -246,9 +246,9 @@ describe('workspace browser rows', () => {
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
const inactive = dragProps()
|
||||
const { rerender } = render(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
|
||||
const { rerender } = render(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
|
||||
)
|
||||
const row = screen.getByRole('treeitem')
|
||||
stubRect(row)
|
||||
@@ -264,9 +264,9 @@ describe('workspace browser rows', () => {
|
||||
expect(inactive.end).toHaveBeenCalledOnce()
|
||||
|
||||
const active = dragProps({ active: true, marker: 'before' })
|
||||
rerender(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} drag={active} />,
|
||||
rerender(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} drag={active} />,
|
||||
)
|
||||
stubRect(screen.getByRole('treeitem'))
|
||||
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
|
||||
@@ -278,9 +278,9 @@ describe('workspace browser rows', () => {
|
||||
expect(active.drop).toHaveBeenCalledWith('after')
|
||||
|
||||
const after = dragProps({ active: true, marker: 'after' })
|
||||
rerender(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} drag={after} />,
|
||||
rerender(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} drag={after} />,
|
||||
)
|
||||
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
|
||||
})
|
||||
|
||||
@@ -56,6 +56,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
startSession: vi.fn(),
|
||||
open: vi.fn(),
|
||||
renameSession: vi.fn(async () => {}),
|
||||
forkSession: vi.fn(),
|
||||
renameWorkspace: vi.fn(async () => {}),
|
||||
deleteWorkspace: vi.fn(async () => {}),
|
||||
insertSessionBefore: vi.fn(async () => {}),
|
||||
|
||||
@@ -1148,6 +1148,66 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
},
|
||||
|
||||
async fork(request) {
|
||||
const { sessionId, atSeq } = request.payload
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
const source = found.agent.session
|
||||
const events = source.events
|
||||
// Boundary: the first turn/end at or after atSeq (fork includes that
|
||||
// whole turn); an overshooting atSeq or an omitted one falls back to
|
||||
// the last completed turn.
|
||||
const boundary = (atSeq === undefined ? undefined : events.find(e => e.type === 'turn/end' && e.seq >= atSeq))
|
||||
?? events.findLast(e => e.type === 'turn/end')
|
||||
if (boundary === undefined) {
|
||||
return err(request, {
|
||||
code: 'fork-unavailable',
|
||||
message: `session "${sessionId}" has no completed turn to fork from`,
|
||||
details: { sessionId },
|
||||
})
|
||||
}
|
||||
// Extend the cut through trailing out-of-band appends (session/title,
|
||||
// injections) up to the next turn/start: they are standalone events, so
|
||||
// the seed stays balanced, and the child inherits a title generated
|
||||
// right after the boundary turn.
|
||||
let cut = boundary.seq + 1
|
||||
while (cut < events.length && events[cut]?.type !== 'turn/start') cut++
|
||||
const childId = `session-${randomUUID()}` as SessionId
|
||||
try {
|
||||
await ctx.agents.create({
|
||||
sessionId: childId,
|
||||
seed: events.slice(0, cut),
|
||||
meta: {
|
||||
...source.header.cwd === undefined ? {} : { cwd: source.header.cwd },
|
||||
parentSession: source.id,
|
||||
seedLength: cut,
|
||||
},
|
||||
agentOptions,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: `failed to fork session "${sessionId}": ${String(error)}`,
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
// Keep the child in the source's Workspace so the list nests it under
|
||||
// its parent; the child is already published if the attach fails.
|
||||
const workspace = ctx.workspace.list().find(w => w.sessionIds.includes(source.id))
|
||||
if (workspace !== undefined) {
|
||||
try {
|
||||
await workspace.attachSession(childId)
|
||||
} catch (error: unknown) {
|
||||
return err(request, {
|
||||
code: 'workspace-attach-failed',
|
||||
message: `session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`,
|
||||
details: { sessionId: childId, workspaceId: workspace.id },
|
||||
})
|
||||
}
|
||||
}
|
||||
return ok(request, { sessionId: childId })
|
||||
},
|
||||
|
||||
async prompt(request) {
|
||||
const { sessionId, mode, content } = request.payload
|
||||
const found = await agentFor(sessionId)
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface RpcMethodMap {
|
||||
'session.models': SessionsApi['models']
|
||||
'session.selectModel': SessionsApi['selectModel']
|
||||
'session.rename': SessionsApi['rename']
|
||||
'session.fork': SessionsApi['fork']
|
||||
'session.prompt': SessionsApi['prompt']
|
||||
'session.updateQueue': SessionsApi['updateQueue']
|
||||
'session.cancel': SessionsApi['cancel']
|
||||
|
||||
@@ -51,6 +51,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
|
||||
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
|
||||
z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
|
||||
]) as unknown as z.ZodType<RpcError>
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface RpcErrorDetailsMap {
|
||||
/** A leading-/ prompt named no registered command; the message names the token. */
|
||||
'unknown-command': {}
|
||||
'title-invalid': { sessionId: SessionId }
|
||||
'fork-unavailable': { sessionId: SessionId }
|
||||
'internal': {}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,17 @@ export const sessionRenameValueSchema = z.object({
|
||||
seq: z.number().int().nonnegative(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.rename'>>>
|
||||
|
||||
/** session.fork request payload (atSeq anchors the completed-turn cut). */
|
||||
export const sessionForkRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
atSeq: z.number().int().nonnegative().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.fork'>>>
|
||||
|
||||
/** session.fork response value (the child session id). */
|
||||
export const sessionForkValueSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.fork'>>>
|
||||
|
||||
/** session.history request payload (beforeSeq/maxMessages page backwards from the window tail). */
|
||||
export const sessionHistoryRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
|
||||
@@ -238,6 +238,20 @@ export interface SessionsApi {
|
||||
* one — carried for future rendering; the state change is the feedback). A usage/state error is an
|
||||
* RPC error with code command-error; an unrecognized name is an RPC error with code unknown-command.
|
||||
*/
|
||||
/**
|
||||
* Forks a new session from a completed-turn prefix of the source. `atSeq`
|
||||
* anchors the cut: the boundary is the first `turn/end` at or after it
|
||||
* (a message's fork button passes the message seq, so the fork includes
|
||||
* that whole turn); a boundary past the log end, or an omitted `atSeq`,
|
||||
* falls back to the source's last completed turn. A source with no
|
||||
* completed turn fails with `fork-unavailable`. The child inherits the
|
||||
* source cwd (and its workspace attachment) and records
|
||||
* `parentSessionId` lineage; the seed prefix carries the source title.
|
||||
*/
|
||||
fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>):
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
|
||||
/** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */
|
||||
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
|
||||
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import {
|
||||
sessionCancelValueSchema,
|
||||
sessionCreateValueSchema,
|
||||
sessionForkValueSchema,
|
||||
sessionHistoryValueSchema,
|
||||
sessionListValueSchema,
|
||||
sessionModelsValueSchema,
|
||||
@@ -69,6 +70,7 @@ export interface IApiClient {
|
||||
models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
|
||||
selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
|
||||
rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.rename'>>>
|
||||
fork(payload: RequestPayload<'session.fork'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.fork'>>>
|
||||
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
|
||||
updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.updateQueue'>>>
|
||||
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
|
||||
@@ -121,6 +123,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'session.models': sessionModelsValueSchema,
|
||||
'session.selectModel': sessionSelectModelValueSchema,
|
||||
'session.rename': sessionRenameValueSchema,
|
||||
'session.fork': sessionForkValueSchema,
|
||||
'session.prompt': sessionPromptValueSchema,
|
||||
'session.updateQueue': sessionUpdateQueueValueSchema,
|
||||
'session.cancel': sessionCancelValueSchema,
|
||||
@@ -334,6 +337,7 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
models: (payload, signal) => this.callUnary('session.models', payload, signal),
|
||||
selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal),
|
||||
rename: (payload, signal) => this.callUnary('session.rename', payload, signal),
|
||||
fork: (payload, signal) => this.callUnary('session.fork', payload, signal),
|
||||
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
|
||||
updateQueue: (payload, signal) => this.callUnary('session.updateQueue', payload, signal),
|
||||
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
|
||||
|
||||
@@ -17,6 +17,7 @@ import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts'
|
||||
import {
|
||||
sessionCancelRequestSchema,
|
||||
sessionCreateRequestSchema,
|
||||
sessionForkRequestSchema,
|
||||
sessionHistoryRequestSchema,
|
||||
sessionListRequestSchema,
|
||||
sessionModelsRequestSchema,
|
||||
@@ -71,6 +72,7 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) },
|
||||
'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) },
|
||||
'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) },
|
||||
'session.fork': { schema: sessionForkRequestSchema, invoke: (api, r) => api.sessions.fork(r) },
|
||||
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
|
||||
'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) },
|
||||
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
|
||||
|
||||
@@ -47,6 +47,7 @@ function scriptedApi(overrides: {
|
||||
selected: { provider: r.payload.provider, model: r.payload.model },
|
||||
}),
|
||||
rename: r => ok(r, { title: 'renamed', seq: 0 }),
|
||||
fork: r => ok(r, { sessionId: sid('s-fork') }),
|
||||
prompt: r => ok(r, { accepted: true as const }),
|
||||
updateQueue: r => ok(r, { accepted: true as const }),
|
||||
cancel: r => ok(r, { accepted: true as const }),
|
||||
|
||||
@@ -70,6 +70,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
async rename(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { title: request.payload.title, seq: 0 } } }
|
||||
},
|
||||
async fork(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-fork' as never } } }
|
||||
},
|
||||
async prompt(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user