refactor(tui): replace v2 sync with data context (#31826)
This commit is contained in:
@@ -34,7 +34,7 @@ import { useEvent } from "./context/event"
|
|||||||
import { SDKProvider, useSDK } from "./context/sdk"
|
import { SDKProvider, useSDK } from "./context/sdk"
|
||||||
import { StartupLoading } from "./component/startup-loading"
|
import { StartupLoading } from "./component/startup-loading"
|
||||||
import { SyncProvider, useSync } from "./context/sync"
|
import { SyncProvider, useSync } from "./context/sync"
|
||||||
import { SyncProviderV2 } from "./context/sync-v2"
|
import { DataProvider } from "./context/data"
|
||||||
import { LocalProvider, useLocal } from "./context/local"
|
import { LocalProvider, useLocal } from "./context/local"
|
||||||
import { DialogModel } from "./component/dialog-model"
|
import { DialogModel } from "./component/dialog-model"
|
||||||
import { useConnected } from "./component/use-connected"
|
import { useConnected } from "./component/use-connected"
|
||||||
@@ -294,7 +294,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
>
|
>
|
||||||
<ProjectProvider>
|
<ProjectProvider>
|
||||||
<SyncProvider>
|
<SyncProvider>
|
||||||
<SyncProviderV2>
|
<DataProvider>
|
||||||
<ThemeProvider mode={mode}>
|
<ThemeProvider mode={mode}>
|
||||||
<LocalProvider>
|
<LocalProvider>
|
||||||
<PromptStashProvider>
|
<PromptStashProvider>
|
||||||
@@ -315,7 +315,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
</PromptStashProvider>
|
</PromptStashProvider>
|
||||||
</LocalProvider>
|
</LocalProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</SyncProviderV2>
|
</DataProvider>
|
||||||
</SyncProvider>
|
</SyncProvider>
|
||||||
</ProjectProvider>
|
</ProjectProvider>
|
||||||
</SDKProvider>
|
</SDKProvider>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { createMemo, createSignal } from "solid-js"
|
import { createMemo, createSignal } from "solid-js"
|
||||||
import { useLocal } from "../context/local"
|
import { useLocal } from "../context/local"
|
||||||
import { useSync } from "../context/sync"
|
import { map, pipe, filter, sortBy, take } from "remeda"
|
||||||
import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda"
|
|
||||||
import { DialogSelect } from "../ui/dialog-select"
|
import { DialogSelect } from "../ui/dialog-select"
|
||||||
import { useDialog } from "../ui/dialog"
|
import { useDialog } from "../ui/dialog"
|
||||||
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
|
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
|
||||||
import { DialogVariant } from "./dialog-variant"
|
import { DialogVariant } from "./dialog-variant"
|
||||||
import * as fuzzysort from "fuzzysort"
|
import * as fuzzysort from "fuzzysort"
|
||||||
import { useConnected } from "./use-connected"
|
import { useConnected } from "./use-connected"
|
||||||
|
import { useData } from "../context/data"
|
||||||
|
|
||||||
export function DialogModel(props: { providerID?: string }) {
|
export function DialogModel(props: { providerID?: string }) {
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const sync = useSync()
|
const data = useData()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const [query, setQuery] = createSignal("")
|
const [query, setQuery] = createSignal("")
|
||||||
|
|
||||||
@@ -29,19 +29,21 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
function toOptions(items: typeof favorites, category: string) {
|
function toOptions(items: typeof favorites, category: string) {
|
||||||
if (!showSections) return []
|
if (!showSections) return []
|
||||||
return items.flatMap((item) => {
|
return items.flatMap((item) => {
|
||||||
const provider = sync.data.provider.find((x) => x.id === item.providerID)
|
const provider = data.location.provider.list()?.find((provider) => provider.id === item.providerID)
|
||||||
if (!provider) return []
|
if (!provider) return []
|
||||||
const model = provider.models[item.modelID]
|
const model = data.location.model
|
||||||
|
.list()
|
||||||
|
?.find((model) => model.providerID === item.providerID && model.id === item.modelID)
|
||||||
if (!model) return []
|
if (!model) return []
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: item,
|
key: item,
|
||||||
value: { providerID: provider.id, modelID: model.id },
|
value: { providerID: provider.id, modelID: model.id },
|
||||||
title: model.name ?? item.modelID,
|
title: model.name,
|
||||||
description: provider.name,
|
description: provider.name,
|
||||||
category,
|
category,
|
||||||
disabled: provider.id === "opencode" && model.id.includes("-nano"),
|
disabled: provider.id === "opencode" && model.id.includes("-nano"),
|
||||||
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
footer: model.cost[0]?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
onSelect(provider.id, model.id)
|
onSelect(provider.id, model.id)
|
||||||
},
|
},
|
||||||
@@ -59,42 +61,45 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const providerOptions = pipe(
|
const providerOptions = pipe(
|
||||||
sync.data.provider,
|
data.location.model.list() ?? [],
|
||||||
|
filter((model) => model.status !== "deprecated"),
|
||||||
|
filter((model) => (props.providerID ? model.providerID === props.providerID : true)),
|
||||||
sortBy(
|
sortBy(
|
||||||
(provider) => provider.id !== "opencode",
|
(model) => model.providerID !== "opencode",
|
||||||
(provider) => provider.name,
|
(model) => data.location.provider.list()?.find((provider) => provider.id === model.providerID)?.name ?? "",
|
||||||
|
[(model) => model.time.released, "desc"],
|
||||||
),
|
),
|
||||||
flatMap((provider) =>
|
map((model) => ({
|
||||||
pipe(
|
value: { providerID: model.providerID, modelID: model.id },
|
||||||
provider.models,
|
title: model.name,
|
||||||
entries(),
|
releaseDate: model.time.released,
|
||||||
filter(([_, info]) => info.status !== "deprecated"),
|
description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
|
||||||
filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)),
|
|
||||||
map(([model, info]) => ({
|
|
||||||
value: { providerID: provider.id, modelID: model },
|
|
||||||
title: info.name ?? model,
|
|
||||||
releaseDate: info.release_date,
|
|
||||||
description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
|
|
||||||
? "(Favorite)"
|
? "(Favorite)"
|
||||||
: undefined,
|
: undefined,
|
||||||
category: connected() ? provider.name : undefined,
|
category: connected()
|
||||||
disabled: provider.id === "opencode" && model.includes("-nano"),
|
? data.location.provider.list()?.find((provider) => provider.id === model.providerID)?.name
|
||||||
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
: undefined,
|
||||||
|
disabled: !model.enabled || (model.providerID === "opencode" && model.id.includes("-nano")),
|
||||||
|
footer: model.cost[0]?.input === 0 && model.providerID === "opencode" ? "Free" : undefined,
|
||||||
onSelect() {
|
onSelect() {
|
||||||
onSelect(provider.id, model)
|
onSelect(model.providerID, model.id)
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
filter((x) => {
|
filter((option) => {
|
||||||
if (!showSections) return true
|
if (!showSections) return true
|
||||||
if (favorites.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID))
|
if (
|
||||||
|
favorites.some(
|
||||||
|
(item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
|
||||||
|
)
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
if (recents.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID))
|
if (
|
||||||
|
recents.some((item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID)
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
return true
|
return true
|
||||||
}),
|
}),
|
||||||
(options) => sortModelOptions(options, props.providerID !== undefined),
|
(options) => sortModelOptions(options, props.providerID !== undefined),
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const popularProviders = !connected()
|
const popularProviders = !connected()
|
||||||
@@ -119,7 +124,7 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const provider = createMemo(() =>
|
const provider = createMemo(() =>
|
||||||
props.providerID ? sync.data.provider.find((x) => x.id === props.providerID) : null,
|
props.providerID ? data.location.provider.list()?.find((item) => item.id === props.providerID) : null,
|
||||||
)
|
)
|
||||||
|
|
||||||
const title = createMemo(() => {
|
const title = createMemo(() => {
|
||||||
@@ -172,7 +177,7 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortModelOptions<T extends { footer?: string; releaseDate: string; title: string }>(
|
export function sortModelOptions<T extends { footer?: string; releaseDate: string | number; title: string }>(
|
||||||
options: T[],
|
options: T[],
|
||||||
newestFirst: boolean,
|
newestFirst: boolean,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export async function warpWorkspaceSession(input: {
|
|||||||
|
|
||||||
input.project.workspace.set(input.workspaceID)
|
input.project.workspace.set(input.workspaceID)
|
||||||
|
|
||||||
await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
|
await input.sync.bootstrap()
|
||||||
|
|
||||||
const dir = input.project.instance.directory() || input.sync.path.directory
|
const dir = input.project.instance.directory() || input.sync.path.directory
|
||||||
if (dir) {
|
if (dir) {
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function DialogWorkspaceList() {
|
|||||||
route.navigate({ type: "home" })
|
route.navigate({ type: "home" })
|
||||||
}
|
}
|
||||||
await project.workspace.sync()
|
await project.workspace.sync()
|
||||||
await sync.bootstrap({ fatal: false }).catch(() => undefined)
|
await sync.bootstrap()
|
||||||
setRemoving(undefined)
|
setRemoving(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useEditorContext } from "../../context/editor"
|
|||||||
import { useProject } from "../../context/project"
|
import { useProject } from "../../context/project"
|
||||||
import { useSDK } from "../../context/sdk"
|
import { useSDK } from "../../context/sdk"
|
||||||
import { useSync } from "../../context/sync"
|
import { useSync } from "../../context/sync"
|
||||||
import { useSyncV2 } from "../../context/sync-v2"
|
import { useData } from "../../context/data"
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
import { useTuiPaths } from "../../context/runtime"
|
import { useTuiPaths } from "../../context/runtime"
|
||||||
import { useTuiConfig } from "../../config"
|
import { useTuiConfig } from "../../config"
|
||||||
@@ -85,7 +85,7 @@ export function Autocomplete(props: {
|
|||||||
const editor = useEditorContext()
|
const editor = useEditorContext()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const syncV2 = useSyncV2()
|
const data = useData()
|
||||||
const project = useProject()
|
const project = useProject()
|
||||||
const slashes = useCommandSlashes()
|
const slashes = useCommandSlashes()
|
||||||
const modeStack = useOpencodeModeStack()
|
const modeStack = useOpencodeModeStack()
|
||||||
@@ -273,12 +273,14 @@ export function Autocomplete(props: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const references = createMemo(() => data.location.reference.list() ?? [])
|
||||||
|
|
||||||
const referenceMatch = createMemo(() => {
|
const referenceMatch = createMemo(() => {
|
||||||
if (!store.visible || store.visible === "/") return
|
if (!store.visible || store.visible === "/") return
|
||||||
const { baseQuery } = extractLineRange(search())
|
const { baseQuery } = extractLineRange(search())
|
||||||
const slash = baseQuery.indexOf("/")
|
const slash = baseQuery.indexOf("/")
|
||||||
const alias = slash === -1 ? baseQuery : baseQuery.slice(0, slash)
|
const alias = slash === -1 ? baseQuery : baseQuery.slice(0, slash)
|
||||||
return syncV2.data.reference.find((item) => !item.hidden && item.name === alias)
|
return references().find((item) => !item.hidden && item.name === alias)
|
||||||
})
|
})
|
||||||
|
|
||||||
function normalizeMentionPath(filePath: string) {
|
function normalizeMentionPath(filePath: string) {
|
||||||
@@ -312,14 +314,12 @@ export function Autocomplete(props: {
|
|||||||
async (query) => {
|
async (query) => {
|
||||||
if (!store.visible || store.visible === "/") return []
|
if (!store.visible || store.visible === "/") return []
|
||||||
if (referenceMatch()) return []
|
if (referenceMatch()) return []
|
||||||
|
|
||||||
const { lineRange, baseQuery } = extractLineRange(query ?? "")
|
const { lineRange, baseQuery } = extractLineRange(query ?? "")
|
||||||
|
|
||||||
// Get files from SDK
|
// Get files from SDK
|
||||||
const result = await sdk.client.v2.fs.find({
|
const result = await sdk.client.find.files({
|
||||||
query: baseQuery,
|
query: baseQuery,
|
||||||
limit: "20",
|
workspace: project.workspace.current(),
|
||||||
location: { workspace: project.workspace.current() },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const options: AutocompleteOption[] = []
|
const options: AutocompleteOption[] = []
|
||||||
@@ -329,14 +329,15 @@ export function Autocomplete(props: {
|
|||||||
if (!result.error && result.data) {
|
if (!result.error && result.data) {
|
||||||
const width = props.anchor().width - 4
|
const width = props.anchor().width - 4
|
||||||
options.push(
|
options.push(
|
||||||
...result.data.data.map((item): AutocompleteOption => {
|
...result.data.map((item): AutocompleteOption => {
|
||||||
const { filename, url, part } = createFilePart(item.path, lineRange)
|
const { filename, url, part } = createFilePart(item, lineRange)
|
||||||
|
|
||||||
|
const isDir = item.endsWith("/")
|
||||||
return {
|
return {
|
||||||
display: Locale.truncateMiddle(filename, width),
|
display: Locale.truncateMiddle(filename, width),
|
||||||
value: filename,
|
value: filename,
|
||||||
isDirectory: item.type === "directory",
|
isDirectory: isDir,
|
||||||
path: item.path,
|
path: item,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
insertPart(filename, part)
|
insertPart(filename, part)
|
||||||
},
|
},
|
||||||
@@ -389,16 +390,15 @@ export function Autocomplete(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const agents = createMemo(() => {
|
const agents = createMemo(() => {
|
||||||
const agents = sync.data.agent
|
return (data.location.agent.list() ?? [])
|
||||||
return agents
|
|
||||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||||
.map(
|
.map(
|
||||||
(agent): AutocompleteOption => ({
|
(agent): AutocompleteOption => ({
|
||||||
display: "@" + agent.name,
|
display: "@" + agent.id,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
insertPart(agent.name, {
|
insertPart(agent.id, {
|
||||||
type: "agent",
|
type: "agent",
|
||||||
name: agent.name,
|
name: agent.id,
|
||||||
source: {
|
source: {
|
||||||
start: 0,
|
start: 0,
|
||||||
end: 0,
|
end: 0,
|
||||||
@@ -411,7 +411,7 @@ export function Autocomplete(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const referenceAliases = createMemo(() =>
|
const referenceAliases = createMemo(() =>
|
||||||
syncV2.data.reference
|
references()
|
||||||
.filter((reference) => !reference.hidden)
|
.filter((reference) => !reference.hidden)
|
||||||
.map(
|
.map(
|
||||||
(reference): AutocompleteOption => ({
|
(reference): AutocompleteOption => ({
|
||||||
@@ -471,8 +471,6 @@ export function Autocomplete(props: {
|
|||||||
const commandsValue = commands()
|
const commandsValue = commands()
|
||||||
const searchValue = search()
|
const searchValue = search()
|
||||||
|
|
||||||
// @<alias>/... — narrow to the matched reference, files come from fff
|
|
||||||
// already ranked so there is no re-ranking here.
|
|
||||||
if (store.visible === "@" && referenceMatchValue) {
|
if (store.visible === "@" && referenceMatchValue) {
|
||||||
return referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`)
|
return referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { useSync } from "../context/sync"
|
import { useData } from "../context/data"
|
||||||
|
|
||||||
export function useConnected() {
|
export function useConnected() {
|
||||||
const sync = useSync()
|
const data = useData()
|
||||||
return createMemo(() =>
|
return createMemo(() => (data.location.provider.list() ?? []).some((provider) => provider.enabled !== false))
|
||||||
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,567 @@
|
|||||||
|
import { useEvent } from "./event"
|
||||||
|
import type {
|
||||||
|
AgentV2Info,
|
||||||
|
CommandV2Info,
|
||||||
|
Event,
|
||||||
|
LocationRef,
|
||||||
|
ModelV2Info,
|
||||||
|
PermissionSavedInfo,
|
||||||
|
PermissionV2Request,
|
||||||
|
ProviderV2Info,
|
||||||
|
QuestionV2Request,
|
||||||
|
ReferenceInfo,
|
||||||
|
SessionMessage,
|
||||||
|
SessionMessageAssistant,
|
||||||
|
SessionMessageAssistantReasoning,
|
||||||
|
SessionMessageAssistantText,
|
||||||
|
SessionMessageAssistantTool,
|
||||||
|
SessionV2Info,
|
||||||
|
SkillV2Info,
|
||||||
|
} from "@opencode-ai/sdk/v2"
|
||||||
|
import { createStore, produce } from "solid-js/store"
|
||||||
|
import { createSimpleContext } from "./helper"
|
||||||
|
import { useSDK } from "./sdk"
|
||||||
|
import { createSignal, onMount } from "solid-js"
|
||||||
|
|
||||||
|
type LocationData = {
|
||||||
|
agent?: AgentV2Info[]
|
||||||
|
command?: CommandV2Info[]
|
||||||
|
model?: ModelV2Info[]
|
||||||
|
provider?: ProviderV2Info[]
|
||||||
|
reference?: ReferenceInfo[]
|
||||||
|
skill?: SkillV2Info[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Data = {
|
||||||
|
session: {
|
||||||
|
info: Record<string, SessionV2Info>
|
||||||
|
message: Record<string, SessionMessage[]>
|
||||||
|
permission: Record<string, PermissionV2Request[]>
|
||||||
|
question: Record<string, QuestionV2Request[]>
|
||||||
|
}
|
||||||
|
project: {
|
||||||
|
permission: Record<string, PermissionSavedInfo[]>
|
||||||
|
}
|
||||||
|
location: Record<string, LocationData>
|
||||||
|
}
|
||||||
|
|
||||||
|
function locationKey(location: LocationRef) {
|
||||||
|
return JSON.stringify([location.directory, location.workspaceID])
|
||||||
|
}
|
||||||
|
|
||||||
|
function locationQuery(ref?: LocationRef) {
|
||||||
|
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
|
name: "Data",
|
||||||
|
init: () => {
|
||||||
|
const [store, setStore] = createStore<Data>({
|
||||||
|
session: {
|
||||||
|
info: {},
|
||||||
|
message: {},
|
||||||
|
permission: {},
|
||||||
|
question: {},
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
permission: {},
|
||||||
|
},
|
||||||
|
location: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = useEvent()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
|
||||||
|
directory: sdk.directory ?? process.cwd(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
update(sessionID: string, fn: (messages: SessionMessage[]) => void) {
|
||||||
|
setStore(
|
||||||
|
"session",
|
||||||
|
"message",
|
||||||
|
produce((draft) => {
|
||||||
|
fn((draft[sessionID] ??= []))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
prepend(messages: SessionMessage[], item: SessionMessage) {
|
||||||
|
if (messages.some((existing) => existing.id === item.id)) return
|
||||||
|
messages.unshift(item)
|
||||||
|
},
|
||||||
|
activeAssistant(messages: SessionMessage[]) {
|
||||||
|
const item = messages.find((item) => item.type === "assistant" && !item.time.completed)
|
||||||
|
return item?.type === "assistant" ? item : undefined
|
||||||
|
},
|
||||||
|
assistant(messages: SessionMessage[], messageID: string) {
|
||||||
|
const item = messages.find((item) => item.type === "assistant" && item.id === messageID)
|
||||||
|
return item?.type === "assistant" ? item : undefined
|
||||||
|
},
|
||||||
|
activeShell(messages: SessionMessage[], callID: string) {
|
||||||
|
const item = messages.find((item) => item.type === "shell" && item.callID === callID)
|
||||||
|
return item?.type === "shell" ? item : undefined
|
||||||
|
},
|
||||||
|
latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
|
||||||
|
return assistant?.content.findLast(
|
||||||
|
(item): item is SessionMessageAssistantTool =>
|
||||||
|
item.type === "tool" && (callID === undefined || item.id === callID),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
latestText(assistant: SessionMessageAssistant | undefined, textID: string) {
|
||||||
|
return assistant?.content.findLast(
|
||||||
|
(item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
|
||||||
|
return assistant?.content.findLast(
|
||||||
|
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
event.subscribe((event) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case "session.next.agent.switched":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
message.prepend(draft, {
|
||||||
|
id: event.properties.messageID,
|
||||||
|
type: "agent-switched",
|
||||||
|
agent: event.properties.agent,
|
||||||
|
time: { created: event.properties.timestamp },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.model.switched":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
message.prepend(draft, {
|
||||||
|
id: event.properties.messageID,
|
||||||
|
type: "model-switched",
|
||||||
|
model: event.properties.model,
|
||||||
|
time: { created: event.properties.timestamp },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.prompted": {
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
message.prepend(draft, {
|
||||||
|
id: event.properties.messageID,
|
||||||
|
type: "user",
|
||||||
|
text: event.properties.prompt.text,
|
||||||
|
files: event.properties.prompt.files,
|
||||||
|
agents: event.properties.prompt.agents,
|
||||||
|
time: { created: event.properties.timestamp },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "session.next.prompt.admitted":
|
||||||
|
break
|
||||||
|
case "session.next.prompt.promoted":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
message.prepend(draft, {
|
||||||
|
id: event.properties.messageID,
|
||||||
|
type: "user",
|
||||||
|
text: event.properties.prompt.text,
|
||||||
|
files: event.properties.prompt.files,
|
||||||
|
agents: event.properties.prompt.agents,
|
||||||
|
time: { created: event.properties.timeCreated },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.context.updated":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
message.prepend(draft, {
|
||||||
|
id: event.properties.messageID,
|
||||||
|
type: "system",
|
||||||
|
text: event.properties.text,
|
||||||
|
time: { created: event.properties.timestamp },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.synthetic":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
message.prepend(draft, {
|
||||||
|
id: event.properties.messageID,
|
||||||
|
type: "synthetic",
|
||||||
|
sessionID: event.properties.sessionID,
|
||||||
|
text: event.properties.text,
|
||||||
|
time: { created: event.properties.timestamp },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.shell.started":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
message.prepend(draft, {
|
||||||
|
id: event.properties.messageID,
|
||||||
|
type: "shell",
|
||||||
|
callID: event.properties.callID,
|
||||||
|
command: event.properties.command,
|
||||||
|
output: "",
|
||||||
|
time: { created: event.properties.timestamp },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.shell.ended":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
const match = message.activeShell(draft, event.properties.callID)
|
||||||
|
if (!match) return
|
||||||
|
match.output = event.properties.output
|
||||||
|
match.time.completed = event.properties.timestamp
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.step.started":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
if (draft.some((message) => message.id === event.properties.assistantMessageID)) return
|
||||||
|
const currentAssistant = message.activeAssistant(draft)
|
||||||
|
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
|
||||||
|
message.prepend(draft, {
|
||||||
|
id: event.properties.assistantMessageID,
|
||||||
|
type: "assistant",
|
||||||
|
agent: event.properties.agent,
|
||||||
|
model: event.properties.model,
|
||||||
|
content: [],
|
||||||
|
snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined,
|
||||||
|
time: { created: event.properties.timestamp },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.step.ended":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
const currentAssistant = message.assistant(draft, event.properties.assistantMessageID)
|
||||||
|
if (!currentAssistant) return
|
||||||
|
currentAssistant.time.completed = event.properties.timestamp
|
||||||
|
currentAssistant.finish = event.properties.finish
|
||||||
|
currentAssistant.cost = event.properties.cost
|
||||||
|
currentAssistant.tokens = event.properties.tokens
|
||||||
|
if (event.properties.snapshot)
|
||||||
|
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot }
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.step.failed":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
const currentAssistant = message.assistant(draft, event.properties.assistantMessageID)
|
||||||
|
if (!currentAssistant) return
|
||||||
|
currentAssistant.time.completed = event.properties.timestamp
|
||||||
|
currentAssistant.finish = "error"
|
||||||
|
currentAssistant.error = event.properties.error
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.text.started":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
message.assistant(draft, event.properties.assistantMessageID)?.content.push({
|
||||||
|
type: "text",
|
||||||
|
id: event.properties.textID,
|
||||||
|
text: "",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.text.delta":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
const match = message.latestText(
|
||||||
|
message.assistant(draft, event.properties.assistantMessageID),
|
||||||
|
event.properties.textID,
|
||||||
|
)
|
||||||
|
if (match) match.text += event.properties.delta
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.text.ended":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
const match = message.latestText(
|
||||||
|
message.assistant(draft, event.properties.assistantMessageID),
|
||||||
|
event.properties.textID,
|
||||||
|
)
|
||||||
|
if (match) match.text = event.properties.text
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.tool.input.started":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
message.assistant(draft, event.properties.assistantMessageID)?.content.push({
|
||||||
|
type: "tool",
|
||||||
|
id: event.properties.callID,
|
||||||
|
name: event.properties.name,
|
||||||
|
time: { created: event.properties.timestamp },
|
||||||
|
state: { status: "pending", input: "" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.tool.input.delta":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
const match = message.latestTool(
|
||||||
|
message.assistant(draft, event.properties.assistantMessageID),
|
||||||
|
event.properties.callID,
|
||||||
|
)
|
||||||
|
if (match?.state.status === "pending") match.state.input += event.properties.delta
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.tool.input.ended":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
const match = message.latestTool(
|
||||||
|
message.assistant(draft, event.properties.assistantMessageID),
|
||||||
|
event.properties.callID,
|
||||||
|
)
|
||||||
|
if (match?.state.status === "pending") match.state.input = event.properties.text
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.tool.called":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
const match = message.latestTool(
|
||||||
|
message.assistant(draft, event.properties.assistantMessageID),
|
||||||
|
event.properties.callID,
|
||||||
|
)
|
||||||
|
if (!match) return
|
||||||
|
match.time.ran = event.properties.timestamp
|
||||||
|
match.provider = event.properties.provider
|
||||||
|
match.state = { status: "running", input: event.properties.input, structured: {}, content: [] }
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.tool.progress":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
const match = message.latestTool(
|
||||||
|
message.assistant(draft, event.properties.assistantMessageID),
|
||||||
|
event.properties.callID,
|
||||||
|
)
|
||||||
|
if (match?.state.status !== "running") return
|
||||||
|
match.state.structured = event.properties.structured
|
||||||
|
match.state.content = [...event.properties.content]
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.tool.success":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
const match = message.latestTool(
|
||||||
|
message.assistant(draft, event.properties.assistantMessageID),
|
||||||
|
event.properties.callID,
|
||||||
|
)
|
||||||
|
if (match?.state.status !== "running") return
|
||||||
|
match.state = {
|
||||||
|
status: "completed",
|
||||||
|
input: match.state.input,
|
||||||
|
structured: event.properties.structured,
|
||||||
|
content: [...event.properties.content],
|
||||||
|
result: event.properties.result,
|
||||||
|
}
|
||||||
|
match.provider = {
|
||||||
|
executed: event.properties.provider.executed || match.provider?.executed === true,
|
||||||
|
metadata: match.provider?.metadata,
|
||||||
|
resultMetadata: event.properties.provider.metadata,
|
||||||
|
}
|
||||||
|
match.time.completed = event.properties.timestamp
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.tool.failed":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
const match = message.latestTool(
|
||||||
|
message.assistant(draft, event.properties.assistantMessageID),
|
||||||
|
event.properties.callID,
|
||||||
|
)
|
||||||
|
if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
|
||||||
|
match.state = {
|
||||||
|
status: "error",
|
||||||
|
error: event.properties.error,
|
||||||
|
input: typeof match.state.input === "string" ? {} : match.state.input,
|
||||||
|
structured: match.state.status === "running" ? match.state.structured : {},
|
||||||
|
content: match.state.status === "running" ? match.state.content : [],
|
||||||
|
result: event.properties.result,
|
||||||
|
}
|
||||||
|
match.provider = {
|
||||||
|
executed: event.properties.provider.executed || match.provider?.executed === true,
|
||||||
|
metadata: match.provider?.metadata,
|
||||||
|
resultMetadata: event.properties.provider.metadata,
|
||||||
|
}
|
||||||
|
match.time.completed = event.properties.timestamp
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.reasoning.started":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
message.assistant(draft, event.properties.assistantMessageID)?.content.push({
|
||||||
|
type: "reasoning",
|
||||||
|
id: event.properties.reasoningID,
|
||||||
|
text: "",
|
||||||
|
providerMetadata: event.properties.providerMetadata,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.reasoning.delta":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
const match = message.latestReasoning(
|
||||||
|
message.assistant(draft, event.properties.assistantMessageID),
|
||||||
|
event.properties.reasoningID,
|
||||||
|
)
|
||||||
|
if (match) match.text += event.properties.delta
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.reasoning.ended":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
const match = message.latestReasoning(
|
||||||
|
message.assistant(draft, event.properties.assistantMessageID),
|
||||||
|
event.properties.reasoningID,
|
||||||
|
)
|
||||||
|
if (match) {
|
||||||
|
match.text = event.properties.text
|
||||||
|
if (event.properties.providerMetadata !== undefined)
|
||||||
|
match.providerMetadata = event.properties.providerMetadata
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "session.next.retried":
|
||||||
|
case "session.next.compaction.started":
|
||||||
|
case "session.next.compaction.delta":
|
||||||
|
break
|
||||||
|
case "session.next.compaction.ended":
|
||||||
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
|
message.prepend(draft, {
|
||||||
|
id: event.properties.messageID,
|
||||||
|
type: "compaction",
|
||||||
|
reason: event.properties.reason,
|
||||||
|
summary: event.properties.text,
|
||||||
|
recent: event.properties.recent,
|
||||||
|
time: { created: event.properties.timestamp },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "reference.updated":
|
||||||
|
void result.location.reference.refresh()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
session: {
|
||||||
|
get(sessionID: string) {
|
||||||
|
return store.session.info[sessionID]
|
||||||
|
},
|
||||||
|
async refresh(sessionID: string) {
|
||||||
|
const result = await sdk.client.v2.session.get({ sessionID }, { throwOnError: true })
|
||||||
|
setStore("session", "info", sessionID, result.data.data)
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
list(sessionID: string) {
|
||||||
|
return store.session.message[sessionID]
|
||||||
|
},
|
||||||
|
async refresh(sessionID: string) {
|
||||||
|
const result = await sdk.client.v2.session.messages({ sessionID }, { throwOnError: true })
|
||||||
|
setStore("session", "message", sessionID, result.data.data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
permission: {
|
||||||
|
list(sessionID: string) {
|
||||||
|
return store.session.permission[sessionID]
|
||||||
|
},
|
||||||
|
async refresh(sessionID: string) {
|
||||||
|
const result = await sdk.client.v2.session.permission.list({ sessionID }, { throwOnError: true })
|
||||||
|
setStore("session", "permission", sessionID, result.data.data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
question: {
|
||||||
|
list(sessionID: string) {
|
||||||
|
return store.session.question[sessionID]
|
||||||
|
},
|
||||||
|
async refresh(sessionID: string) {
|
||||||
|
const result = await sdk.client.v2.session.question.list({ sessionID }, { throwOnError: true })
|
||||||
|
setStore("session", "question", sessionID, result.data.data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
permission: {
|
||||||
|
list(projectID: string) {
|
||||||
|
return store.project.permission[projectID]
|
||||||
|
},
|
||||||
|
async refresh(projectID: string) {
|
||||||
|
const result = await sdk.client.v2.permission.saved.list({ projectID }, { throwOnError: true })
|
||||||
|
setStore("project", "permission", projectID, result.data.data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
default() {
|
||||||
|
return defaultLocation()
|
||||||
|
},
|
||||||
|
async refresh(ref?: LocationRef) {
|
||||||
|
const response = await sdk.client.v2.location.get({ location: locationQuery(ref) }, { throwOnError: true })
|
||||||
|
const location = response.data
|
||||||
|
const key = locationKey(location)
|
||||||
|
if (!store.location[key]) setStore("location", key, {})
|
||||||
|
if (!ref) setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID })
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
list(location?: LocationRef) {
|
||||||
|
return store.location[locationKey(location ?? defaultLocation())]?.agent
|
||||||
|
},
|
||||||
|
async refresh(ref?: LocationRef) {
|
||||||
|
const result = await sdk.client.v2.agent.list({ location: locationQuery(ref) }, { throwOnError: true })
|
||||||
|
const key = locationKey(result.data.location)
|
||||||
|
setStore("location", key, "agent", result.data.data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
command: {
|
||||||
|
list(location?: LocationRef) {
|
||||||
|
return store.location[locationKey(location ?? defaultLocation())]?.command
|
||||||
|
},
|
||||||
|
async refresh(ref?: LocationRef) {
|
||||||
|
const result = await sdk.client.v2.command.list({ location: locationQuery(ref) }, { throwOnError: true })
|
||||||
|
const key = locationKey(result.data.location)
|
||||||
|
setStore("location", key, "command", result.data.data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
list(location?: LocationRef) {
|
||||||
|
return store.location[locationKey(location ?? defaultLocation())]?.model
|
||||||
|
},
|
||||||
|
async refresh(ref?: LocationRef) {
|
||||||
|
const result = await sdk.client.v2.model.list({ location: locationQuery(ref) }, { throwOnError: true })
|
||||||
|
const key = locationKey(result.data.location)
|
||||||
|
setStore("location", key, "model", result.data.data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
list(location?: LocationRef) {
|
||||||
|
return store.location[locationKey(location ?? defaultLocation())]?.provider
|
||||||
|
},
|
||||||
|
async refresh(ref?: LocationRef) {
|
||||||
|
const result = await sdk.client.v2.provider.list({ location: locationQuery(ref) }, { throwOnError: true })
|
||||||
|
const key = locationKey(result.data.location)
|
||||||
|
setStore("location", key, "provider", result.data.data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reference: {
|
||||||
|
list(location?: LocationRef) {
|
||||||
|
return store.location[locationKey(location ?? defaultLocation())]?.reference
|
||||||
|
},
|
||||||
|
async refresh(ref?: LocationRef) {
|
||||||
|
const result = await sdk.client.v2.reference.list({ location: locationQuery(ref) }, { throwOnError: true })
|
||||||
|
const key = locationKey(result.data.location)
|
||||||
|
setStore("location", key, "reference", result.data.data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
skill: {
|
||||||
|
list(location?: LocationRef) {
|
||||||
|
return store.location[locationKey(location ?? defaultLocation())]?.skill
|
||||||
|
},
|
||||||
|
async refresh(ref?: LocationRef) {
|
||||||
|
const result = await sdk.client.v2.skill.list({ location: locationQuery(ref) }, { throwOnError: true })
|
||||||
|
const key = locationKey(result.data.location)
|
||||||
|
setStore("location", key, "skill", result.data.data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void Promise.allSettled([
|
||||||
|
result.location.refresh(),
|
||||||
|
result.location.agent.refresh(),
|
||||||
|
result.location.model.refresh(),
|
||||||
|
result.location.provider.refresh(),
|
||||||
|
result.location.reference.refresh(),
|
||||||
|
result.location.command.refresh(),
|
||||||
|
result.location.skill.refresh(),
|
||||||
|
])
|
||||||
|
.then((settled) => {
|
||||||
|
for (const failure of settled.filter((item) => item.status === "rejected"))
|
||||||
|
console.error("Failed to refresh default location data", failure.reason)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -12,6 +12,7 @@ import { readJson, writeJsonAtomic } from "../util/persistence"
|
|||||||
import { useTheme } from "./theme"
|
import { useTheme } from "./theme"
|
||||||
import { useToast } from "../ui/toast"
|
import { useToast } from "../ui/toast"
|
||||||
import { useRoute } from "./route"
|
import { useRoute } from "./route"
|
||||||
|
import { useData } from "./data"
|
||||||
|
|
||||||
export type LocalTheme = {
|
export type LocalTheme = {
|
||||||
secondary: RGBA
|
secondary: RGBA
|
||||||
@@ -51,15 +52,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
name: "Local",
|
name: "Local",
|
||||||
init: () => {
|
init: () => {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const data = useData()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const theme = useTheme().theme
|
const theme = useTheme().theme
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const paths = useTuiPaths()
|
const paths = useTuiPaths()
|
||||||
|
const providers = createMemo(() => data.location.provider.list() ?? [])
|
||||||
|
const models = createMemo(() => data.location.model.list() ?? [])
|
||||||
|
|
||||||
function isModelValid(model: { providerID: string; modelID: string }) {
|
function isModelValid(model: { providerID: string; modelID: string }) {
|
||||||
const provider = sync.data.provider.find((x) => x.id === model.providerID)
|
return models().some((item) => item.providerID === model.providerID && item.id === model.modelID && item.enabled)
|
||||||
return !!provider?.models[model.modelID]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFirstValidModel(...modelFns: (() => { providerID: string; modelID: string } | undefined)[]) {
|
function getFirstValidModel(...modelFns: (() => { providerID: string; modelID: string } | undefined)[]) {
|
||||||
@@ -71,8 +74,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createAgent() {
|
function createAgent() {
|
||||||
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
const all = createMemo(() =>
|
||||||
const visibleAgents = createMemo(() => sync.data.agent.filter((x) => !x.hidden))
|
(data.location.agent.list() ?? []).map((agent) => ({
|
||||||
|
...agent,
|
||||||
|
name: agent.id,
|
||||||
|
native: false,
|
||||||
|
model: agent.model
|
||||||
|
? { providerID: agent.model.providerID, modelID: agent.model.id, variant: agent.model.variant }
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const agents = createMemo(() => all().filter((agent) => agent.mode !== "subagent" && !agent.hidden))
|
||||||
|
const visibleAgents = createMemo(() => all().filter((agent) => !agent.hidden))
|
||||||
const [agentStore, setAgentStore] = createStore({
|
const [agentStore, setAgentStore] = createStore({
|
||||||
current: undefined as string | undefined,
|
current: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
@@ -218,15 +231,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = sync.data.provider[0]
|
const model = models().find((item) => item.enabled && providers().some((provider) => provider.id === item.providerID))
|
||||||
if (!provider) return undefined
|
|
||||||
const defaultModel = sync.data.provider_default[provider.id]
|
|
||||||
const firstModel = Object.values(provider.models)[0]
|
|
||||||
const model = defaultModel ?? firstModel?.id
|
|
||||||
if (!model) return undefined
|
if (!model) return undefined
|
||||||
return {
|
return {
|
||||||
providerID: provider.id,
|
providerID: model.providerID,
|
||||||
modelID: model,
|
modelID: model.id,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -261,12 +270,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
reasoning: false,
|
reasoning: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const provider = sync.data.provider.find((x) => x.id === value.providerID)
|
const provider = providers().find((item) => item.id === value.providerID)
|
||||||
const info = provider?.models[value.modelID]
|
const info = models().find((item) => item.providerID === value.providerID && item.id === value.modelID)
|
||||||
return {
|
return {
|
||||||
provider: provider?.name ?? value.providerID,
|
provider: provider?.name ?? value.providerID,
|
||||||
model: info?.name ?? value.modelID,
|
model: info?.name ?? value.modelID,
|
||||||
reasoning: info?.capabilities?.reasoning ?? false,
|
reasoning: false,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
cycle(direction: 1 | -1) {
|
cycle(direction: 1 | -1) {
|
||||||
@@ -372,10 +381,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
list() {
|
list() {
|
||||||
const m = currentModel()
|
const m = currentModel()
|
||||||
if (!m) return []
|
if (!m) return []
|
||||||
const provider = sync.data.provider.find((x) => x.id === m.providerID)
|
const info = models().find((item) => item.providerID === m.providerID && item.id === m.modelID)
|
||||||
const info = provider?.models[m.modelID]
|
return info?.variants.map((variant) => variant.id) ?? []
|
||||||
if (!info?.variants) return []
|
|
||||||
return Object.keys(info.variants)
|
|
||||||
},
|
},
|
||||||
set(value: string | undefined) {
|
set(value: string | undefined) {
|
||||||
const m = currentModel()
|
const m = currentModel()
|
||||||
|
|||||||
@@ -1,465 +0,0 @@
|
|||||||
import { useEvent } from "./event"
|
|
||||||
import type {
|
|
||||||
Event,
|
|
||||||
ReferenceInfo,
|
|
||||||
SessionMessage,
|
|
||||||
SessionMessageAssistant,
|
|
||||||
SessionMessageAssistantReasoning,
|
|
||||||
SessionMessageAssistantText,
|
|
||||||
SessionMessageAssistantTool,
|
|
||||||
} from "@opencode-ai/sdk/v2"
|
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
|
||||||
import { createSimpleContext } from "./helper"
|
|
||||||
import { useSDK } from "./sdk"
|
|
||||||
import { useProject } from "./project"
|
|
||||||
import { createEffect } from "solid-js"
|
|
||||||
|
|
||||||
function activeAssistant(messages: SessionMessage[]) {
|
|
||||||
const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed)
|
|
||||||
if (index < 0) return
|
|
||||||
const assistant = messages[index]
|
|
||||||
return assistant?.type === "assistant" ? assistant : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function ownedAssistant(messages: SessionMessage[], messageID: string) {
|
|
||||||
const message = messages.find((message) => message.type === "assistant" && message.id === messageID)
|
|
||||||
return message?.type === "assistant" ? message : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function activeShell(messages: SessionMessage[], callID: string) {
|
|
||||||
const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID)
|
|
||||||
if (index < 0) return
|
|
||||||
const shell = messages[index]
|
|
||||||
return shell?.type === "shell" ? shell : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
|
|
||||||
return assistant?.content.findLast(
|
|
||||||
(item): item is SessionMessageAssistantTool => item.type === "tool" && (callID === undefined || item.id === callID),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function latestText(assistant: SessionMessageAssistant | undefined, textID: string) {
|
|
||||||
return assistant?.content.findLast(
|
|
||||||
(item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
|
|
||||||
return assistant?.content.findLast(
|
|
||||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function prepend(messages: SessionMessage[], message: SessionMessage) {
|
|
||||||
if (messages.some((item) => item.id === message.id)) return
|
|
||||||
messages.unshift(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext({
|
|
||||||
name: "SyncV2",
|
|
||||||
init: () => {
|
|
||||||
const [store, setStore] = createStore<{
|
|
||||||
messages: {
|
|
||||||
[sessionID: string]: SessionMessage[]
|
|
||||||
}
|
|
||||||
reference: ReferenceInfo[]
|
|
||||||
}>({
|
|
||||||
messages: {},
|
|
||||||
reference: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const event = useEvent()
|
|
||||||
const sdk = useSDK()
|
|
||||||
const project = useProject()
|
|
||||||
const applied = new Set<string>()
|
|
||||||
const buffering = new Map<string, Event[]>()
|
|
||||||
const syncing = new Map<string, Promise<void>>()
|
|
||||||
|
|
||||||
function duplicate(id: string) {
|
|
||||||
if (applied.has(id)) return true
|
|
||||||
applied.add(id)
|
|
||||||
if (applied.size <= 1000) return false
|
|
||||||
const oldest = applied.values().next()
|
|
||||||
if (!oldest.done) applied.delete(oldest.value)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function update(sessionID: string, fn: (messages: SessionMessage[]) => void) {
|
|
||||||
setStore(
|
|
||||||
"messages",
|
|
||||||
produce((draft) => {
|
|
||||||
fn((draft[sessionID] ??= []))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hydrate(sessionID: string) {
|
|
||||||
const pending: Event[] = []
|
|
||||||
const before = JSON.parse(JSON.stringify(store.messages[sessionID] ?? [])) as SessionMessage[]
|
|
||||||
buffering.set(sessionID, pending)
|
|
||||||
try {
|
|
||||||
const response = await sdk.client.v2.session.messages({ sessionID })
|
|
||||||
const messages = response.data?.data ?? []
|
|
||||||
const snapshotIDs = new Set(messages.map((message) => message.id))
|
|
||||||
setStore(
|
|
||||||
"messages",
|
|
||||||
sessionID,
|
|
||||||
reconcile([...messages, ...before.filter((message) => !snapshotIDs.has(message.id))]),
|
|
||||||
)
|
|
||||||
buffering.delete(sessionID)
|
|
||||||
for (const event of pending) apply(event)
|
|
||||||
} catch (error) {
|
|
||||||
buffering.delete(sessionID)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sync(sessionID: string) {
|
|
||||||
const existing = syncing.get(sessionID)
|
|
||||||
if (existing) return existing
|
|
||||||
const result = hydrate(sessionID).finally(() => syncing.delete(sessionID))
|
|
||||||
syncing.set(sessionID, result)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncReferences(workspace = project.workspace.current()) {
|
|
||||||
const result = await sdk.client.v2.reference.list({ location: { workspace } })
|
|
||||||
if (workspace !== project.workspace.current()) return
|
|
||||||
setStore("reference", reconcile(result.data?.data ?? []))
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
project.workspace.current()
|
|
||||||
void syncReferences()
|
|
||||||
})
|
|
||||||
|
|
||||||
function apply(event: Event) {
|
|
||||||
switch (event.type) {
|
|
||||||
case "session.next.agent.switched":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
prepend(draft, {
|
|
||||||
id: event.properties.messageID,
|
|
||||||
type: "agent-switched",
|
|
||||||
agent: event.properties.agent,
|
|
||||||
time: { created: event.properties.timestamp },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.model.switched":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
prepend(draft, {
|
|
||||||
id: event.properties.messageID,
|
|
||||||
type: "model-switched",
|
|
||||||
model: event.properties.model,
|
|
||||||
time: { created: event.properties.timestamp },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.prompted": {
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
prepend(draft, {
|
|
||||||
id: event.properties.messageID,
|
|
||||||
type: "user",
|
|
||||||
text: event.properties.prompt.text,
|
|
||||||
files: event.properties.prompt.files,
|
|
||||||
agents: event.properties.prompt.agents,
|
|
||||||
time: { created: event.properties.timestamp },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "session.next.prompt.admitted":
|
|
||||||
break
|
|
||||||
case "session.next.prompt.promoted":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
prepend(draft, {
|
|
||||||
id: event.properties.messageID,
|
|
||||||
type: "user",
|
|
||||||
text: event.properties.prompt.text,
|
|
||||||
files: event.properties.prompt.files,
|
|
||||||
agents: event.properties.prompt.agents,
|
|
||||||
time: { created: event.properties.timeCreated },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.context.updated":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
prepend(draft, {
|
|
||||||
id: event.properties.messageID,
|
|
||||||
type: "system",
|
|
||||||
text: event.properties.text,
|
|
||||||
time: { created: event.properties.timestamp },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.synthetic":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
prepend(draft, {
|
|
||||||
id: event.properties.messageID,
|
|
||||||
type: "synthetic",
|
|
||||||
sessionID: event.properties.sessionID,
|
|
||||||
text: event.properties.text,
|
|
||||||
time: { created: event.properties.timestamp },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.shell.started":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
prepend(draft, {
|
|
||||||
id: event.properties.messageID,
|
|
||||||
type: "shell",
|
|
||||||
callID: event.properties.callID,
|
|
||||||
command: event.properties.command,
|
|
||||||
output: "",
|
|
||||||
time: { created: event.properties.timestamp },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.shell.ended":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
const match = activeShell(draft, event.properties.callID)
|
|
||||||
if (!match) return
|
|
||||||
match.output = event.properties.output
|
|
||||||
match.time.completed = event.properties.timestamp
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.step.started":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
if (draft.some((message) => message.id === event.properties.assistantMessageID)) return
|
|
||||||
const currentAssistant = activeAssistant(draft)
|
|
||||||
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
|
|
||||||
prepend(draft, {
|
|
||||||
id: event.properties.assistantMessageID,
|
|
||||||
type: "assistant",
|
|
||||||
agent: event.properties.agent,
|
|
||||||
model: event.properties.model,
|
|
||||||
content: [],
|
|
||||||
snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined,
|
|
||||||
time: { created: event.properties.timestamp },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.step.ended":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
const currentAssistant = ownedAssistant(draft, event.properties.assistantMessageID)
|
|
||||||
if (!currentAssistant) return
|
|
||||||
currentAssistant.time.completed = event.properties.timestamp
|
|
||||||
currentAssistant.finish = event.properties.finish
|
|
||||||
currentAssistant.cost = event.properties.cost
|
|
||||||
currentAssistant.tokens = event.properties.tokens
|
|
||||||
if (event.properties.snapshot)
|
|
||||||
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot }
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.step.failed":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
const currentAssistant = ownedAssistant(draft, event.properties.assistantMessageID)
|
|
||||||
if (!currentAssistant) return
|
|
||||||
currentAssistant.time.completed = event.properties.timestamp
|
|
||||||
currentAssistant.finish = "error"
|
|
||||||
currentAssistant.error = event.properties.error
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.text.started":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
|
|
||||||
type: "text",
|
|
||||||
id: event.properties.textID,
|
|
||||||
text: "",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.text.delta":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
const match = latestText(
|
|
||||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
|
||||||
event.properties.textID,
|
|
||||||
)
|
|
||||||
if (match) match.text += event.properties.delta
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.text.ended":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
const match = latestText(
|
|
||||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
|
||||||
event.properties.textID,
|
|
||||||
)
|
|
||||||
if (match) match.text = event.properties.text
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.tool.input.started":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
|
|
||||||
type: "tool",
|
|
||||||
id: event.properties.callID,
|
|
||||||
name: event.properties.name,
|
|
||||||
time: { created: event.properties.timestamp },
|
|
||||||
state: { status: "pending", input: "" },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.tool.input.delta":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
const match = latestTool(
|
|
||||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
|
||||||
event.properties.callID,
|
|
||||||
)
|
|
||||||
if (match?.state.status === "pending") match.state.input += event.properties.delta
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.tool.input.ended":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
const match = latestTool(
|
|
||||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
|
||||||
event.properties.callID,
|
|
||||||
)
|
|
||||||
if (match?.state.status === "pending") match.state.input = event.properties.text
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.tool.called":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
const match = latestTool(
|
|
||||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
|
||||||
event.properties.callID,
|
|
||||||
)
|
|
||||||
if (!match) return
|
|
||||||
match.time.ran = event.properties.timestamp
|
|
||||||
match.provider = event.properties.provider
|
|
||||||
match.state = { status: "running", input: event.properties.input, structured: {}, content: [] }
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.tool.progress":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
const match = latestTool(
|
|
||||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
|
||||||
event.properties.callID,
|
|
||||||
)
|
|
||||||
if (match?.state.status !== "running") return
|
|
||||||
match.state.structured = event.properties.structured
|
|
||||||
match.state.content = [...event.properties.content]
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.tool.success":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
const match = latestTool(
|
|
||||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
|
||||||
event.properties.callID,
|
|
||||||
)
|
|
||||||
if (match?.state.status !== "running") return
|
|
||||||
match.state = {
|
|
||||||
status: "completed",
|
|
||||||
input: match.state.input,
|
|
||||||
structured: event.properties.structured,
|
|
||||||
content: [...event.properties.content],
|
|
||||||
result: event.properties.result,
|
|
||||||
}
|
|
||||||
match.provider = {
|
|
||||||
executed: event.properties.provider.executed || match.provider?.executed === true,
|
|
||||||
metadata: match.provider?.metadata,
|
|
||||||
resultMetadata: event.properties.provider.metadata,
|
|
||||||
}
|
|
||||||
match.time.completed = event.properties.timestamp
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.tool.failed":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
const match = latestTool(
|
|
||||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
|
||||||
event.properties.callID,
|
|
||||||
)
|
|
||||||
if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
|
|
||||||
match.state = {
|
|
||||||
status: "error",
|
|
||||||
error: event.properties.error,
|
|
||||||
input: typeof match.state.input === "string" ? {} : match.state.input,
|
|
||||||
structured: match.state.status === "running" ? match.state.structured : {},
|
|
||||||
content: match.state.status === "running" ? match.state.content : [],
|
|
||||||
result: event.properties.result,
|
|
||||||
}
|
|
||||||
match.provider = {
|
|
||||||
executed: event.properties.provider.executed || match.provider?.executed === true,
|
|
||||||
metadata: match.provider?.metadata,
|
|
||||||
resultMetadata: event.properties.provider.metadata,
|
|
||||||
}
|
|
||||||
match.time.completed = event.properties.timestamp
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.reasoning.started":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
|
|
||||||
type: "reasoning",
|
|
||||||
id: event.properties.reasoningID,
|
|
||||||
text: "",
|
|
||||||
providerMetadata: event.properties.providerMetadata,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.reasoning.delta":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
const match = latestReasoning(
|
|
||||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
|
||||||
event.properties.reasoningID,
|
|
||||||
)
|
|
||||||
if (match) match.text += event.properties.delta
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.reasoning.ended":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
const match = latestReasoning(
|
|
||||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
|
||||||
event.properties.reasoningID,
|
|
||||||
)
|
|
||||||
if (match) {
|
|
||||||
match.text = event.properties.text
|
|
||||||
if (event.properties.providerMetadata !== undefined)
|
|
||||||
match.providerMetadata = event.properties.providerMetadata
|
|
||||||
}
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.retried":
|
|
||||||
case "session.next.compaction.started":
|
|
||||||
case "session.next.compaction.delta":
|
|
||||||
break
|
|
||||||
case "session.next.compaction.ended":
|
|
||||||
update(event.properties.sessionID, (draft) => {
|
|
||||||
prepend(draft, {
|
|
||||||
id: event.properties.messageID,
|
|
||||||
type: "compaction",
|
|
||||||
reason: event.properties.reason,
|
|
||||||
summary: event.properties.text,
|
|
||||||
recent: event.properties.recent,
|
|
||||||
time: { created: event.properties.timestamp },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "reference.updated":
|
|
||||||
void syncReferences()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
event.subscribe((event) => {
|
|
||||||
if (duplicate(event.id)) return
|
|
||||||
if ("sessionID" in event.properties && typeof event.properties.sessionID === "string")
|
|
||||||
buffering.get(event.properties.sessionID)?.push(event)
|
|
||||||
apply(event)
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
data: store,
|
|
||||||
session: {
|
|
||||||
message: {
|
|
||||||
sync,
|
|
||||||
fromSession(sessionID: string) {
|
|
||||||
const messages = store.messages[sessionID]
|
|
||||||
if (!messages) return []
|
|
||||||
return messages
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -10,7 +10,6 @@ import SidebarTodo from "./sidebar/todo"
|
|||||||
import DiffViewer from "./system/diff-viewer"
|
import DiffViewer from "./system/diff-viewer"
|
||||||
import Notifications from "./system/notifications"
|
import Notifications from "./system/notifications"
|
||||||
import PluginManager from "./system/plugins"
|
import PluginManager from "./system/plugins"
|
||||||
import SessionV2Debug from "./system/session-v2"
|
|
||||||
import WhichKey from "./system/which-key"
|
import WhichKey from "./system/which-key"
|
||||||
|
|
||||||
export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
|
export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
|
||||||
@@ -33,6 +32,5 @@ export function createBuiltinPlugins(options: { experimentalEventSystem: boolean
|
|||||||
PluginManager,
|
PluginManager,
|
||||||
WhichKey,
|
WhichKey,
|
||||||
DiffViewer,
|
DiffViewer,
|
||||||
...(options.experimentalEventSystem ? [SessionV2Debug] : []),
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,21 @@ import { createMemo, Match, Show, Switch } from "solid-js"
|
|||||||
import { abbreviateHome } from "../../runtime"
|
import { abbreviateHome } from "../../runtime"
|
||||||
import { useTuiPaths } from "../../context/runtime"
|
import { useTuiPaths } from "../../context/runtime"
|
||||||
import { useHomeSessionDestination } from "../../routes/home/session-destination"
|
import { useHomeSessionDestination } from "../../routes/home/session-destination"
|
||||||
|
import { useData } from "../../context/data"
|
||||||
|
|
||||||
const id = "internal:home-footer"
|
const id = "internal:home-footer"
|
||||||
|
|
||||||
function Directory(props: { api: TuiPluginApi }) {
|
function Directory(props: { api: TuiPluginApi }) {
|
||||||
const theme = () => props.api.theme.current
|
const theme = () => props.api.theme.current
|
||||||
const destination = useHomeSessionDestination()
|
const destination = useHomeSessionDestination()
|
||||||
|
const data = useData()
|
||||||
const paths = useTuiPaths()
|
const paths = useTuiPaths()
|
||||||
const dir = createMemo(() => {
|
const dir = createMemo(() => {
|
||||||
const selected = destination?.destination()
|
const selected = destination?.destination()
|
||||||
if (!selected || selected.type === "new") return
|
const directory = !selected || selected.type === "new" ? data.location.default().directory : selected.directory
|
||||||
const out = abbreviateHome(selected.directory, paths.home)
|
const out = abbreviateHome(directory, paths.home)
|
||||||
const branch =
|
const branch =
|
||||||
selected.directory === (props.api.state.path.directory || paths.cwd) ? props.api.state.vcs?.branch : undefined
|
directory === (props.api.state.path.directory || paths.cwd) ? props.api.state.vcs?.branch : undefined
|
||||||
if (branch) return out + ":" + branch
|
if (branch) return out + ":" + branch
|
||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,7 @@ import {
|
|||||||
type ParentProps,
|
type ParentProps,
|
||||||
type Setter,
|
type Setter,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { useSync } from "../../context/sync"
|
import { useData } from "../../context/data"
|
||||||
import { useTuiPaths } from "../../context/runtime"
|
|
||||||
|
|
||||||
export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" }
|
export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" }
|
||||||
|
|
||||||
@@ -21,11 +20,10 @@ type Context = {
|
|||||||
const HomeSessionDestinationContext = createContext<Context>()
|
const HomeSessionDestinationContext = createContext<Context>()
|
||||||
|
|
||||||
export function HomeSessionDestinationProvider(props: ParentProps) {
|
export function HomeSessionDestinationProvider(props: ParentProps) {
|
||||||
const sync = useSync()
|
const data = useData()
|
||||||
const paths = useTuiPaths()
|
|
||||||
const [selected, setDestination] = createSignal<HomeSessionDestination>()
|
const [selected, setDestination] = createSignal<HomeSessionDestination>()
|
||||||
const destination = createMemo<HomeSessionDestination>(
|
const destination = createMemo<HomeSessionDestination>(
|
||||||
() => selected() ?? { type: "directory", directory: sync.path.directory || paths.cwd, subdirectory: false },
|
() => selected() ?? { type: "directory", directory: data.location.default().directory, subdirectory: false },
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<HomeSessionDestinationContext.Provider
|
<HomeSessionDestinationContext.Provider
|
||||||
|
|||||||
@@ -0,0 +1,433 @@
|
|||||||
|
/** @jsxImportSource @opentui/solid */
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { testRender } from "@opentui/solid"
|
||||||
|
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||||
|
import { onMount } from "solid-js"
|
||||||
|
import { ProjectProvider } from "../../../src/context/project"
|
||||||
|
import { SDKProvider } from "../../../src/context/sdk"
|
||||||
|
import { DataProvider, useData } from "../../../src/context/data"
|
||||||
|
import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
|
||||||
|
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||||
|
|
||||||
|
async function wait(fn: () => boolean, timeout = 2000) {
|
||||||
|
const start = Date.now()
|
||||||
|
while (!fn()) {
|
||||||
|
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||||
|
await Bun.sleep(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function global(payload: Event): GlobalEvent {
|
||||||
|
return { directory, project: "proj_test", payload }
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitEvent(events: ReturnType<typeof createEventSource>, payload: Event) {
|
||||||
|
events.emit(global(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("refreshes resources into reactive getters", async () => {
|
||||||
|
const location = {
|
||||||
|
directory,
|
||||||
|
project: { id: "proj_test", directory },
|
||||||
|
}
|
||||||
|
const calls = createFetch((url) => {
|
||||||
|
if (url.pathname === "/api/session/ses_test")
|
||||||
|
return json({
|
||||||
|
data: {
|
||||||
|
id: "ses_test",
|
||||||
|
projectID: "proj_test",
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: 0, updated: 0 },
|
||||||
|
title: "Test session",
|
||||||
|
location: { directory },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (url.pathname === "/api/agent")
|
||||||
|
return json({
|
||||||
|
location,
|
||||||
|
data: [
|
||||||
|
{ id: "build", request: { headers: {}, body: {} }, mode: "primary", hidden: false, permissions: [] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
const events = createEventSource()
|
||||||
|
let data!: ReturnType<typeof useData>
|
||||||
|
let ready!: () => void
|
||||||
|
const mounted = new Promise<void>((resolve) => {
|
||||||
|
ready = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
data = useData()
|
||||||
|
onMount(ready)
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<TestTuiContexts>
|
||||||
|
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||||
|
<ProjectProvider>
|
||||||
|
<DataProvider>
|
||||||
|
<Probe />
|
||||||
|
</DataProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mounted
|
||||||
|
expect(data.location.default()).toEqual({ directory })
|
||||||
|
expect(data.session.get("ses_test")).toBeUndefined()
|
||||||
|
expect(data.location.agent.list(location)).toBeUndefined()
|
||||||
|
|
||||||
|
await data.session.refresh("ses_test")
|
||||||
|
await data.location.agent.refresh()
|
||||||
|
|
||||||
|
expect(data.session.get("ses_test")?.title).toBe("Test session")
|
||||||
|
expect(data.location.default()).toEqual({ directory, workspaceID: undefined })
|
||||||
|
expect(data.location.agent.list(location)?.map((agent) => agent.id)).toEqual(["build"])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refreshes references after updates", async () => {
|
||||||
|
const events = createEventSource()
|
||||||
|
let requests = 0
|
||||||
|
const calls = createFetch((url) => {
|
||||||
|
if (url.pathname !== "/api/reference") return
|
||||||
|
requests++
|
||||||
|
return json({
|
||||||
|
location: { directory, project: { id: "proj_test", directory } },
|
||||||
|
data: requests === 1 ? [] : [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
let data!: ReturnType<typeof useData>
|
||||||
|
let ready!: () => void
|
||||||
|
const mounted = new Promise<void>((resolve) => {
|
||||||
|
ready = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
data = useData()
|
||||||
|
onMount(ready)
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<TestTuiContexts>
|
||||||
|
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||||
|
<ProjectProvider>
|
||||||
|
<DataProvider>
|
||||||
|
<Probe />
|
||||||
|
</DataProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mounted
|
||||||
|
await wait(() => requests === 1)
|
||||||
|
emitEvent(events, { id: "evt_reference_1", type: "reference.updated", properties: {} })
|
||||||
|
await wait(() => data.location.reference.list()?.length === 1)
|
||||||
|
expect(data.location.reference.list()?.[0]?.name).toBe("docs")
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("settles pending tools when a live failure arrives", async () => {
|
||||||
|
const events = createEventSource()
|
||||||
|
const calls = createFetch()
|
||||||
|
let sync!: ReturnType<typeof useData>
|
||||||
|
let ready!: () => void
|
||||||
|
const mounted = new Promise<void>((resolve) => {
|
||||||
|
ready = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
sync = useData()
|
||||||
|
onMount(ready)
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<TestTuiContexts>
|
||||||
|
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||||
|
<ProjectProvider>
|
||||||
|
<DataProvider>
|
||||||
|
<Probe />
|
||||||
|
</DataProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mounted
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_agent_1",
|
||||||
|
type: "session.next.agent.switched",
|
||||||
|
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
|
||||||
|
})
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_model_1",
|
||||||
|
type: "session.next.model.switched",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: "msg_model_1",
|
||||||
|
timestamp: 0,
|
||||||
|
model: { id: "model-1", providerID: "provider-1" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_step_started_1",
|
||||||
|
type: "session.next.step.started",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
assistantMessageID: "msg_explicit_assistant_9",
|
||||||
|
timestamp: 1,
|
||||||
|
agent: "build",
|
||||||
|
model: { id: "model-1", providerID: "provider-1" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_input_1",
|
||||||
|
type: "session.next.tool.input.started",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
assistantMessageID: "msg_explicit_assistant_9",
|
||||||
|
timestamp: 2,
|
||||||
|
callID: "call-1",
|
||||||
|
name: "bash",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_called_1",
|
||||||
|
type: "session.next.tool.called",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
timestamp: 2,
|
||||||
|
assistantMessageID: "msg_explicit_assistant_9",
|
||||||
|
callID: "call-1",
|
||||||
|
tool: "bash",
|
||||||
|
input: {},
|
||||||
|
provider: { executed: false, metadata: { fake: { call: true } } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_failed_1",
|
||||||
|
type: "session.next.tool.failed",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
timestamp: 3,
|
||||||
|
assistantMessageID: "msg_explicit_assistant_9",
|
||||||
|
callID: "call-1",
|
||||||
|
error: { type: "unknown", message: "aborted" },
|
||||||
|
provider: { executed: false, metadata: { fake: { result: true } } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wait(() => {
|
||||||
|
const assistant = sync.session.message.list("session-1")?.[0]
|
||||||
|
return (
|
||||||
|
assistant?.type === "assistant" &&
|
||||||
|
assistant.content[0]?.type === "tool" &&
|
||||||
|
assistant.content[0].state.status === "error"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const assistant = sync.session.message.list("session-1")?.[0]
|
||||||
|
expect(assistant?.type).toBe("assistant")
|
||||||
|
if (assistant?.type !== "assistant") return
|
||||||
|
expect(assistant.id).toBe("msg_explicit_assistant_9")
|
||||||
|
const tool = assistant.content[0]
|
||||||
|
expect(tool?.type).toBe("tool")
|
||||||
|
if (tool?.type !== "tool") return
|
||||||
|
expect(tool.state.status).toBe("error")
|
||||||
|
if (tool.state.status !== "error") return
|
||||||
|
expect(tool.state.error).toEqual({ type: "unknown", message: "aborted" })
|
||||||
|
expect(tool.state.input).toEqual({})
|
||||||
|
expect(tool.state.structured).toEqual({})
|
||||||
|
expect(tool.state.content).toEqual([])
|
||||||
|
expect(tool.provider).toEqual({
|
||||||
|
executed: false,
|
||||||
|
metadata: { fake: { call: true } },
|
||||||
|
resultMetadata: { fake: { result: true } },
|
||||||
|
})
|
||||||
|
expect((sync.session.message.list("session-1") ?? []).map((message) => message.type)).toEqual([
|
||||||
|
"assistant",
|
||||||
|
"model-switched",
|
||||||
|
"agent-switched",
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("renders admitted prompts only after promotion", async () => {
|
||||||
|
const events = createEventSource()
|
||||||
|
const calls = createFetch()
|
||||||
|
let sync!: ReturnType<typeof useData>
|
||||||
|
let ready!: () => void
|
||||||
|
const mounted = new Promise<void>((resolve) => {
|
||||||
|
ready = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
sync = useData()
|
||||||
|
onMount(ready)
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<TestTuiContexts>
|
||||||
|
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||||
|
<ProjectProvider>
|
||||||
|
<DataProvider>
|
||||||
|
<Probe />
|
||||||
|
</DataProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mounted
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_admitted_1",
|
||||||
|
type: "session.next.prompt.admitted",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: "msg_user_1",
|
||||||
|
timestamp: 0,
|
||||||
|
prompt: { text: "hello" },
|
||||||
|
delivery: "steer",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(sync.session.message.list("session-1") ?? []).toEqual([])
|
||||||
|
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_promoted_1",
|
||||||
|
type: "session.next.prompt.promoted",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: "msg_user_1",
|
||||||
|
timestamp: 1,
|
||||||
|
prompt: { text: "hello" },
|
||||||
|
timeCreated: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wait(() => sync.session.message.list("session-1")?.length === 1)
|
||||||
|
const message = sync.session.message.list("session-1")?.[0]
|
||||||
|
expect(message?.type).toBe("user")
|
||||||
|
if (message?.type !== "user") return
|
||||||
|
expect(message).toMatchObject({ id: "msg_user_1", text: "hello" })
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("renders a promoted prompt when admission was missed", async () => {
|
||||||
|
const events = createEventSource()
|
||||||
|
const calls = createFetch()
|
||||||
|
let sync!: ReturnType<typeof useData>
|
||||||
|
let ready!: () => void
|
||||||
|
const mounted = new Promise<void>((resolve) => {
|
||||||
|
ready = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
sync = useData()
|
||||||
|
onMount(ready)
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<TestTuiContexts>
|
||||||
|
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||||
|
<ProjectProvider>
|
||||||
|
<DataProvider>
|
||||||
|
<Probe />
|
||||||
|
</DataProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mounted
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_promoted_1",
|
||||||
|
type: "session.next.prompt.promoted",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: "msg_user_1",
|
||||||
|
timestamp: 1,
|
||||||
|
prompt: { text: "hello" },
|
||||||
|
timeCreated: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wait(() => sync.session.message.list("session-1")?.length === 1)
|
||||||
|
expect(sync.session.message.list("session-1")?.[0]?.id).toBe("msg_user_1")
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("projects live context updates with their message ID", async () => {
|
||||||
|
const events = createEventSource()
|
||||||
|
const calls = createFetch()
|
||||||
|
let sync!: ReturnType<typeof useData>
|
||||||
|
let ready!: () => void
|
||||||
|
const mounted = new Promise<void>((resolve) => {
|
||||||
|
ready = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
sync = useData()
|
||||||
|
onMount(ready)
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<TestTuiContexts>
|
||||||
|
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||||
|
<ProjectProvider>
|
||||||
|
<DataProvider>
|
||||||
|
<Probe />
|
||||||
|
</DataProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mounted
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_context_1",
|
||||||
|
type: "session.next.context.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: "msg_context_1",
|
||||||
|
timestamp: 1,
|
||||||
|
text: "Updated context",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wait(() => sync.session.message.list("session-1")?.length === 1)
|
||||||
|
expect(sync.session.message.list("session-1")?.[0]).toMatchObject({
|
||||||
|
id: "msg_context_1",
|
||||||
|
type: "system",
|
||||||
|
text: "Updated context",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,619 +0,0 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
|
||||||
import { expect, test } from "bun:test"
|
|
||||||
import { testRender } from "@opentui/solid"
|
|
||||||
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
|
|
||||||
import { onMount } from "solid-js"
|
|
||||||
import { ProjectProvider } from "../../../src/context/project"
|
|
||||||
import { SDKProvider } from "../../../src/context/sdk"
|
|
||||||
import { SyncProviderV2, useSyncV2 } from "../../../src/context/sync-v2"
|
|
||||||
import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
|
|
||||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
|
||||||
|
|
||||||
async function wait(fn: () => boolean, timeout = 2000) {
|
|
||||||
const start = Date.now()
|
|
||||||
while (!fn()) {
|
|
||||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
|
||||||
await Bun.sleep(10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function global(payload: Event): GlobalEvent {
|
|
||||||
return { directory, project: "proj_test", payload }
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitTwice(events: ReturnType<typeof createEventSource>, payload: Event) {
|
|
||||||
const event = global(payload)
|
|
||||||
events.emit(event)
|
|
||||||
events.emit(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("sync v2 refreshes references after updates", async () => {
|
|
||||||
const events = createEventSource()
|
|
||||||
let requests = 0
|
|
||||||
const calls = createFetch((url) => {
|
|
||||||
if (url.pathname !== "/api/reference") return
|
|
||||||
requests++
|
|
||||||
return json({
|
|
||||||
location: { directory, project: { id: "proj_test", directory } },
|
|
||||||
data: requests === 1 ? [] : [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
let sync!: ReturnType<typeof useSyncV2>
|
|
||||||
let ready!: () => void
|
|
||||||
const mounted = new Promise<void>((resolve) => {
|
|
||||||
ready = resolve
|
|
||||||
})
|
|
||||||
|
|
||||||
function Probe() {
|
|
||||||
sync = useSyncV2()
|
|
||||||
onMount(ready)
|
|
||||||
return <box />
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = await testRender(() => (
|
|
||||||
<TestTuiContexts>
|
|
||||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
|
||||||
<ProjectProvider>
|
|
||||||
<SyncProviderV2>
|
|
||||||
<Probe />
|
|
||||||
</SyncProviderV2>
|
|
||||||
</ProjectProvider>
|
|
||||||
</SDKProvider>
|
|
||||||
</TestTuiContexts>
|
|
||||||
))
|
|
||||||
|
|
||||||
try {
|
|
||||||
await mounted
|
|
||||||
await wait(() => requests === 1)
|
|
||||||
events.emit(global({ id: "evt_reference_1", type: "reference.updated", properties: {} }))
|
|
||||||
await wait(() => sync.data.reference.length === 1)
|
|
||||||
expect(sync.data.reference[0]?.name).toBe("docs")
|
|
||||||
} finally {
|
|
||||||
app.renderer.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("sync v2 settles pending tools when a live failure arrives", async () => {
|
|
||||||
const events = createEventSource()
|
|
||||||
const calls = createFetch()
|
|
||||||
let sync!: ReturnType<typeof useSyncV2>
|
|
||||||
let ready!: () => void
|
|
||||||
const mounted = new Promise<void>((resolve) => {
|
|
||||||
ready = resolve
|
|
||||||
})
|
|
||||||
|
|
||||||
function Probe() {
|
|
||||||
sync = useSyncV2()
|
|
||||||
onMount(ready)
|
|
||||||
return <box />
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = await testRender(() => (
|
|
||||||
<TestTuiContexts>
|
|
||||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
|
||||||
<ProjectProvider>
|
|
||||||
<SyncProviderV2>
|
|
||||||
<Probe />
|
|
||||||
</SyncProviderV2>
|
|
||||||
</ProjectProvider>
|
|
||||||
</SDKProvider>
|
|
||||||
</TestTuiContexts>
|
|
||||||
))
|
|
||||||
|
|
||||||
try {
|
|
||||||
await mounted
|
|
||||||
emitTwice(events, {
|
|
||||||
id: "evt_agent_1",
|
|
||||||
type: "session.next.agent.switched",
|
|
||||||
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
|
|
||||||
})
|
|
||||||
emitTwice(events, {
|
|
||||||
id: "evt_model_1",
|
|
||||||
type: "session.next.model.switched",
|
|
||||||
properties: {
|
|
||||||
sessionID: "session-1",
|
|
||||||
messageID: "msg_model_1",
|
|
||||||
timestamp: 0,
|
|
||||||
model: { id: "model-1", providerID: "provider-1" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
emitTwice(events, {
|
|
||||||
id: "evt_step_started_1",
|
|
||||||
type: "session.next.step.started",
|
|
||||||
properties: {
|
|
||||||
sessionID: "session-1",
|
|
||||||
assistantMessageID: "msg_explicit_assistant_9",
|
|
||||||
timestamp: 1,
|
|
||||||
agent: "build",
|
|
||||||
model: { id: "model-1", providerID: "provider-1" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
emitTwice(events, {
|
|
||||||
id: "evt_input_1",
|
|
||||||
type: "session.next.tool.input.started",
|
|
||||||
properties: {
|
|
||||||
sessionID: "session-1",
|
|
||||||
assistantMessageID: "msg_explicit_assistant_9",
|
|
||||||
timestamp: 2,
|
|
||||||
callID: "call-1",
|
|
||||||
name: "bash",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
emitTwice(events, {
|
|
||||||
id: "evt_called_1",
|
|
||||||
type: "session.next.tool.called",
|
|
||||||
properties: {
|
|
||||||
sessionID: "session-1",
|
|
||||||
timestamp: 2,
|
|
||||||
assistantMessageID: "msg_explicit_assistant_9",
|
|
||||||
callID: "call-1",
|
|
||||||
tool: "bash",
|
|
||||||
input: {},
|
|
||||||
provider: { executed: false, metadata: { fake: { call: true } } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
emitTwice(events, {
|
|
||||||
id: "evt_failed_1",
|
|
||||||
type: "session.next.tool.failed",
|
|
||||||
properties: {
|
|
||||||
sessionID: "session-1",
|
|
||||||
timestamp: 3,
|
|
||||||
assistantMessageID: "msg_explicit_assistant_9",
|
|
||||||
callID: "call-1",
|
|
||||||
error: { type: "unknown", message: "aborted" },
|
|
||||||
provider: { executed: false, metadata: { fake: { result: true } } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await wait(() => {
|
|
||||||
const assistant = sync.session.message.fromSession("session-1")[0]
|
|
||||||
return (
|
|
||||||
assistant?.type === "assistant" &&
|
|
||||||
assistant.content[0]?.type === "tool" &&
|
|
||||||
assistant.content[0].state.status === "error"
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const assistant = sync.session.message.fromSession("session-1")[0]
|
|
||||||
expect(assistant?.type).toBe("assistant")
|
|
||||||
if (assistant?.type !== "assistant") return
|
|
||||||
expect(assistant.id).toBe("msg_explicit_assistant_9")
|
|
||||||
const tool = assistant.content[0]
|
|
||||||
expect(tool?.type).toBe("tool")
|
|
||||||
if (tool?.type !== "tool") return
|
|
||||||
expect(tool.state.status).toBe("error")
|
|
||||||
if (tool.state.status !== "error") return
|
|
||||||
expect(tool.state.error).toEqual({ type: "unknown", message: "aborted" })
|
|
||||||
expect(tool.state.input).toEqual({})
|
|
||||||
expect(tool.state.structured).toEqual({})
|
|
||||||
expect(tool.state.content).toEqual([])
|
|
||||||
expect(tool.provider).toEqual({
|
|
||||||
executed: false,
|
|
||||||
metadata: { fake: { call: true } },
|
|
||||||
resultMetadata: { fake: { result: true } },
|
|
||||||
})
|
|
||||||
expect(sync.session.message.fromSession("session-1").map((message) => message.type)).toEqual([
|
|
||||||
"assistant",
|
|
||||||
"model-switched",
|
|
||||||
"agent-switched",
|
|
||||||
])
|
|
||||||
} finally {
|
|
||||||
app.renderer.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("sync v2 renders admitted prompts only after promotion", async () => {
|
|
||||||
const events = createEventSource()
|
|
||||||
const calls = createFetch()
|
|
||||||
let sync!: ReturnType<typeof useSyncV2>
|
|
||||||
let ready!: () => void
|
|
||||||
const mounted = new Promise<void>((resolve) => {
|
|
||||||
ready = resolve
|
|
||||||
})
|
|
||||||
|
|
||||||
function Probe() {
|
|
||||||
sync = useSyncV2()
|
|
||||||
onMount(ready)
|
|
||||||
return <box />
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = await testRender(() => (
|
|
||||||
<TestTuiContexts>
|
|
||||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
|
||||||
<ProjectProvider>
|
|
||||||
<SyncProviderV2>
|
|
||||||
<Probe />
|
|
||||||
</SyncProviderV2>
|
|
||||||
</ProjectProvider>
|
|
||||||
</SDKProvider>
|
|
||||||
</TestTuiContexts>
|
|
||||||
))
|
|
||||||
|
|
||||||
try {
|
|
||||||
await mounted
|
|
||||||
emitTwice(events, {
|
|
||||||
id: "evt_admitted_1",
|
|
||||||
type: "session.next.prompt.admitted",
|
|
||||||
properties: {
|
|
||||||
sessionID: "session-1",
|
|
||||||
messageID: "msg_user_1",
|
|
||||||
timestamp: 0,
|
|
||||||
prompt: { text: "hello" },
|
|
||||||
delivery: "steer",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(sync.session.message.fromSession("session-1")).toEqual([])
|
|
||||||
|
|
||||||
emitTwice(events, {
|
|
||||||
id: "evt_promoted_1",
|
|
||||||
type: "session.next.prompt.promoted",
|
|
||||||
properties: {
|
|
||||||
sessionID: "session-1",
|
|
||||||
messageID: "msg_user_1",
|
|
||||||
timestamp: 1,
|
|
||||||
prompt: { text: "hello" },
|
|
||||||
timeCreated: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await wait(() => sync.session.message.fromSession("session-1").length === 1)
|
|
||||||
const message = sync.session.message.fromSession("session-1")[0]
|
|
||||||
expect(message?.type).toBe("user")
|
|
||||||
if (message?.type !== "user") return
|
|
||||||
expect(message).toMatchObject({ id: "msg_user_1", text: "hello" })
|
|
||||||
} finally {
|
|
||||||
app.renderer.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("sync v2 renders a promoted prompt when admission was missed", async () => {
|
|
||||||
const events = createEventSource()
|
|
||||||
const calls = createFetch()
|
|
||||||
let sync!: ReturnType<typeof useSyncV2>
|
|
||||||
let ready!: () => void
|
|
||||||
const mounted = new Promise<void>((resolve) => {
|
|
||||||
ready = resolve
|
|
||||||
})
|
|
||||||
|
|
||||||
function Probe() {
|
|
||||||
sync = useSyncV2()
|
|
||||||
onMount(ready)
|
|
||||||
return <box />
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = await testRender(() => (
|
|
||||||
<TestTuiContexts>
|
|
||||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
|
||||||
<ProjectProvider>
|
|
||||||
<SyncProviderV2>
|
|
||||||
<Probe />
|
|
||||||
</SyncProviderV2>
|
|
||||||
</ProjectProvider>
|
|
||||||
</SDKProvider>
|
|
||||||
</TestTuiContexts>
|
|
||||||
))
|
|
||||||
|
|
||||||
try {
|
|
||||||
await mounted
|
|
||||||
emitTwice(events, {
|
|
||||||
id: "evt_promoted_1",
|
|
||||||
type: "session.next.prompt.promoted",
|
|
||||||
properties: {
|
|
||||||
sessionID: "session-1",
|
|
||||||
messageID: "msg_user_1",
|
|
||||||
timestamp: 1,
|
|
||||||
prompt: { text: "hello" },
|
|
||||||
timeCreated: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await wait(() => sync.session.message.fromSession("session-1").length === 1)
|
|
||||||
expect(sync.session.message.fromSession("session-1")[0]?.id).toBe("msg_user_1")
|
|
||||||
} finally {
|
|
||||||
app.renderer.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("sync v2 projects live context updates with their message ID", async () => {
|
|
||||||
const events = createEventSource()
|
|
||||||
const calls = createFetch()
|
|
||||||
let sync!: ReturnType<typeof useSyncV2>
|
|
||||||
let ready!: () => void
|
|
||||||
const mounted = new Promise<void>((resolve) => {
|
|
||||||
ready = resolve
|
|
||||||
})
|
|
||||||
|
|
||||||
function Probe() {
|
|
||||||
sync = useSyncV2()
|
|
||||||
onMount(ready)
|
|
||||||
return <box />
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = await testRender(() => (
|
|
||||||
<TestTuiContexts>
|
|
||||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
|
||||||
<ProjectProvider>
|
|
||||||
<SyncProviderV2>
|
|
||||||
<Probe />
|
|
||||||
</SyncProviderV2>
|
|
||||||
</ProjectProvider>
|
|
||||||
</SDKProvider>
|
|
||||||
</TestTuiContexts>
|
|
||||||
))
|
|
||||||
|
|
||||||
try {
|
|
||||||
await mounted
|
|
||||||
emitTwice(events, {
|
|
||||||
id: "evt_context_1",
|
|
||||||
type: "session.next.context.updated",
|
|
||||||
properties: {
|
|
||||||
sessionID: "session-1",
|
|
||||||
messageID: "msg_context_1",
|
|
||||||
timestamp: 1,
|
|
||||||
text: "Updated context",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await wait(() => sync.session.message.fromSession("session-1").length === 1)
|
|
||||||
expect(sync.session.message.fromSession("session-1")[0]).toMatchObject({
|
|
||||||
id: "msg_context_1",
|
|
||||||
type: "system",
|
|
||||||
text: "Updated context",
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
app.renderer.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("sync v2 preserves live events while snapshot hydration is in flight", async () => {
|
|
||||||
const events = createEventSource()
|
|
||||||
const response = Promise.withResolvers<Response>()
|
|
||||||
const calls = createFetch((url) => {
|
|
||||||
if (url.pathname === "/api/session/session-1/message") return response.promise
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
let sync!: ReturnType<typeof useSyncV2>
|
|
||||||
let ready!: () => void
|
|
||||||
const mounted = new Promise<void>((resolve) => {
|
|
||||||
ready = resolve
|
|
||||||
})
|
|
||||||
|
|
||||||
function Probe() {
|
|
||||||
sync = useSyncV2()
|
|
||||||
onMount(ready)
|
|
||||||
return <box />
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = await testRender(() => (
|
|
||||||
<TestTuiContexts>
|
|
||||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
|
||||||
<ProjectProvider>
|
|
||||||
<SyncProviderV2>
|
|
||||||
<Probe />
|
|
||||||
</SyncProviderV2>
|
|
||||||
</ProjectProvider>
|
|
||||||
</SDKProvider>
|
|
||||||
</TestTuiContexts>
|
|
||||||
))
|
|
||||||
|
|
||||||
try {
|
|
||||||
await mounted
|
|
||||||
const hydration = sync.session.message.sync("session-1")
|
|
||||||
emitTwice(events, {
|
|
||||||
id: "evt_agent_1",
|
|
||||||
type: "session.next.agent.switched",
|
|
||||||
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
|
|
||||||
})
|
|
||||||
response.resolve(json({ data: [] }))
|
|
||||||
await hydration
|
|
||||||
|
|
||||||
expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([
|
|
||||||
["msg_agent_1", "agent-switched"],
|
|
||||||
])
|
|
||||||
} finally {
|
|
||||||
app.renderer.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("sync v2 replaces stale cached rows while preserving in-flight live rows", async () => {
|
|
||||||
const events = createEventSource()
|
|
||||||
const response = Promise.withResolvers<Response>()
|
|
||||||
const calls = createFetch((url) => {
|
|
||||||
if (url.pathname === "/api/session/session-1/message") return response.promise
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
let sync!: ReturnType<typeof useSyncV2>
|
|
||||||
let ready!: () => void
|
|
||||||
const mounted = new Promise<void>((resolve) => {
|
|
||||||
ready = resolve
|
|
||||||
})
|
|
||||||
|
|
||||||
function Probe() {
|
|
||||||
sync = useSyncV2()
|
|
||||||
onMount(ready)
|
|
||||||
return <box />
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = await testRender(() => (
|
|
||||||
<TestTuiContexts>
|
|
||||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
|
||||||
<ProjectProvider>
|
|
||||||
<SyncProviderV2>
|
|
||||||
<Probe />
|
|
||||||
</SyncProviderV2>
|
|
||||||
</ProjectProvider>
|
|
||||||
</SDKProvider>
|
|
||||||
</TestTuiContexts>
|
|
||||||
))
|
|
||||||
|
|
||||||
try {
|
|
||||||
await mounted
|
|
||||||
emitTwice(events, {
|
|
||||||
id: "evt_promoted_1",
|
|
||||||
type: "session.next.prompt.promoted",
|
|
||||||
properties: {
|
|
||||||
sessionID: "session-1",
|
|
||||||
messageID: "msg_user_1",
|
|
||||||
timestamp: 1,
|
|
||||||
prompt: { text: "stale" },
|
|
||||||
timeCreated: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_user_1")
|
|
||||||
const hydration = sync.session.message.sync("session-1")
|
|
||||||
emitTwice(events, {
|
|
||||||
id: "evt_agent_1",
|
|
||||||
type: "session.next.agent.switched",
|
|
||||||
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 2, agent: "build" },
|
|
||||||
})
|
|
||||||
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_agent_1")
|
|
||||||
response.resolve(
|
|
||||||
json({
|
|
||||||
data: [{ id: "msg_user_1", type: "user", text: "fresh", time: { created: 0 } }],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
await hydration
|
|
||||||
|
|
||||||
expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([
|
|
||||||
["msg_agent_1", "agent-switched"],
|
|
||||||
["msg_user_1", "user"],
|
|
||||||
])
|
|
||||||
expect(sync.session.message.fromSession("session-1")[1]).toMatchObject({ text: "fresh" })
|
|
||||||
} finally {
|
|
||||||
app.renderer.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("sync v2 preserves snapshot order and metadata for in-flight updates", async () => {
|
|
||||||
const events = createEventSource()
|
|
||||||
const response = Promise.withResolvers<Response>()
|
|
||||||
const calls = createFetch((url) => {
|
|
||||||
if (url.pathname === "/api/session/session-1/message") return response.promise
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
let sync!: ReturnType<typeof useSyncV2>
|
|
||||||
let ready!: () => void
|
|
||||||
const mounted = new Promise<void>((resolve) => {
|
|
||||||
ready = resolve
|
|
||||||
})
|
|
||||||
|
|
||||||
function Probe() {
|
|
||||||
sync = useSyncV2()
|
|
||||||
onMount(ready)
|
|
||||||
return <box />
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = await testRender(() => (
|
|
||||||
<TestTuiContexts>
|
|
||||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
|
||||||
<ProjectProvider>
|
|
||||||
<SyncProviderV2>
|
|
||||||
<Probe />
|
|
||||||
</SyncProviderV2>
|
|
||||||
</ProjectProvider>
|
|
||||||
</SDKProvider>
|
|
||||||
</TestTuiContexts>
|
|
||||||
))
|
|
||||||
|
|
||||||
try {
|
|
||||||
await mounted
|
|
||||||
emitTwice(events, {
|
|
||||||
id: "evt_step_older",
|
|
||||||
type: "session.next.step.started",
|
|
||||||
properties: {
|
|
||||||
sessionID: "session-1",
|
|
||||||
assistantMessageID: "msg_assistant_older",
|
|
||||||
timestamp: 0,
|
|
||||||
agent: "build",
|
|
||||||
model: { id: "model", providerID: "provider" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
emitTwice(events, {
|
|
||||||
id: "evt_step_1",
|
|
||||||
type: "session.next.step.started",
|
|
||||||
properties: {
|
|
||||||
sessionID: "session-1",
|
|
||||||
assistantMessageID: "msg_assistant_old",
|
|
||||||
timestamp: 1,
|
|
||||||
agent: "build",
|
|
||||||
model: { id: "model", providerID: "provider" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_assistant_old")
|
|
||||||
const hydration = sync.session.message.sync("session-1")
|
|
||||||
emitTwice(events, {
|
|
||||||
id: "evt_text_1",
|
|
||||||
type: "session.next.text.started",
|
|
||||||
properties: {
|
|
||||||
sessionID: "session-1",
|
|
||||||
assistantMessageID: "msg_assistant_old",
|
|
||||||
timestamp: 2,
|
|
||||||
textID: "text-1",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
emitTwice(events, {
|
|
||||||
id: "evt_text_older",
|
|
||||||
type: "session.next.text.started",
|
|
||||||
properties: {
|
|
||||||
sessionID: "session-1",
|
|
||||||
assistantMessageID: "msg_assistant_older",
|
|
||||||
timestamp: 2,
|
|
||||||
textID: "text-older",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await wait(() => {
|
|
||||||
const messages = sync.session.message.fromSession("session-1")
|
|
||||||
return messages.every((message) => message.type !== "assistant" || message.content[0]?.type === "text")
|
|
||||||
})
|
|
||||||
response.resolve(
|
|
||||||
json({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: "msg_assistant_new",
|
|
||||||
type: "assistant",
|
|
||||||
agent: "build",
|
|
||||||
model: { id: "model", providerID: "provider" },
|
|
||||||
content: [],
|
|
||||||
time: { created: 3 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "msg_assistant_old",
|
|
||||||
type: "assistant",
|
|
||||||
metadata: { source: "snapshot" },
|
|
||||||
agent: "build",
|
|
||||||
model: { id: "model", providerID: "provider" },
|
|
||||||
content: [],
|
|
||||||
time: { created: 1 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
await hydration
|
|
||||||
emitTwice(events, {
|
|
||||||
id: "evt_step_late_duplicate",
|
|
||||||
type: "session.next.step.started",
|
|
||||||
properties: {
|
|
||||||
sessionID: "session-1",
|
|
||||||
assistantMessageID: "msg_assistant_old",
|
|
||||||
timestamp: 1,
|
|
||||||
agent: "build",
|
|
||||||
model: { id: "model", providerID: "provider" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(sync.session.message.fromSession("session-1").map((message) => message.id)).toEqual([
|
|
||||||
"msg_assistant_new",
|
|
||||||
"msg_assistant_old",
|
|
||||||
"msg_assistant_older",
|
|
||||||
])
|
|
||||||
expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[1]))).toMatchObject({
|
|
||||||
metadata: { source: "snapshot" },
|
|
||||||
content: [{ type: "text", id: "text-1", text: "" }],
|
|
||||||
})
|
|
||||||
expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[2]))).toMatchObject({
|
|
||||||
content: [{ type: "text", id: "text-older", text: "" }],
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
app.renderer.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -59,6 +59,13 @@ export function createFetch(override?: FetchHandler) {
|
|||||||
if (url.pathname === "/config/providers") return json({ providers: {}, default: {} })
|
if (url.pathname === "/config/providers") return json({ providers: {}, default: {} })
|
||||||
if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
|
if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
|
||||||
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
|
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
|
||||||
|
if (url.pathname === "/api/location")
|
||||||
|
return json({ directory, project: { id: "proj_test", directory: worktree } })
|
||||||
|
if (["/api/agent", "/api/model", "/api/provider", "/api/command", "/api/skill"].includes(url.pathname))
|
||||||
|
return json({
|
||||||
|
location: { directory, project: { id: "proj_test", directory: worktree } },
|
||||||
|
data: [],
|
||||||
|
})
|
||||||
if (url.pathname === "/project/current") return json({ id: "proj_test" })
|
if (url.pathname === "/project/current") return json({ id: "proj_test" })
|
||||||
if (url.pathname === "/api/reference")
|
if (url.pathname === "/api/reference")
|
||||||
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
|
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
|
||||||
|
|||||||
Reference in New Issue
Block a user