mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Some cleanup in ExtensionHost (#10600)
Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
parent
fc21101a99
commit
a33117a83f
19 changed files with 700 additions and 833 deletions
|
|
@ -180,12 +180,13 @@ function isStreaming(messages) {
|
|||
|
||||
### ExtensionClient
|
||||
|
||||
The **single source of truth** for agent state. It:
|
||||
The **single source of truth** for agent state, including the current mode. It:
|
||||
|
||||
- Receives all messages from the extension
|
||||
- Stores them in the `StateStore`
|
||||
- Tracks the current mode from state messages
|
||||
- Computes the current state via `detectAgentState()`
|
||||
- Emits events when state changes
|
||||
- Emits events when state changes (including mode changes)
|
||||
|
||||
```typescript
|
||||
const client = new ExtensionClient({
|
||||
|
|
@ -199,21 +200,31 @@ if (state.isWaitingForInput) {
|
|||
console.log(`Agent needs: ${state.currentAsk}`)
|
||||
}
|
||||
|
||||
// Query current mode
|
||||
const mode = client.getCurrentMode()
|
||||
console.log(`Current mode: ${mode}`) // e.g., "code", "architect", "ask"
|
||||
|
||||
// Subscribe to events
|
||||
client.on("waitingForInput", (event) => {
|
||||
console.log(`Waiting for: ${event.ask}`)
|
||||
})
|
||||
|
||||
// Subscribe to mode changes
|
||||
client.on("modeChanged", (event) => {
|
||||
console.log(`Mode changed: ${event.previousMode} -> ${event.currentMode}`)
|
||||
})
|
||||
```
|
||||
|
||||
### StateStore
|
||||
|
||||
Holds the `clineMessages` array and computed state:
|
||||
Holds the `clineMessages` array, computed state, and current mode:
|
||||
|
||||
```typescript
|
||||
interface StoreState {
|
||||
messages: ClineMessage[] // The raw message array
|
||||
agentState: AgentStateInfo // Computed state
|
||||
isInitialized: boolean // Have we received any state?
|
||||
currentMode: string | undefined // Current mode (e.g., "code", "architect")
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -221,9 +232,9 @@ interface StoreState {
|
|||
|
||||
Handles incoming messages from the extension:
|
||||
|
||||
- `"state"` messages → Update `clineMessages` array
|
||||
- `"state"` messages → Update `clineMessages` array and track mode
|
||||
- `"messageUpdated"` messages → Update single message in array
|
||||
- Emits events for state transitions
|
||||
- Emits events for state transitions and mode changes
|
||||
|
||||
### AskDispatcher
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
"commander": "^12.1.0",
|
||||
"fuzzysort": "^3.1.0",
|
||||
"ink": "^6.6.0",
|
||||
"p-wait-for": "^5.0.2",
|
||||
"react": "^19.1.0",
|
||||
"superjson": "^2.2.6",
|
||||
"zustand": "^5.0.0"
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ function createMessage(overrides: Partial<ClineMessage>): ClineMessage {
|
|||
return { ts: Date.now() + Math.random() * 1000, type: "say", ...overrides }
|
||||
}
|
||||
|
||||
function createStateMessage(messages: ClineMessage[]): ExtensionMessage {
|
||||
return { type: "state", state: { clineMessages: messages } } as ExtensionMessage
|
||||
function createStateMessage(messages: ClineMessage[], mode?: string): ExtensionMessage {
|
||||
return { type: "state", state: { clineMessages: messages, mode } } as ExtensionMessage
|
||||
}
|
||||
|
||||
describe("detectAgentState", () => {
|
||||
|
|
@ -300,6 +300,44 @@ describe("ExtensionClient", () => {
|
|||
client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })]))
|
||||
expect(callCount).toBe(1) // Should not increase.
|
||||
})
|
||||
|
||||
it("should emit modeChanged events", () => {
|
||||
const { client } = createMockClient()
|
||||
const modeChanges: { previousMode: string | undefined; currentMode: string }[] = []
|
||||
|
||||
client.onModeChanged((event) => {
|
||||
modeChanges.push(event)
|
||||
})
|
||||
|
||||
// Set initial mode
|
||||
client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code"))
|
||||
|
||||
expect(modeChanges).toHaveLength(1)
|
||||
expect(modeChanges[0]).toEqual({ previousMode: undefined, currentMode: "code" })
|
||||
|
||||
// Change mode
|
||||
client.handleMessage(createStateMessage([createMessage({ say: "text" })], "architect"))
|
||||
|
||||
expect(modeChanges).toHaveLength(2)
|
||||
expect(modeChanges[1]).toEqual({ previousMode: "code", currentMode: "architect" })
|
||||
})
|
||||
|
||||
it("should not emit modeChanged when mode stays the same", () => {
|
||||
const { client } = createMockClient()
|
||||
let modeChangeCount = 0
|
||||
|
||||
client.onModeChanged(() => {
|
||||
modeChangeCount++
|
||||
})
|
||||
|
||||
// Set initial mode
|
||||
client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code"))
|
||||
expect(modeChangeCount).toBe(1)
|
||||
|
||||
// Same mode - should not emit
|
||||
client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })], "code"))
|
||||
expect(modeChangeCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Response methods", () => {
|
||||
|
|
@ -458,6 +496,65 @@ describe("ExtensionClient", () => {
|
|||
expect(client.isInitialized()).toBe(false)
|
||||
expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
|
||||
})
|
||||
|
||||
it("should reset mode on reset", () => {
|
||||
const { client } = createMockClient()
|
||||
|
||||
client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code"))
|
||||
expect(client.getCurrentMode()).toBe("code")
|
||||
|
||||
client.reset()
|
||||
|
||||
expect(client.getCurrentMode()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Mode tracking", () => {
|
||||
it("should return undefined mode when not initialized", () => {
|
||||
const { client } = createMockClient()
|
||||
expect(client.getCurrentMode()).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should track mode from state messages", () => {
|
||||
const { client } = createMockClient()
|
||||
|
||||
client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code"))
|
||||
|
||||
expect(client.getCurrentMode()).toBe("code")
|
||||
})
|
||||
|
||||
it("should update mode when it changes", () => {
|
||||
const { client } = createMockClient()
|
||||
|
||||
client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code"))
|
||||
expect(client.getCurrentMode()).toBe("code")
|
||||
|
||||
client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })], "architect"))
|
||||
expect(client.getCurrentMode()).toBe("architect")
|
||||
})
|
||||
|
||||
it("should preserve mode when state message has no mode", () => {
|
||||
const { client } = createMockClient()
|
||||
|
||||
// Set initial mode
|
||||
client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code"))
|
||||
expect(client.getCurrentMode()).toBe("code")
|
||||
|
||||
// State update without mode - should preserve existing mode
|
||||
client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })]))
|
||||
expect(client.getCurrentMode()).toBe("code")
|
||||
})
|
||||
|
||||
it("should preserve mode when task is cleared", () => {
|
||||
const { client } = createMockClient()
|
||||
|
||||
client.handleMessage(createStateMessage([createMessage({ say: "text" })], "architect"))
|
||||
expect(client.getCurrentMode()).toBe("architect")
|
||||
|
||||
client.clearTask()
|
||||
// Mode should be preserved after clear
|
||||
expect(client.getCurrentMode()).toBe("architect")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
import { EventEmitter } from "events"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
|
||||
import type { WebviewMessage } from "@roo-code/types"
|
||||
import type { ExtensionMessage, WebviewMessage } from "@roo-code/types"
|
||||
|
||||
import { type ExtensionHostOptions, ExtensionHost } from "../extension-host.js"
|
||||
import { ExtensionClient } from "../extension-client.js"
|
||||
import { AgentLoopState } from "../agent-state.js"
|
||||
|
||||
vi.mock("@roo-code/vscode-shim", () => ({
|
||||
createVSCodeAPI: vi.fn(() => ({
|
||||
|
|
@ -16,6 +16,10 @@ vi.mock("@roo-code/vscode-shim", () => ({
|
|||
setRuntimeConfigValues: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/lib/storage/index.js", () => ({
|
||||
createEphemeralStorageDir: vi.fn(() => Promise.resolve("/tmp/roo-cli-test-ephemeral")),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Create a test ExtensionHost with default options.
|
||||
*/
|
||||
|
|
@ -46,8 +50,16 @@ function getPrivate<T>(host: ExtensionHost, key: string): T {
|
|||
return (host as unknown as PrivateHost)[key] as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to set private members for testing
|
||||
*/
|
||||
function setPrivate(host: ExtensionHost, key: string, value: unknown): void {
|
||||
;(host as unknown as PrivateHost)[key] = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to call private methods for testing
|
||||
* This uses a more permissive type to avoid TypeScript errors with private methods
|
||||
*/
|
||||
function callPrivate<T>(host: ExtensionHost, method: string, ...args: unknown[]): T {
|
||||
const fn = (host as unknown as PrivateHost)[method] as ((...a: unknown[]) => T) | undefined
|
||||
|
|
@ -86,7 +98,12 @@ describe("ExtensionHost", () => {
|
|||
|
||||
const host = new ExtensionHost(options)
|
||||
|
||||
expect(getPrivate(host, "options")).toEqual(options)
|
||||
// Options are stored but integrationTest is set to true
|
||||
const storedOptions = getPrivate<ExtensionHostOptions>(host, "options")
|
||||
expect(storedOptions.mode).toBe(options.mode)
|
||||
expect(storedOptions.workspacePath).toBe(options.workspacePath)
|
||||
expect(storedOptions.extensionPath).toBe(options.extensionPath)
|
||||
expect(storedOptions.integrationTest).toBe(true) // Always set to true in constructor
|
||||
})
|
||||
|
||||
it("should be an EventEmitter instance", () => {
|
||||
|
|
@ -97,8 +114,7 @@ describe("ExtensionHost", () => {
|
|||
it("should initialize with default state values", () => {
|
||||
const host = createTestHost()
|
||||
|
||||
expect(getPrivate(host, "isWebviewReady")).toBe(false)
|
||||
expect(getPrivate<unknown[]>(host, "pendingMessages")).toEqual([])
|
||||
expect(getPrivate(host, "isReady")).toBe(false)
|
||||
expect(getPrivate(host, "vscode")).toBeNull()
|
||||
expect(getPrivate(host, "extensionModule")).toBeNull()
|
||||
})
|
||||
|
|
@ -115,25 +131,26 @@ describe("ExtensionHost", () => {
|
|||
})
|
||||
|
||||
describe("webview provider registration", () => {
|
||||
it("should register webview provider", () => {
|
||||
it("should register webview provider without throwing", () => {
|
||||
const host = createTestHost()
|
||||
const mockProvider = { resolveWebviewView: vi.fn() }
|
||||
|
||||
host.registerWebviewProvider("test-view", mockProvider)
|
||||
|
||||
const providers = getPrivate<Map<string, unknown>>(host, "webviewProviders")
|
||||
expect(providers.get("test-view")).toBe(mockProvider)
|
||||
// registerWebviewProvider is now a no-op, just ensure it doesn't throw
|
||||
expect(() => {
|
||||
host.registerWebviewProvider("test-view", mockProvider)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it("should unregister webview provider", () => {
|
||||
it("should unregister webview provider without throwing", () => {
|
||||
const host = createTestHost()
|
||||
const mockProvider = { resolveWebviewView: vi.fn() }
|
||||
|
||||
host.registerWebviewProvider("test-view", mockProvider)
|
||||
host.unregisterWebviewProvider("test-view")
|
||||
|
||||
const providers = getPrivate<Map<string, unknown>>(host, "webviewProviders")
|
||||
expect(providers.has("test-view")).toBe(false)
|
||||
// unregisterWebviewProvider is now a no-op, just ensure it doesn't throw
|
||||
expect(() => {
|
||||
host.unregisterWebviewProvider("test-view")
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it("should handle unregistering non-existent provider gracefully", () => {
|
||||
|
|
@ -160,49 +177,48 @@ describe("ExtensionHost", () => {
|
|||
})
|
||||
|
||||
describe("markWebviewReady", () => {
|
||||
it("should set isWebviewReady to true", () => {
|
||||
it("should set isReady to true", () => {
|
||||
const host = createTestHost()
|
||||
host.markWebviewReady()
|
||||
expect(getPrivate(host, "isWebviewReady")).toBe(true)
|
||||
expect(getPrivate(host, "isReady")).toBe(true)
|
||||
})
|
||||
|
||||
it("should emit webviewReady event", () => {
|
||||
const host = createTestHost()
|
||||
const listener = vi.fn()
|
||||
|
||||
host.on("webviewReady", listener)
|
||||
host.markWebviewReady()
|
||||
|
||||
expect(listener).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should flush pending messages", () => {
|
||||
it("should send webviewDidLaunch message", () => {
|
||||
const host = createTestHost()
|
||||
const emitSpy = vi.spyOn(host, "emit")
|
||||
|
||||
// Queue messages before ready
|
||||
host.sendToExtension({ type: "requestModes" })
|
||||
host.sendToExtension({ type: "requestCommands" })
|
||||
|
||||
// Mark ready (should flush)
|
||||
host.markWebviewReady()
|
||||
|
||||
// Check that webviewMessage events were emitted for pending messages
|
||||
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "requestModes" })
|
||||
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "requestCommands" })
|
||||
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "webviewDidLaunch" })
|
||||
})
|
||||
|
||||
it("should send updateSettings message", () => {
|
||||
const host = createTestHost()
|
||||
const emitSpy = vi.spyOn(host, "emit")
|
||||
|
||||
host.markWebviewReady()
|
||||
|
||||
// Check that updateSettings was called
|
||||
const updateSettingsCall = emitSpy.mock.calls.find(
|
||||
(call) =>
|
||||
call[0] === "webviewMessage" &&
|
||||
typeof call[1] === "object" &&
|
||||
call[1] !== null &&
|
||||
(call[1] as WebviewMessage).type === "updateSettings",
|
||||
)
|
||||
expect(updateSettingsCall).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("sendToExtension", () => {
|
||||
it("should queue message when webview not ready", () => {
|
||||
it("should throw error when extension not ready", () => {
|
||||
const host = createTestHost()
|
||||
const message: WebviewMessage = { type: "requestModes" }
|
||||
|
||||
host.sendToExtension(message)
|
||||
|
||||
const pending = getPrivate<unknown[]>(host, "pendingMessages")
|
||||
expect(pending).toContain(message)
|
||||
expect(() => {
|
||||
host.sendToExtension(message)
|
||||
}).toThrow("You cannot send messages to the extension before it is ready")
|
||||
})
|
||||
|
||||
it("should emit webviewMessage event when webview is ready", () => {
|
||||
|
|
@ -211,51 +227,37 @@ describe("ExtensionHost", () => {
|
|||
const message: WebviewMessage = { type: "requestModes" }
|
||||
|
||||
host.markWebviewReady()
|
||||
emitSpy.mockClear() // Clear the markWebviewReady calls
|
||||
host.sendToExtension(message)
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", message)
|
||||
})
|
||||
|
||||
it("should not queue message when webview is ready", () => {
|
||||
it("should not throw when webview is ready", () => {
|
||||
const host = createTestHost()
|
||||
|
||||
host.markWebviewReady()
|
||||
host.sendToExtension({ type: "requestModes" })
|
||||
|
||||
const pending = getPrivate<unknown[]>(host, "pendingMessages")
|
||||
expect(pending).toHaveLength(0)
|
||||
expect(() => {
|
||||
host.sendToExtension({ type: "requestModes" })
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleExtensionMessage", () => {
|
||||
it("should forward messages to the client", () => {
|
||||
describe("message handling via client", () => {
|
||||
it("should forward extension messages to the client", () => {
|
||||
const host = createTestHost()
|
||||
const client = host.getExtensionClient()
|
||||
const handleMessageSpy = vi.spyOn(client, "handleMessage")
|
||||
const client = getPrivate(host, "client") as ExtensionClient
|
||||
|
||||
callPrivate(host, "handleExtensionMessage", { type: "state", state: { clineMessages: [] } })
|
||||
|
||||
expect(handleMessageSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should track mode from state messages", () => {
|
||||
const host = createTestHost()
|
||||
|
||||
callPrivate(host, "handleExtensionMessage", {
|
||||
// Simulate extension message.
|
||||
host.emit("extensionWebviewMessage", {
|
||||
type: "state",
|
||||
state: { mode: "architect", clineMessages: [] },
|
||||
})
|
||||
state: { clineMessages: [] },
|
||||
} as unknown as ExtensionMessage)
|
||||
|
||||
expect(getPrivate(host, "currentMode")).toBe("architect")
|
||||
})
|
||||
|
||||
it("should emit modesUpdated for modes messages", () => {
|
||||
const host = createTestHost()
|
||||
const emitSpy = vi.spyOn(host, "emit")
|
||||
|
||||
callPrivate(host, "handleExtensionMessage", { type: "modes", modes: [] })
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith("modesUpdated", { type: "modes", modes: [] })
|
||||
// Message listener is set up in activate(), which we can't easily call in unit tests.
|
||||
// But we can verify the client exists and has the handleMessage method.
|
||||
expect(typeof client.handleMessage).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -274,94 +276,63 @@ describe("ExtensionHost", () => {
|
|||
const host = createTestHost()
|
||||
expect(typeof host.isWaitingForInput()).toBe("boolean")
|
||||
})
|
||||
|
||||
it("should return isAgentRunning() status", () => {
|
||||
const host = createTestHost()
|
||||
expect(typeof host.isAgentRunning()).toBe("boolean")
|
||||
})
|
||||
|
||||
it("should return the client from getExtensionClient()", () => {
|
||||
const host = createTestHost()
|
||||
const client = host.getExtensionClient()
|
||||
|
||||
expect(client).toBeDefined()
|
||||
expect(typeof client.handleMessage).toBe("function")
|
||||
})
|
||||
|
||||
it("should return the output manager from getOutputManager()", () => {
|
||||
const host = createTestHost()
|
||||
const outputManager = host.getOutputManager()
|
||||
|
||||
expect(outputManager).toBeDefined()
|
||||
expect(typeof outputManager.output).toBe("function")
|
||||
})
|
||||
|
||||
it("should return the prompt manager from getPromptManager()", () => {
|
||||
const host = createTestHost()
|
||||
const promptManager = host.getPromptManager()
|
||||
|
||||
expect(promptManager).toBeDefined()
|
||||
})
|
||||
|
||||
it("should return the ask dispatcher from getAskDispatcher()", () => {
|
||||
const host = createTestHost()
|
||||
const askDispatcher = host.getAskDispatcher()
|
||||
|
||||
expect(askDispatcher).toBeDefined()
|
||||
expect(typeof askDispatcher.handleAsk).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("quiet mode", () => {
|
||||
describe("setupQuietMode", () => {
|
||||
it("should suppress console.log, warn, debug, info when enabled", () => {
|
||||
it("should not modify console when integrationTest is true", () => {
|
||||
// By default, constructor sets integrationTest = true
|
||||
const host = createTestHost()
|
||||
const originalLog = console.log
|
||||
|
||||
callPrivate(host, "setupQuietMode")
|
||||
|
||||
// These should be no-ops now (different from original)
|
||||
expect(console.log).not.toBe(originalLog)
|
||||
// Console should not be modified since integrationTest is true
|
||||
expect(console.log).toBe(originalLog)
|
||||
})
|
||||
|
||||
// Verify they are actually no-ops by calling them (should not throw)
|
||||
expect(() => console.log("test")).not.toThrow()
|
||||
expect(() => console.warn("test")).not.toThrow()
|
||||
expect(() => console.debug("test")).not.toThrow()
|
||||
expect(() => console.info("test")).not.toThrow()
|
||||
it("should suppress console when integrationTest is false", () => {
|
||||
const host = createTestHost()
|
||||
const originalLog = console.log
|
||||
|
||||
// Override integrationTest to false
|
||||
const options = getPrivate<ExtensionHostOptions>(host, "options")
|
||||
options.integrationTest = false
|
||||
|
||||
callPrivate(host, "setupQuietMode")
|
||||
|
||||
// Console should be modified
|
||||
expect(console.log).not.toBe(originalLog)
|
||||
|
||||
// Restore for other tests
|
||||
callPrivate(host, "restoreConsole")
|
||||
})
|
||||
|
||||
it("should preserve console.error", () => {
|
||||
it("should preserve console.error even when suppressing", () => {
|
||||
const host = createTestHost()
|
||||
const originalError = console.error
|
||||
|
||||
// Override integrationTest to false
|
||||
const options = getPrivate<ExtensionHostOptions>(host, "options")
|
||||
options.integrationTest = false
|
||||
|
||||
callPrivate(host, "setupQuietMode")
|
||||
|
||||
expect(console.error).toBe(originalError)
|
||||
|
||||
callPrivate(host, "restoreConsole")
|
||||
})
|
||||
|
||||
it("should store original console methods", () => {
|
||||
const host = createTestHost()
|
||||
const originalLog = console.log
|
||||
|
||||
callPrivate(host, "setupQuietMode")
|
||||
|
||||
const stored = getPrivate<{ log: typeof console.log }>(host, "originalConsole")
|
||||
expect(stored.log).toBe(originalLog)
|
||||
|
||||
callPrivate(host, "restoreConsole")
|
||||
})
|
||||
})
|
||||
|
||||
describe("restoreConsole", () => {
|
||||
it("should restore original console methods", () => {
|
||||
it("should restore original console methods when suppressed", () => {
|
||||
const host = createTestHost()
|
||||
const originalLog = console.log
|
||||
|
||||
// Override integrationTest to false to actually suppress
|
||||
const options = getPrivate<ExtensionHostOptions>(host, "options")
|
||||
options.integrationTest = false
|
||||
|
||||
callPrivate(host, "setupQuietMode")
|
||||
callPrivate(host, "restoreConsole")
|
||||
|
||||
|
|
@ -376,20 +347,6 @@ describe("ExtensionHost", () => {
|
|||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("suppressNodeWarnings", () => {
|
||||
it("should suppress process.emitWarning", () => {
|
||||
const host = createTestHost()
|
||||
const originalEmitWarning = process.emitWarning
|
||||
|
||||
callPrivate(host, "suppressNodeWarnings")
|
||||
|
||||
expect(process.emitWarning).not.toBe(originalEmitWarning)
|
||||
|
||||
// Restore
|
||||
callPrivate(host, "restoreConsole")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("dispose", () => {
|
||||
|
|
@ -401,7 +358,7 @@ describe("ExtensionHost", () => {
|
|||
|
||||
it("should remove message listener", async () => {
|
||||
const listener = vi.fn()
|
||||
;(host as unknown as Record<string, unknown>).messageListener = listener
|
||||
setPrivate(host, "messageListener", listener)
|
||||
host.on("extensionWebviewMessage", listener)
|
||||
|
||||
await host.dispose()
|
||||
|
|
@ -411,9 +368,9 @@ describe("ExtensionHost", () => {
|
|||
|
||||
it("should call extension deactivate if available", async () => {
|
||||
const deactivateMock = vi.fn()
|
||||
;(host as unknown as Record<string, unknown>).extensionModule = {
|
||||
setPrivate(host, "extensionModule", {
|
||||
deactivate: deactivateMock,
|
||||
}
|
||||
})
|
||||
|
||||
await host.dispose()
|
||||
|
||||
|
|
@ -421,7 +378,7 @@ describe("ExtensionHost", () => {
|
|||
})
|
||||
|
||||
it("should clear vscode reference", async () => {
|
||||
;(host as unknown as Record<string, unknown>).vscode = { context: {} }
|
||||
setPrivate(host, "vscode", { context: {} })
|
||||
|
||||
await host.dispose()
|
||||
|
||||
|
|
@ -429,22 +386,13 @@ describe("ExtensionHost", () => {
|
|||
})
|
||||
|
||||
it("should clear extensionModule reference", async () => {
|
||||
;(host as unknown as Record<string, unknown>).extensionModule = {}
|
||||
setPrivate(host, "extensionModule", {})
|
||||
|
||||
await host.dispose()
|
||||
|
||||
expect(getPrivate(host, "extensionModule")).toBeNull()
|
||||
})
|
||||
|
||||
it("should clear webviewProviders", async () => {
|
||||
host.registerWebviewProvider("test", {})
|
||||
|
||||
await host.dispose()
|
||||
|
||||
const providers = getPrivate<Map<string, unknown>>(host, "webviewProviders")
|
||||
expect(providers.size).toBe(0)
|
||||
})
|
||||
|
||||
it("should delete global vscode", async () => {
|
||||
;(global as Record<string, unknown>).vscode = {}
|
||||
|
||||
|
|
@ -461,422 +409,188 @@ describe("ExtensionHost", () => {
|
|||
expect((global as Record<string, unknown>).__extensionHost).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should restore console if it was suppressed", async () => {
|
||||
it("should call restoreConsole", async () => {
|
||||
const restoreConsoleSpy = spyOnPrivate(host, "restoreConsole")
|
||||
|
||||
await host.dispose()
|
||||
|
||||
expect(restoreConsoleSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should clear managers", async () => {
|
||||
const outputManager = host.getOutputManager()
|
||||
const askDispatcher = host.getAskDispatcher()
|
||||
const outputClearSpy = vi.spyOn(outputManager, "clear")
|
||||
const askClearSpy = vi.spyOn(askDispatcher, "clear")
|
||||
|
||||
await host.dispose()
|
||||
|
||||
expect(outputClearSpy).toHaveBeenCalled()
|
||||
expect(askClearSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should reset client", async () => {
|
||||
const client = host.getExtensionClient()
|
||||
const resetSpy = vi.spyOn(client, "reset")
|
||||
|
||||
await host.dispose()
|
||||
|
||||
expect(resetSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("waitForCompletion", () => {
|
||||
it("should resolve when taskComplete is emitted", async () => {
|
||||
describe("runTask", () => {
|
||||
it("should send newTask message when called", async () => {
|
||||
const host = createTestHost()
|
||||
host.markWebviewReady()
|
||||
|
||||
const promise = callPrivate<Promise<void>>(host, "waitForCompletion")
|
||||
const emitSpy = vi.spyOn(host, "emit")
|
||||
const client = getPrivate(host, "client") as ExtensionClient
|
||||
|
||||
// Emit completion after a short delay
|
||||
setTimeout(() => host.emit("taskComplete"), 10)
|
||||
// Start the task (will hang waiting for completion)
|
||||
const taskPromise = host.runTask("test prompt")
|
||||
|
||||
await expect(promise).resolves.toBeUndefined()
|
||||
// Emit completion to resolve the promise via the client's emitter
|
||||
const taskCompletedEvent = {
|
||||
success: true,
|
||||
stateInfo: {
|
||||
state: AgentLoopState.IDLE,
|
||||
isWaitingForInput: false,
|
||||
isRunning: false,
|
||||
isStreaming: false,
|
||||
requiredAction: "start_task" as const,
|
||||
description: "Task completed",
|
||||
},
|
||||
}
|
||||
setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10)
|
||||
|
||||
await taskPromise
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "newTask", text: "test prompt" })
|
||||
})
|
||||
|
||||
it("should reject when taskError is emitted", async () => {
|
||||
it("should resolve when taskCompleted is emitted on client", async () => {
|
||||
const host = createTestHost()
|
||||
host.markWebviewReady()
|
||||
|
||||
const promise = callPrivate<Promise<void>>(host, "waitForCompletion")
|
||||
const client = getPrivate(host, "client") as ExtensionClient
|
||||
const taskPromise = host.runTask("test prompt")
|
||||
|
||||
setTimeout(() => host.emit("taskError", "Test error"), 10)
|
||||
// Emit completion after a short delay via the client's emitter
|
||||
const taskCompletedEvent = {
|
||||
success: true,
|
||||
stateInfo: {
|
||||
state: AgentLoopState.IDLE,
|
||||
isWaitingForInput: false,
|
||||
isRunning: false,
|
||||
isStreaming: false,
|
||||
requiredAction: "start_task" as const,
|
||||
description: "Task completed",
|
||||
},
|
||||
}
|
||||
setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10)
|
||||
|
||||
await expect(promise).rejects.toThrow("Test error")
|
||||
await expect(taskPromise).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("mode tracking via handleExtensionMessage", () => {
|
||||
let host: ExtensionHost
|
||||
describe("initial settings", () => {
|
||||
it("should set mode from options", () => {
|
||||
const host = createTestHost({ mode: "architect" })
|
||||
|
||||
beforeEach(() => {
|
||||
host = createTestHost({
|
||||
mode: "code",
|
||||
provider: "anthropic",
|
||||
apiKey: "test-key",
|
||||
model: "test-model",
|
||||
})
|
||||
// Mock process.stdout.write which is used by output()
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true)
|
||||
const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
|
||||
expect(initialSettings.mode).toBe("architect")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
it("should enable auto-approval in non-interactive mode", () => {
|
||||
const host = createTestHost({ nonInteractive: true })
|
||||
|
||||
const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
|
||||
expect(initialSettings.autoApprovalEnabled).toBe(true)
|
||||
expect(initialSettings.alwaysAllowReadOnly).toBe(true)
|
||||
expect(initialSettings.alwaysAllowWrite).toBe(true)
|
||||
expect(initialSettings.alwaysAllowExecute).toBe(true)
|
||||
})
|
||||
|
||||
it("should track current mode when state updates with a mode", () => {
|
||||
// Initial state update establishes current mode
|
||||
callPrivate(host, "handleExtensionMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
|
||||
expect(getPrivate(host, "currentMode")).toBe("code")
|
||||
it("should disable auto-approval in interactive mode", () => {
|
||||
const host = createTestHost({ nonInteractive: false })
|
||||
|
||||
// Second state update should update tracked mode
|
||||
callPrivate(host, "handleExtensionMessage", {
|
||||
type: "state",
|
||||
state: { mode: "architect", clineMessages: [] },
|
||||
})
|
||||
expect(getPrivate(host, "currentMode")).toBe("architect")
|
||||
const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
|
||||
expect(initialSettings.autoApprovalEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it("should not change current mode when state has no mode", () => {
|
||||
// Initial state update establishes current mode
|
||||
callPrivate(host, "handleExtensionMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
|
||||
expect(getPrivate(host, "currentMode")).toBe("code")
|
||||
it("should set reasoning effort when specified", () => {
|
||||
const host = createTestHost({ reasoningEffort: "high" })
|
||||
|
||||
// State without mode should not change tracked mode
|
||||
callPrivate(host, "handleExtensionMessage", { type: "state", state: { clineMessages: [] } })
|
||||
expect(getPrivate(host, "currentMode")).toBe("code")
|
||||
const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
|
||||
expect(initialSettings.enableReasoningEffort).toBe(true)
|
||||
expect(initialSettings.reasoningEffort).toBe("high")
|
||||
})
|
||||
|
||||
it("should track current mode across multiple changes", () => {
|
||||
// Start with code mode
|
||||
callPrivate(host, "handleExtensionMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
|
||||
expect(getPrivate(host, "currentMode")).toBe("code")
|
||||
it("should disable reasoning effort when set to disabled", () => {
|
||||
const host = createTestHost({ reasoningEffort: "disabled" })
|
||||
|
||||
// Change to architect
|
||||
callPrivate(host, "handleExtensionMessage", {
|
||||
type: "state",
|
||||
state: { mode: "architect", clineMessages: [] },
|
||||
})
|
||||
expect(getPrivate(host, "currentMode")).toBe("architect")
|
||||
|
||||
// Change to debug
|
||||
callPrivate(host, "handleExtensionMessage", { type: "state", state: { mode: "debug", clineMessages: [] } })
|
||||
expect(getPrivate(host, "currentMode")).toBe("debug")
|
||||
|
||||
// Another state update with debug
|
||||
callPrivate(host, "handleExtensionMessage", { type: "state", state: { mode: "debug", clineMessages: [] } })
|
||||
expect(getPrivate(host, "currentMode")).toBe("debug")
|
||||
const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
|
||||
expect(initialSettings.enableReasoningEffort).toBe(false)
|
||||
})
|
||||
|
||||
it("should not send updateSettings on mode change (CLI settings are applied once during runTask)", () => {
|
||||
// This test ensures mode changes don't trigger automatic re-application of API settings.
|
||||
// CLI settings are applied once during runTask() via updateSettings.
|
||||
// Mode-specific provider profiles are handled by the extension's handleModeSwitch.
|
||||
const sendToExtensionSpy = vi.spyOn(host, "sendToExtension")
|
||||
it("should not set reasoning effort when unspecified", () => {
|
||||
const host = createTestHost({ reasoningEffort: "unspecified" })
|
||||
|
||||
// Initial state
|
||||
callPrivate(host, "handleExtensionMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
|
||||
sendToExtensionSpy.mockClear()
|
||||
|
||||
// Mode change should NOT trigger sendToExtension
|
||||
callPrivate(host, "handleExtensionMessage", {
|
||||
type: "state",
|
||||
state: { mode: "architect", clineMessages: [] },
|
||||
})
|
||||
expect(sendToExtensionSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyRuntimeSettings - mode switching", () => {
|
||||
it("should use currentMode when set (from user mode switches)", () => {
|
||||
const host = createTestHost({
|
||||
mode: "code", // Initial mode from CLI options
|
||||
provider: "anthropic",
|
||||
apiKey: "test-key",
|
||||
model: "test-model",
|
||||
})
|
||||
|
||||
// Simulate user switching mode via Ctrl+M - this updates currentMode
|
||||
;(host as unknown as Record<string, unknown>).currentMode = "architect"
|
||||
|
||||
// Create settings object to be modified
|
||||
const settings: Record<string, unknown> = {}
|
||||
callPrivate(host, "applyRuntimeSettings", settings)
|
||||
|
||||
// Should use currentMode (architect), not options.mode (code)
|
||||
expect(settings.mode).toBe("architect")
|
||||
})
|
||||
|
||||
it("should fall back to options.mode when currentMode is not set", () => {
|
||||
const host = createTestHost({
|
||||
mode: "code",
|
||||
provider: "anthropic",
|
||||
apiKey: "test-key",
|
||||
model: "test-model",
|
||||
})
|
||||
|
||||
// currentMode is not set (still null from constructor)
|
||||
expect(getPrivate(host, "currentMode")).toBe("code") // Set from options.mode in constructor
|
||||
|
||||
const settings: Record<string, unknown> = {}
|
||||
callPrivate(host, "applyRuntimeSettings", settings)
|
||||
|
||||
// Should use options.mode as fallback
|
||||
expect(settings.mode).toBe("code")
|
||||
})
|
||||
|
||||
it("should use currentMode even when it differs from initial options.mode", () => {
|
||||
const host = createTestHost({
|
||||
mode: "code",
|
||||
provider: "anthropic",
|
||||
apiKey: "test-key",
|
||||
model: "test-model",
|
||||
})
|
||||
|
||||
// Simulate multiple mode switches: code -> architect -> debug
|
||||
;(host as unknown as Record<string, unknown>).currentMode = "debug"
|
||||
|
||||
const settings: Record<string, unknown> = {}
|
||||
callPrivate(host, "applyRuntimeSettings", settings)
|
||||
|
||||
// Should use the latest currentMode
|
||||
expect(settings.mode).toBe("debug")
|
||||
})
|
||||
|
||||
it("should not set mode if neither currentMode nor options.mode is set", () => {
|
||||
const host = createTestHost({
|
||||
// No mode specified - mode defaults to "code" in createTestHost
|
||||
provider: "anthropic",
|
||||
apiKey: "test-key",
|
||||
model: "test-model",
|
||||
})
|
||||
|
||||
// Explicitly set currentMode to null (edge case)
|
||||
;(host as unknown as Record<string, unknown>).currentMode = null
|
||||
// Also clear options.mode
|
||||
const options = getPrivate<ExtensionHostOptions>(host, "options")
|
||||
options.mode = ""
|
||||
|
||||
const settings: Record<string, unknown> = {}
|
||||
callPrivate(host, "applyRuntimeSettings", settings)
|
||||
|
||||
// Mode should not be set
|
||||
expect(settings.mode).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("mode switching - end to end simulation", () => {
|
||||
let host: ExtensionHost
|
||||
|
||||
beforeEach(() => {
|
||||
host = createTestHost({
|
||||
mode: "code",
|
||||
provider: "anthropic",
|
||||
apiKey: "test-key",
|
||||
model: "test-model",
|
||||
})
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("should preserve mode switch when starting a new task", () => {
|
||||
// Step 1: Initial state from extension (like webviewDidLaunch response)
|
||||
callPrivate(host, "handleExtensionMessage", {
|
||||
type: "state",
|
||||
state: { mode: "code", clineMessages: [] },
|
||||
})
|
||||
expect(getPrivate(host, "currentMode")).toBe("code")
|
||||
|
||||
// Step 2: User presses Ctrl+M to switch mode, extension sends new state
|
||||
callPrivate(host, "handleExtensionMessage", {
|
||||
type: "state",
|
||||
state: { mode: "architect", clineMessages: [] },
|
||||
})
|
||||
expect(getPrivate(host, "currentMode")).toBe("architect")
|
||||
|
||||
// Step 3: When runTask is called, applyRuntimeSettings should use architect
|
||||
const settings: Record<string, unknown> = {}
|
||||
callPrivate(host, "applyRuntimeSettings", settings)
|
||||
expect(settings.mode).toBe("architect")
|
||||
})
|
||||
|
||||
it("should handle mode switch before any state messages", () => {
|
||||
// currentMode is initialized to options.mode in constructor
|
||||
expect(getPrivate(host, "currentMode")).toBe("code")
|
||||
|
||||
// Without any state messages, should still use options.mode
|
||||
const settings: Record<string, unknown> = {}
|
||||
callPrivate(host, "applyRuntimeSettings", settings)
|
||||
expect(settings.mode).toBe("code")
|
||||
})
|
||||
|
||||
it("should track multiple mode switches correctly", () => {
|
||||
// Switch through multiple modes
|
||||
callPrivate(host, "handleExtensionMessage", {
|
||||
type: "state",
|
||||
state: { mode: "code", clineMessages: [] },
|
||||
})
|
||||
callPrivate(host, "handleExtensionMessage", {
|
||||
type: "state",
|
||||
state: { mode: "architect", clineMessages: [] },
|
||||
})
|
||||
callPrivate(host, "handleExtensionMessage", {
|
||||
type: "state",
|
||||
state: { mode: "debug", clineMessages: [] },
|
||||
})
|
||||
callPrivate(host, "handleExtensionMessage", {
|
||||
type: "state",
|
||||
state: { mode: "ask", clineMessages: [] },
|
||||
})
|
||||
|
||||
// Should use the most recent mode
|
||||
expect(getPrivate(host, "currentMode")).toBe("ask")
|
||||
|
||||
const settings: Record<string, unknown> = {}
|
||||
callPrivate(host, "applyRuntimeSettings", settings)
|
||||
expect(settings.mode).toBe("ask")
|
||||
const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
|
||||
expect(initialSettings.enableReasoningEffort).toBeUndefined()
|
||||
expect(initialSettings.reasoningEffort).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("ephemeral mode", () => {
|
||||
describe("constructor", () => {
|
||||
it("should store ephemeral option", () => {
|
||||
const host = createTestHost({ ephemeral: true })
|
||||
const options = getPrivate<ExtensionHostOptions>(host, "options")
|
||||
expect(options.ephemeral).toBe(true)
|
||||
})
|
||||
it("should store ephemeral option correctly", () => {
|
||||
const host = createTestHost({ ephemeral: true })
|
||||
|
||||
it("should default ephemeral to undefined", () => {
|
||||
const host = createTestHost()
|
||||
const options = getPrivate<ExtensionHostOptions>(host, "options")
|
||||
expect(options.ephemeral).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should initialize ephemeralStorageDir to null", () => {
|
||||
const host = createTestHost({ ephemeral: true })
|
||||
expect(getPrivate(host, "ephemeralStorageDir")).toBeNull()
|
||||
})
|
||||
const options = getPrivate<ExtensionHostOptions>(host, "options")
|
||||
expect(options.ephemeral).toBe(true)
|
||||
})
|
||||
|
||||
describe("createEphemeralStorageDir", () => {
|
||||
let createdDirs: string[] = []
|
||||
it("should default ephemeralStorageDir to null", () => {
|
||||
const host = createTestHost()
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up any directories created during tests
|
||||
for (const dir of createdDirs) {
|
||||
try {
|
||||
await fs.promises.rm(dir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
createdDirs = []
|
||||
})
|
||||
|
||||
it("should create a directory in the system temp folder", async () => {
|
||||
const host = createTestHost({ ephemeral: true })
|
||||
const tmpDir = await callPrivate<Promise<string>>(host, "createEphemeralStorageDir")
|
||||
createdDirs.push(tmpDir)
|
||||
|
||||
expect(tmpDir).toContain(os.tmpdir())
|
||||
expect(tmpDir).toContain("roo-cli-")
|
||||
expect(fs.existsSync(tmpDir)).toBe(true)
|
||||
})
|
||||
|
||||
it("should create a unique directory each time", async () => {
|
||||
const host = createTestHost({ ephemeral: true })
|
||||
const dir1 = await callPrivate<Promise<string>>(host, "createEphemeralStorageDir")
|
||||
const dir2 = await callPrivate<Promise<string>>(host, "createEphemeralStorageDir")
|
||||
createdDirs.push(dir1, dir2)
|
||||
|
||||
expect(dir1).not.toBe(dir2)
|
||||
expect(fs.existsSync(dir1)).toBe(true)
|
||||
expect(fs.existsSync(dir2)).toBe(true)
|
||||
})
|
||||
|
||||
it("should include timestamp and random id in directory name", async () => {
|
||||
const host = createTestHost({ ephemeral: true })
|
||||
const tmpDir = await callPrivate<Promise<string>>(host, "createEphemeralStorageDir")
|
||||
createdDirs.push(tmpDir)
|
||||
|
||||
const dirName = path.basename(tmpDir)
|
||||
// Format: roo-cli-{timestamp}-{randomId}
|
||||
expect(dirName).toMatch(/^roo-cli-\d+-[a-z0-9]+$/)
|
||||
})
|
||||
expect(getPrivate(host, "ephemeralStorageDir")).toBeNull()
|
||||
})
|
||||
|
||||
describe("dispose - ephemeral cleanup", () => {
|
||||
it("should clean up ephemeral storage directory on dispose", async () => {
|
||||
const host = createTestHost({ ephemeral: true })
|
||||
it("should clean up ephemeral storage directory on dispose", async () => {
|
||||
const host = createTestHost({ ephemeral: true })
|
||||
|
||||
// Create the ephemeral directory
|
||||
const tmpDir = await callPrivate<Promise<string>>(host, "createEphemeralStorageDir")
|
||||
;(host as unknown as Record<string, unknown>).ephemeralStorageDir = tmpDir
|
||||
// Set up a mock ephemeral storage directory
|
||||
const mockEphemeralDir = "/tmp/roo-cli-test-ephemeral-cleanup"
|
||||
setPrivate(host, "ephemeralStorageDir", mockEphemeralDir)
|
||||
|
||||
// Verify directory exists
|
||||
expect(fs.existsSync(tmpDir)).toBe(true)
|
||||
// Mock fs.promises.rm
|
||||
const rmMock = vi.spyOn(fs.promises, "rm").mockResolvedValue(undefined)
|
||||
|
||||
// Dispose the host
|
||||
await host.dispose()
|
||||
await host.dispose()
|
||||
|
||||
// Directory should be removed
|
||||
expect(fs.existsSync(tmpDir)).toBe(false)
|
||||
expect(getPrivate(host, "ephemeralStorageDir")).toBeNull()
|
||||
})
|
||||
expect(rmMock).toHaveBeenCalledWith(mockEphemeralDir, { recursive: true, force: true })
|
||||
expect(getPrivate(host, "ephemeralStorageDir")).toBeNull()
|
||||
|
||||
it("should not fail dispose if ephemeral directory doesn't exist", async () => {
|
||||
const host = createTestHost({ ephemeral: true })
|
||||
rmMock.mockRestore()
|
||||
})
|
||||
|
||||
// Set a non-existent directory
|
||||
;(host as unknown as Record<string, unknown>).ephemeralStorageDir = "/non/existent/path/roo-cli-test"
|
||||
it("should not clean up when ephemeralStorageDir is null", async () => {
|
||||
const host = createTestHost()
|
||||
|
||||
// Dispose should not throw
|
||||
await expect(host.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
// ephemeralStorageDir is null by default
|
||||
expect(getPrivate(host, "ephemeralStorageDir")).toBeNull()
|
||||
|
||||
it("should clean up ephemeral directory with contents", async () => {
|
||||
const host = createTestHost({ ephemeral: true })
|
||||
const rmMock = vi.spyOn(fs.promises, "rm").mockResolvedValue(undefined)
|
||||
|
||||
// Create the ephemeral directory with some content
|
||||
const tmpDir = await callPrivate<Promise<string>>(host, "createEphemeralStorageDir")
|
||||
;(host as unknown as Record<string, unknown>).ephemeralStorageDir = tmpDir
|
||||
await host.dispose()
|
||||
|
||||
// Add some files and subdirectories
|
||||
await fs.promises.writeFile(path.join(tmpDir, "test.txt"), "test content")
|
||||
await fs.promises.mkdir(path.join(tmpDir, "subdir"))
|
||||
await fs.promises.writeFile(path.join(tmpDir, "subdir", "nested.txt"), "nested content")
|
||||
// rm should not be called when there's no ephemeral storage
|
||||
expect(rmMock).not.toHaveBeenCalled()
|
||||
|
||||
// Verify content exists
|
||||
expect(fs.existsSync(path.join(tmpDir, "test.txt"))).toBe(true)
|
||||
expect(fs.existsSync(path.join(tmpDir, "subdir", "nested.txt"))).toBe(true)
|
||||
rmMock.mockRestore()
|
||||
})
|
||||
|
||||
// Dispose the host
|
||||
await host.dispose()
|
||||
it("should handle ephemeral storage cleanup errors gracefully", async () => {
|
||||
const host = createTestHost({ ephemeral: true })
|
||||
|
||||
// Directory and all contents should be removed
|
||||
expect(fs.existsSync(tmpDir)).toBe(false)
|
||||
})
|
||||
// Set up a mock ephemeral storage directory
|
||||
setPrivate(host, "ephemeralStorageDir", "/tmp/roo-cli-test-ephemeral-error")
|
||||
|
||||
it("should not clean up anything if not in ephemeral mode", async () => {
|
||||
const host = createTestHost({ ephemeral: false })
|
||||
// Mock fs.promises.rm to throw an error
|
||||
const rmMock = vi.spyOn(fs.promises, "rm").mockRejectedValue(new Error("Cleanup failed"))
|
||||
|
||||
// ephemeralStorageDir should be null
|
||||
expect(getPrivate(host, "ephemeralStorageDir")).toBeNull()
|
||||
// dispose should not throw even if cleanup fails
|
||||
await expect(host.dispose()).resolves.toBeUndefined()
|
||||
|
||||
// Dispose should complete normally
|
||||
await expect(host.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
rmMock.mockRestore()
|
||||
})
|
||||
|
||||
it("should not affect normal mode when ephemeral is false", () => {
|
||||
const host = createTestHost({ ephemeral: false })
|
||||
|
||||
const options = getPrivate<ExtensionHostOptions>(host, "options")
|
||||
expect(options.ephemeral).toBe(false)
|
||||
expect(getPrivate(host, "ephemeralStorageDir")).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -71,6 +71,11 @@ export interface ClientEventMap {
|
|||
*/
|
||||
taskCleared: void
|
||||
|
||||
/**
|
||||
* Emitted when the current mode changes.
|
||||
*/
|
||||
modeChanged: ModeChangedEvent
|
||||
|
||||
/**
|
||||
* Emitted on any error during message processing.
|
||||
*/
|
||||
|
|
@ -113,6 +118,16 @@ export interface TaskCompletedEvent {
|
|||
message?: ClineMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Event payload when mode changes.
|
||||
*/
|
||||
export interface ModeChangedEvent {
|
||||
/** The previous mode (undefined if first mode set) */
|
||||
previousMode: string | undefined
|
||||
/** The new/current mode */
|
||||
currentMode: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Typed Event Emitter
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
type ClientEventMap,
|
||||
type AgentStateChangeEvent,
|
||||
type WaitingForInputEvent,
|
||||
type ModeChangedEvent,
|
||||
} from "./events.js"
|
||||
import { AgentLoopState, type AgentStateInfo } from "./agent-state.js"
|
||||
|
||||
|
|
@ -154,10 +155,12 @@ export class ExtensionClient {
|
|||
|
||||
if (typeof message === "string") {
|
||||
parsed = parseExtensionMessage(message)
|
||||
|
||||
if (!parsed) {
|
||||
if (this.debug) {
|
||||
console.log("[ExtensionClient] Failed to parse message:", message)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
} else {
|
||||
|
|
@ -257,6 +260,14 @@ export class ExtensionClient {
|
|||
return this.store.isInitialized()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current mode (e.g., "code", "architect", "ask").
|
||||
* Returns undefined if no mode has been received yet.
|
||||
*/
|
||||
getCurrentMode(): string | undefined {
|
||||
return this.store.getCurrentMode()
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Event Subscriptions - Realtime notifications
|
||||
// ===========================================================================
|
||||
|
|
@ -319,6 +330,13 @@ export class ExtensionClient {
|
|||
return this.on("waitingForInput", listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method: Subscribe only to mode changes.
|
||||
*/
|
||||
onModeChanged(listener: (event: ModeChangedEvent) => void): () => void {
|
||||
return this.on("modeChanged", listener)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Response Methods - Send actions to the extension
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -6,20 +6,15 @@
|
|||
* 2. Loading the extension bundle via require()
|
||||
* 3. Activating the extension
|
||||
* 4. Wiring up managers for output, prompting, and ask handling
|
||||
*
|
||||
* Managers handle all the heavy lifting:
|
||||
* - ExtensionClient: Agent state detection (single source of truth)
|
||||
* - OutputManager: CLI output and streaming
|
||||
* - PromptManager: User input collection
|
||||
* - AskDispatcher: Ask routing and handling
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "events"
|
||||
import { createRequire } from "module"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import { EventEmitter } from "events"
|
||||
|
||||
import pWaitFor from "p-wait-for"
|
||||
|
||||
import type {
|
||||
ClineMessage,
|
||||
|
|
@ -28,15 +23,16 @@ import type {
|
|||
RooCodeSettings,
|
||||
WebviewMessage,
|
||||
} from "@roo-code/types"
|
||||
import { createVSCodeAPI, setRuntimeConfigValues } from "@roo-code/vscode-shim"
|
||||
import { createVSCodeAPI, IExtensionHost, ExtensionHostEventMap, setRuntimeConfigValues } from "@roo-code/vscode-shim"
|
||||
import { DebugLogger } from "@roo-code/core/cli"
|
||||
|
||||
import type { SupportedProvider } from "@/types/index.js"
|
||||
import type { User } from "@/lib/sdk/index.js"
|
||||
import { getProviderSettings } from "@/lib/utils/provider.js"
|
||||
import { createEphemeralStorageDir } from "@/lib/storage/index.js"
|
||||
|
||||
import type { AgentStateChangeEvent, WaitingForInputEvent, TaskCompletedEvent } from "./events.js"
|
||||
import { type AgentStateInfo, AgentLoopState } from "./agent-state.js"
|
||||
import type { WaitingForInputEvent, TaskCompletedEvent } from "./events.js"
|
||||
import type { AgentStateInfo } from "./agent-state.js"
|
||||
import { ExtensionClient } from "./extension-client.js"
|
||||
import { OutputManager } from "./output-manager.js"
|
||||
import { PromptManager } from "./prompt-manager.js"
|
||||
|
|
@ -52,10 +48,6 @@ const cliLogger = new DebugLogger("CLI")
|
|||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const CLI_PACKAGE_ROOT = process.env.ROO_CLI_ROOT || path.resolve(__dirname, "..")
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface ExtensionHostOptions {
|
||||
mode: string
|
||||
reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled"
|
||||
|
|
@ -92,22 +84,25 @@ interface WebviewViewProvider {
|
|||
resolveWebviewView?(webviewView: unknown, context: unknown, token: unknown): void | Promise<void>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ExtensionHost Class
|
||||
// =============================================================================
|
||||
export interface ExtensionHostInterface extends IExtensionHost<ExtensionHostEventMap> {
|
||||
client: ExtensionClient
|
||||
activate(): Promise<void>
|
||||
runTask(prompt: string): Promise<void>
|
||||
sendToExtension(message: WebviewMessage): void
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
export class ExtensionHost extends EventEmitter {
|
||||
// Extension lifecycle
|
||||
export class ExtensionHost extends EventEmitter implements ExtensionHostInterface {
|
||||
// Extension lifecycle.
|
||||
private vscode: ReturnType<typeof createVSCodeAPI> | null = null
|
||||
private extensionModule: ExtensionModule | null = null
|
||||
private extensionAPI: unknown = null
|
||||
private webviewProviders: Map<string, WebviewViewProvider> = new Map()
|
||||
private options: ExtensionHostOptions
|
||||
private isWebviewReady = false
|
||||
private pendingMessages: unknown[] = []
|
||||
private isReady = false
|
||||
private messageListener: ((message: ExtensionMessage) => void) | null = null
|
||||
private initialSettings: RooCodeSettings
|
||||
|
||||
// Console suppression
|
||||
// Console suppression.
|
||||
private originalConsole: {
|
||||
log: typeof console.log
|
||||
warn: typeof console.warn
|
||||
|
|
@ -115,12 +110,10 @@ export class ExtensionHost extends EventEmitter {
|
|||
debug: typeof console.debug
|
||||
info: typeof console.info
|
||||
} | null = null
|
||||
|
||||
private originalProcessEmitWarning: typeof process.emitWarning | null = null
|
||||
|
||||
// Mode tracking
|
||||
private currentMode: string | null = null
|
||||
|
||||
// Ephemeral storage
|
||||
// Ephemeral storage.
|
||||
private ephemeralStorageDir: string | null = null
|
||||
|
||||
// ==========================================================================
|
||||
|
|
@ -131,7 +124,7 @@ export class ExtensionHost extends EventEmitter {
|
|||
* ExtensionClient: Single source of truth for agent loop state.
|
||||
* Handles message processing and state detection.
|
||||
*/
|
||||
private client: ExtensionClient
|
||||
public readonly client: ExtensionClient
|
||||
|
||||
/**
|
||||
* OutputManager: Handles all CLI output and streaming.
|
||||
|
|
@ -159,9 +152,9 @@ export class ExtensionHost extends EventEmitter {
|
|||
super()
|
||||
|
||||
this.options = options
|
||||
this.currentMode = options.mode || null
|
||||
this.options.integrationTest = true
|
||||
|
||||
// Initialize client - single source of truth for agent state.
|
||||
// Initialize client - single source of truth for agent state (including mode).
|
||||
this.client = new ExtensionClient({
|
||||
sendMessage: (msg) => this.sendToExtension(msg),
|
||||
debug: options.debug, // Enable debug logging in the client.
|
||||
|
|
@ -189,6 +182,47 @@ export class ExtensionHost extends EventEmitter {
|
|||
|
||||
// Wire up client events.
|
||||
this.setupClientEventHandlers()
|
||||
|
||||
// Populate initial settings.
|
||||
const baseSettings: RooCodeSettings = {
|
||||
mode: this.options.mode,
|
||||
commandExecutionTimeout: 30,
|
||||
browserToolEnabled: false,
|
||||
enableCheckpoints: false,
|
||||
...getProviderSettings(this.options.provider, this.options.apiKey, this.options.model),
|
||||
}
|
||||
|
||||
this.initialSettings = this.options.nonInteractive
|
||||
? {
|
||||
autoApprovalEnabled: true,
|
||||
alwaysAllowReadOnly: true,
|
||||
alwaysAllowReadOnlyOutsideWorkspace: true,
|
||||
alwaysAllowWrite: true,
|
||||
alwaysAllowWriteOutsideWorkspace: true,
|
||||
alwaysAllowWriteProtected: true,
|
||||
alwaysAllowBrowser: true,
|
||||
alwaysAllowMcp: true,
|
||||
alwaysAllowModeSwitch: true,
|
||||
alwaysAllowSubtasks: true,
|
||||
alwaysAllowExecute: true,
|
||||
allowedCommands: ["*"],
|
||||
...baseSettings,
|
||||
}
|
||||
: {
|
||||
autoApprovalEnabled: false,
|
||||
...baseSettings,
|
||||
}
|
||||
|
||||
if (this.options.reasoningEffort && this.options.reasoningEffort !== "unspecified") {
|
||||
if (this.options.reasoningEffort === "disabled") {
|
||||
this.initialSettings.enableReasoningEffort = false
|
||||
} else {
|
||||
this.initialSettings.enableReasoningEffort = true
|
||||
this.initialSettings.reasoningEffort = this.options.reasoningEffort
|
||||
}
|
||||
}
|
||||
|
||||
this.setupQuietMode()
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
|
|
@ -200,11 +234,6 @@ export class ExtensionHost extends EventEmitter {
|
|||
* The client emits events, managers handle them.
|
||||
*/
|
||||
private setupClientEventHandlers(): void {
|
||||
// Forward state changes for external consumers.
|
||||
this.client.on("stateChange", (event: AgentStateChangeEvent) => {
|
||||
this.emit("agentStateChange", event)
|
||||
})
|
||||
|
||||
// Handle new messages - delegate to OutputManager.
|
||||
this.client.on("message", (msg: ClineMessage) => {
|
||||
this.logMessageDebug(msg, "new")
|
||||
|
|
@ -219,61 +248,34 @@ export class ExtensionHost extends EventEmitter {
|
|||
|
||||
// Handle waiting for input - delegate to AskDispatcher.
|
||||
this.client.on("waitingForInput", (event: WaitingForInputEvent) => {
|
||||
this.emit("agentWaitingForInput", event)
|
||||
this.askDispatcher.handleAsk(event.message)
|
||||
})
|
||||
|
||||
// Handle task completion.
|
||||
this.client.on("taskCompleted", (event: TaskCompletedEvent) => {
|
||||
this.emit("agentTaskCompleted", event)
|
||||
this.handleTaskCompleted(event)
|
||||
// Output completion message via OutputManager.
|
||||
// Note: completion_result is an "ask" type, not a "say" type.
|
||||
if (event.message && event.message.type === "ask" && event.message.ask === "completion_result") {
|
||||
this.outputManager.outputCompletionResult(event.message.ts, event.message.text || "")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug logging for messages (first/last pattern).
|
||||
*/
|
||||
private logMessageDebug(msg: ClineMessage, type: "new" | "updated"): void {
|
||||
if (msg.partial) {
|
||||
if (!this.outputManager.hasLoggedFirstPartial(msg.ts)) {
|
||||
this.outputManager.setLoggedFirstPartial(msg.ts)
|
||||
cliLogger.debug("message:start", { ts: msg.ts, type: msg.say || msg.ask })
|
||||
}
|
||||
} else {
|
||||
cliLogger.debug(`message:${type === "new" ? "new" : "complete"}`, { ts: msg.ts, type: msg.say || msg.ask })
|
||||
this.outputManager.clearLoggedFirstPartial(msg.ts)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle task completion.
|
||||
*/
|
||||
private handleTaskCompleted(event: TaskCompletedEvent): void {
|
||||
// Output completion message via OutputManager.
|
||||
// Note: completion_result is an "ask" type, not a "say" type.
|
||||
if (event.message && event.message.type === "ask" && event.message.ask === "completion_result") {
|
||||
this.outputManager.outputCompletionResult(event.message.ts, event.message.text || "")
|
||||
}
|
||||
|
||||
// Emit taskComplete for waitForCompletion.
|
||||
this.emit("taskComplete")
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Console Suppression
|
||||
// Logging + Console Suppression
|
||||
// ==========================================================================
|
||||
|
||||
private suppressNodeWarnings(): void {
|
||||
this.originalProcessEmitWarning = process.emitWarning
|
||||
process.emitWarning = () => {}
|
||||
process.on("warning", () => {})
|
||||
}
|
||||
|
||||
private setupQuietMode(): void {
|
||||
if (this.options.integrationTest) {
|
||||
return
|
||||
}
|
||||
|
||||
// Suppress node warnings.
|
||||
this.originalProcessEmitWarning = process.emitWarning
|
||||
process.emitWarning = () => {}
|
||||
process.on("warning", () => {})
|
||||
|
||||
// Suppress console output.
|
||||
this.originalConsole = {
|
||||
log: console.log,
|
||||
warn: console.warn,
|
||||
|
|
@ -308,21 +310,23 @@ export class ExtensionHost extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
private logMessageDebug(msg: ClineMessage, type: "new" | "updated"): void {
|
||||
if (msg.partial) {
|
||||
if (!this.outputManager.hasLoggedFirstPartial(msg.ts)) {
|
||||
this.outputManager.setLoggedFirstPartial(msg.ts)
|
||||
cliLogger.debug("message:start", { ts: msg.ts, type: msg.say || msg.ask })
|
||||
}
|
||||
} else {
|
||||
cliLogger.debug(`message:${type === "new" ? "new" : "complete"}`, { ts: msg.ts, type: msg.say || msg.ask })
|
||||
this.outputManager.clearLoggedFirstPartial(msg.ts)
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Extension Lifecycle
|
||||
// ==========================================================================
|
||||
|
||||
private async createEphemeralStorageDir(): Promise<string> {
|
||||
const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`
|
||||
const tmpDir = path.join(os.tmpdir(), `roo-cli-${uniqueId}`)
|
||||
await fs.promises.mkdir(tmpDir, { recursive: true })
|
||||
return tmpDir
|
||||
}
|
||||
|
||||
async activate(): Promise<void> {
|
||||
this.suppressNodeWarnings()
|
||||
this.setupQuietMode()
|
||||
|
||||
public async activate(): Promise<void> {
|
||||
const bundlePath = path.join(this.options.extensionPath, "extension.js")
|
||||
|
||||
if (!fs.existsSync(bundlePath)) {
|
||||
|
|
@ -333,8 +337,8 @@ export class ExtensionHost extends EventEmitter {
|
|||
let storageDir: string | undefined
|
||||
|
||||
if (this.options.ephemeral) {
|
||||
storageDir = await this.createEphemeralStorageDir()
|
||||
this.ephemeralStorageDir = storageDir
|
||||
this.ephemeralStorageDir = await createEphemeralStorageDir()
|
||||
storageDir = this.ephemeralStorageDir
|
||||
}
|
||||
|
||||
// Create VSCode API mock.
|
||||
|
|
@ -372,6 +376,7 @@ export class ExtensionHost extends EventEmitter {
|
|||
this.extensionModule = require(bundlePath) as ExtensionModule
|
||||
} catch (error) {
|
||||
Module._resolveFilename = originalResolve
|
||||
|
||||
throw new Error(
|
||||
`Failed to load extension bundle: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
|
|
@ -385,234 +390,106 @@ export class ExtensionHost extends EventEmitter {
|
|||
throw new Error(`Failed to activate extension: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
|
||||
// Set up message listener - forward all messages to client
|
||||
this.messageListener = (message: ExtensionMessage) => this.handleExtensionMessage(message)
|
||||
// Set up message listener - forward all messages to client.
|
||||
this.messageListener = (message: ExtensionMessage) => this.client.handleMessage(message)
|
||||
this.on("extensionWebviewMessage", this.messageListener)
|
||||
|
||||
await pWaitFor(() => this.isReady, { interval: 100, timeout: 10_000 })
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Webview Provider Registration
|
||||
// ==========================================================================
|
||||
public registerWebviewProvider(_viewId: string, _provider: WebviewViewProvider): void {}
|
||||
|
||||
registerWebviewProvider(viewId: string, provider: WebviewViewProvider): void {
|
||||
this.webviewProviders.set(viewId, provider)
|
||||
public unregisterWebviewProvider(_viewId: string): void {}
|
||||
|
||||
public markWebviewReady(): void {
|
||||
this.isReady = true
|
||||
|
||||
// Send initial webview messages to trigger proper extension initialization.
|
||||
// This is critical for the extension to start sending state updates properly.
|
||||
this.sendToExtension({ type: "webviewDidLaunch" })
|
||||
|
||||
setRuntimeConfigValues("roo-cline", this.initialSettings as Record<string, unknown>)
|
||||
this.sendToExtension({ type: "updateSettings", updatedSettings: this.initialSettings })
|
||||
}
|
||||
|
||||
unregisterWebviewProvider(viewId: string): void {
|
||||
this.webviewProviders.delete(viewId)
|
||||
}
|
||||
|
||||
isInInitialSetup(): boolean {
|
||||
return !this.isWebviewReady
|
||||
}
|
||||
|
||||
markWebviewReady(): void {
|
||||
this.isWebviewReady = true
|
||||
this.emit("webviewReady")
|
||||
this.flushPendingMessages()
|
||||
}
|
||||
|
||||
private flushPendingMessages(): void {
|
||||
if (this.pendingMessages.length > 0) {
|
||||
for (const message of this.pendingMessages) {
|
||||
this.emit("webviewMessage", message)
|
||||
}
|
||||
this.pendingMessages = []
|
||||
}
|
||||
public isInInitialSetup(): boolean {
|
||||
return !this.isReady
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Message Handling
|
||||
// ==========================================================================
|
||||
|
||||
sendToExtension(message: WebviewMessage): void {
|
||||
if (!this.isWebviewReady) {
|
||||
this.pendingMessages.push(message)
|
||||
return
|
||||
public sendToExtension(message: WebviewMessage): void {
|
||||
if (!this.isReady) {
|
||||
throw new Error("You cannot send messages to the extension before it is ready")
|
||||
}
|
||||
|
||||
this.emit("webviewMessage", message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming messages from extension.
|
||||
* Forward to client (single source of truth).
|
||||
*/
|
||||
private handleExtensionMessage(msg: ExtensionMessage): void {
|
||||
// Track mode changes
|
||||
if (msg.type === "state" && msg.state?.mode && typeof msg.state.mode === "string") {
|
||||
this.currentMode = msg.state.mode
|
||||
}
|
||||
|
||||
// Forward to client - it's the single source of truth
|
||||
this.client.handleMessage(msg)
|
||||
|
||||
// Handle modes separately
|
||||
if (msg.type === "modes") {
|
||||
this.emit("modesUpdated", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Task Management
|
||||
// ==========================================================================
|
||||
|
||||
private applyRuntimeSettings(settings: RooCodeSettings): void {
|
||||
const activeMode = this.currentMode || this.options.mode
|
||||
if (activeMode) {
|
||||
settings.mode = activeMode
|
||||
}
|
||||
|
||||
if (this.options.reasoningEffort && this.options.reasoningEffort !== "unspecified") {
|
||||
if (this.options.reasoningEffort === "disabled") {
|
||||
settings.enableReasoningEffort = false
|
||||
} else {
|
||||
settings.enableReasoningEffort = true
|
||||
settings.reasoningEffort = this.options.reasoningEffort
|
||||
}
|
||||
}
|
||||
|
||||
setRuntimeConfigValues("roo-cline", settings as Record<string, unknown>)
|
||||
}
|
||||
|
||||
async runTask(prompt: string): Promise<void> {
|
||||
if (!this.isWebviewReady) {
|
||||
await new Promise<void>((resolve) => this.once("webviewReady", resolve))
|
||||
}
|
||||
|
||||
// Send initial webview messages to trigger proper extension initialization
|
||||
// This is critical for the extension to start sending state updates properly
|
||||
this.sendToExtension({ type: "webviewDidLaunch" })
|
||||
|
||||
const baseSettings: RooCodeSettings = {
|
||||
commandExecutionTimeout: 30,
|
||||
browserToolEnabled: false,
|
||||
enableCheckpoints: false,
|
||||
...getProviderSettings(this.options.provider, this.options.apiKey, this.options.model),
|
||||
}
|
||||
|
||||
const settings: RooCodeSettings = this.options.nonInteractive
|
||||
? {
|
||||
autoApprovalEnabled: true,
|
||||
alwaysAllowReadOnly: true,
|
||||
alwaysAllowReadOnlyOutsideWorkspace: true,
|
||||
alwaysAllowWrite: true,
|
||||
alwaysAllowWriteOutsideWorkspace: true,
|
||||
alwaysAllowWriteProtected: true,
|
||||
alwaysAllowBrowser: true,
|
||||
alwaysAllowMcp: true,
|
||||
alwaysAllowModeSwitch: true,
|
||||
alwaysAllowSubtasks: true,
|
||||
alwaysAllowExecute: true,
|
||||
allowedCommands: ["*"],
|
||||
...baseSettings,
|
||||
}
|
||||
: {
|
||||
autoApprovalEnabled: false,
|
||||
...baseSettings,
|
||||
}
|
||||
|
||||
this.applyRuntimeSettings(settings)
|
||||
this.sendToExtension({ type: "updateSettings", updatedSettings: settings })
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 100))
|
||||
public async runTask(prompt: string): Promise<void> {
|
||||
this.sendToExtension({ type: "newTask", text: prompt })
|
||||
await this.waitForCompletion()
|
||||
}
|
||||
|
||||
private waitForCompletion(timeoutMs: number = 110000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeoutId: NodeJS.Timeout | null = null
|
||||
const timeoutMs: number = 110_000
|
||||
|
||||
const completeHandler = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const errorHandler = (error: string) => {
|
||||
|
||||
const errorHandler = (error: Error) => {
|
||||
cleanup()
|
||||
reject(new Error(error))
|
||||
}
|
||||
const timeoutHandler = () => {
|
||||
cleanup()
|
||||
reject(
|
||||
new Error(`Task completion timeout after ${timeoutMs}ms - no completion or error event received`),
|
||||
)
|
||||
reject(error)
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = null
|
||||
}
|
||||
this.off("taskComplete", completeHandler)
|
||||
this.off("taskError", errorHandler)
|
||||
|
||||
this.client.off("taskCompleted", completeHandler)
|
||||
this.client.off("error", errorHandler)
|
||||
}
|
||||
|
||||
// Set timeout to prevent indefinite hanging
|
||||
timeoutId = setTimeout(timeoutHandler, timeoutMs)
|
||||
// Set timeout to prevent indefinite hanging.
|
||||
timeoutId = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(
|
||||
new Error(`Task completion timeout after ${timeoutMs}ms - no completion or error event received`),
|
||||
)
|
||||
}, timeoutMs)
|
||||
|
||||
this.once("taskComplete", completeHandler)
|
||||
this.once("taskError", errorHandler)
|
||||
this.client.once("taskCompleted", completeHandler)
|
||||
this.client.once("error", errorHandler)
|
||||
})
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Public Agent State API (delegated to ExtensionClient)
|
||||
// Public Agent State API
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get the current agent loop state.
|
||||
*/
|
||||
getAgentState(): AgentStateInfo {
|
||||
public getAgentState(): AgentStateInfo {
|
||||
return this.client.getAgentState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the agent is currently waiting for user input.
|
||||
*/
|
||||
isWaitingForInput(): boolean {
|
||||
public isWaitingForInput(): boolean {
|
||||
return this.client.getAgentState().isWaitingForInput
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the agent is currently running.
|
||||
*/
|
||||
isAgentRunning(): boolean {
|
||||
return this.client.getAgentState().isRunning
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current agent loop state enum value.
|
||||
*/
|
||||
getAgentLoopState(): AgentLoopState {
|
||||
return this.client.getAgentState().state
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying ExtensionClient for advanced use cases.
|
||||
*/
|
||||
getExtensionClient(): ExtensionClient {
|
||||
return this.client
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OutputManager for advanced output control.
|
||||
*/
|
||||
getOutputManager(): OutputManager {
|
||||
return this.outputManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PromptManager for advanced prompting.
|
||||
*/
|
||||
getPromptManager(): PromptManager {
|
||||
return this.promptManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the AskDispatcher for advanced ask handling.
|
||||
*/
|
||||
getAskDispatcher(): AskDispatcher {
|
||||
return this.askDispatcher
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Cleanup
|
||||
// ==========================================================================
|
||||
|
|
@ -644,7 +521,6 @@ export class ExtensionHost extends EventEmitter {
|
|||
this.vscode = null
|
||||
this.extensionModule = null
|
||||
this.extensionAPI = null
|
||||
this.webviewProviders.clear()
|
||||
|
||||
// Clear globals.
|
||||
delete (global as Record<string, unknown>).vscode
|
||||
|
|
|
|||
|
|
@ -161,7 +161,21 @@ export class MessageProcessor {
|
|||
return
|
||||
}
|
||||
|
||||
const { clineMessages } = message.state
|
||||
const { clineMessages, mode } = message.state
|
||||
|
||||
// Track mode changes.
|
||||
if (mode && typeof mode === "string") {
|
||||
const previousMode = this.store.getCurrentMode()
|
||||
|
||||
if (previousMode !== mode) {
|
||||
if (this.options.debug) {
|
||||
debugLog("[MessageProcessor] Mode changed", { from: previousMode, to: mode })
|
||||
}
|
||||
|
||||
this.store.setCurrentMode(mode)
|
||||
this.emitter.emit("modeChanged", { previousMode, currentMode: mode })
|
||||
}
|
||||
}
|
||||
|
||||
if (!clineMessages) {
|
||||
if (this.options.debug) {
|
||||
|
|
@ -170,7 +184,7 @@ export class MessageProcessor {
|
|||
return
|
||||
}
|
||||
|
||||
// Get previous state for comparison
|
||||
// Get previous state for comparison.
|
||||
const previousState = this.store.getAgentState()
|
||||
|
||||
// Update the store with new messages
|
||||
|
|
|
|||
|
|
@ -48,6 +48,12 @@ export interface StoreState {
|
|||
*/
|
||||
lastUpdatedAt: number
|
||||
|
||||
/**
|
||||
* The current mode (e.g., "code", "architect", "ask").
|
||||
* Tracked from state messages received from the extension.
|
||||
*/
|
||||
currentMode: string | undefined
|
||||
|
||||
/**
|
||||
* Optional: Cache of extension state fields we might need.
|
||||
* This is a subset of the full ExtensionState.
|
||||
|
|
@ -64,6 +70,7 @@ function createInitialState(): StoreState {
|
|||
agentState: detectAgentState([]),
|
||||
isInitialized: false,
|
||||
lastUpdatedAt: Date.now(),
|
||||
currentMode: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -183,6 +190,13 @@ export class StateStore {
|
|||
return this.state.agentState.state
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current mode (e.g., "code", "architect", "ask").
|
||||
*/
|
||||
getCurrentMode(): string | undefined {
|
||||
return this.state.currentMode
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// State Updates
|
||||
// ===========================================================================
|
||||
|
|
@ -203,6 +217,7 @@ export class StateStore {
|
|||
agentState: newAgentState,
|
||||
isInitialized: true,
|
||||
lastUpdatedAt: Date.now(),
|
||||
currentMode: this.state.currentMode, // Preserve mode across message updates
|
||||
})
|
||||
|
||||
return previousAgentState
|
||||
|
|
@ -249,10 +264,27 @@ export class StateStore {
|
|||
agentState: detectAgentState([]),
|
||||
isInitialized: true, // Still initialized, just empty
|
||||
lastUpdatedAt: Date.now(),
|
||||
currentMode: this.state.currentMode, // Preserve mode when clearing task
|
||||
extensionState: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current mode.
|
||||
* Called when mode changes are detected from extension state messages.
|
||||
*
|
||||
* @param mode - The new mode value
|
||||
*/
|
||||
setCurrentMode(mode: string | undefined): void {
|
||||
if (this.state.currentMode !== mode) {
|
||||
this.updateState({
|
||||
...this.state,
|
||||
currentMode: mode,
|
||||
lastUpdatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to completely uninitialized state.
|
||||
* Called on disconnect or reset.
|
||||
|
|
@ -366,6 +398,7 @@ export function getDefaultStore(): StateStore {
|
|||
if (!defaultStore) {
|
||||
defaultStore = new StateStore()
|
||||
}
|
||||
|
||||
return defaultStore
|
||||
}
|
||||
|
||||
|
|
@ -377,5 +410,6 @@ export function resetDefaultStore(): void {
|
|||
if (defaultStore) {
|
||||
defaultStore.reset()
|
||||
}
|
||||
|
||||
defaultStore = null
|
||||
}
|
||||
|
|
|
|||
10
apps/cli/src/lib/storage/ephemeral.ts
Normal file
10
apps/cli/src/lib/storage/ephemeral.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import path from "path"
|
||||
import os from "os"
|
||||
import fs from "fs"
|
||||
|
||||
export async function createEphemeralStorageDir(): Promise<string> {
|
||||
const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`
|
||||
const tmpDir = path.join(os.tmpdir(), `roo-cli-${uniqueId}`)
|
||||
await fs.promises.mkdir(tmpDir, { recursive: true })
|
||||
return tmpDir
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./config-dir.js"
|
||||
export * from "./settings.js"
|
||||
export * from "./credentials.js"
|
||||
export * from "./ephemeral.js"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { Box, Text, useApp, useInput } from "ink"
|
||||
import { Select } from "@inkjs/ui"
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react"
|
||||
import type { WebviewMessage } from "@roo-code/types"
|
||||
|
||||
import { ExtensionHostOptions } from "@/agent/index.js"
|
||||
import { ExtensionHostInterface, ExtensionHostOptions } from "@/agent/index.js"
|
||||
|
||||
import { getGlobalCommandsForAutocomplete } from "@/lib/utils/commands.js"
|
||||
import { arePathsEqual } from "@/lib/utils/path.js"
|
||||
|
|
@ -59,15 +58,6 @@ import ScrollIndicator from "./components/ScrollIndicator.js"
|
|||
|
||||
const PICKER_HEIGHT = 10
|
||||
|
||||
interface ExtensionHostInterface {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
on(event: string, handler: (...args: any[]) => void): void
|
||||
activate(): Promise<void>
|
||||
runTask(prompt: string): Promise<void>
|
||||
sendToExtension(message: WebviewMessage): void
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
export interface TUIAppProps extends ExtensionHostOptions {
|
||||
initialPrompt: string
|
||||
debug: boolean
|
||||
|
|
|
|||
|
|
@ -184,47 +184,47 @@ describe("useCLIStore", () => {
|
|||
it("should support the full task resumption workflow", () => {
|
||||
const store = useCLIStore.getState
|
||||
|
||||
// Step 1: Initial state with task history and modes from webviewDidLaunch
|
||||
// Step 1: Initial state with task history and modes from webviewDidLaunch.
|
||||
store().setTaskHistory([{ id: "task1", task: "Previous task", workspace: "/test", ts: Date.now() }])
|
||||
store().setAvailableModes([{ key: "code", slug: "code", name: "Code" }])
|
||||
store().setAllSlashCommands([{ key: "new", name: "new", source: "global" as const }])
|
||||
|
||||
// Step 2: User starts a new task
|
||||
// Step 2: User starts a new task.
|
||||
store().setHasStartedTask(true)
|
||||
store().addMessage({ id: "1", role: "user", content: "New task" })
|
||||
store().addMessage({ id: "2", role: "assistant", content: "Working on it..." })
|
||||
store().setLoading(true)
|
||||
|
||||
// Verify current state
|
||||
// Verify current state.
|
||||
expect(store().messages.length).toBe(2)
|
||||
expect(store().hasStartedTask).toBe(true)
|
||||
|
||||
// Step 3: User selects a task from history to resume
|
||||
// This triggers resetForTaskSwitch + setIsResumingTask(true)
|
||||
// Step 3: User selects a task from history to resume.
|
||||
// This triggers resetForTaskSwitch + setIsResumingTask(true).
|
||||
store().resetForTaskSwitch()
|
||||
store().setIsResumingTask(true)
|
||||
|
||||
// Verify task-specific state is cleared but global state preserved
|
||||
// Verify task-specific state is cleared but global state preserved.
|
||||
expect(store().messages).toEqual([])
|
||||
expect(store().isLoading).toBe(false)
|
||||
expect(store().hasStartedTask).toBe(false)
|
||||
expect(store().isResumingTask).toBe(true) // Flag is set
|
||||
expect(store().taskHistory.length).toBe(1) // Preserved
|
||||
expect(store().availableModes.length).toBe(1) // Preserved
|
||||
expect(store().allSlashCommands.length).toBe(1) // Preserved
|
||||
expect(store().isResumingTask).toBe(true) // Flag is set.
|
||||
expect(store().taskHistory.length).toBe(1) // Preserved.
|
||||
expect(store().availableModes.length).toBe(1) // Preserved.
|
||||
expect(store().allSlashCommands.length).toBe(1) // Preserved.
|
||||
|
||||
// Step 4: Extension sends state message with clineMessages
|
||||
// (simulated by adding messages)
|
||||
// (simulated by adding messages).
|
||||
store().addMessage({ id: "old1", role: "user", content: "Previous task prompt" })
|
||||
store().addMessage({ id: "old2", role: "assistant", content: "Previous response" })
|
||||
|
||||
// Step 5: After processing state, isResumingTask should be cleared
|
||||
// Step 5: After processing state, isResumingTask should be cleared.
|
||||
store().setIsResumingTask(false)
|
||||
|
||||
// Final verification
|
||||
// Final verification.
|
||||
expect(store().isResumingTask).toBe(false)
|
||||
expect(store().messages.length).toBe(2)
|
||||
expect(store().taskHistory.length).toBe(1) // Still preserved
|
||||
expect(store().taskHistory.length).toBe(1) // Still preserved.
|
||||
})
|
||||
|
||||
it("should allow reading isResumingTask synchronously during message processing", () => {
|
||||
|
|
|
|||
|
|
@ -3,19 +3,10 @@ import { useApp } from "ink"
|
|||
import { randomUUID } from "crypto"
|
||||
import type { ExtensionMessage, WebviewMessage } from "@roo-code/types"
|
||||
|
||||
import { ExtensionHostOptions } from "@/agent/index.js"
|
||||
import { ExtensionHostInterface, ExtensionHostOptions } from "@/agent/index.js"
|
||||
|
||||
import { useCLIStore } from "../store.js"
|
||||
|
||||
interface ExtensionHostInterface {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
on(event: string, handler: (...args: any[]) => void): void
|
||||
activate(): Promise<void>
|
||||
runTask(prompt: string): Promise<void>
|
||||
sendToExtension(message: WebviewMessage): void
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
export interface UseExtensionHostOptions extends ExtensionHostOptions {
|
||||
initialPrompt?: string
|
||||
exitOnComplete?: boolean
|
||||
|
|
@ -89,9 +80,9 @@ export function useExtensionHost({
|
|||
hostRef.current = host
|
||||
isReadyRef.current = true
|
||||
|
||||
host.on("extensionWebviewMessage", onExtensionMessage)
|
||||
host.on("extensionWebviewMessage", (msg) => onExtensionMessage(msg as ExtensionMessage))
|
||||
|
||||
host.on("taskComplete", async () => {
|
||||
host.client.on("taskCompleted", async () => {
|
||||
setComplete(true)
|
||||
setLoading(false)
|
||||
|
||||
|
|
@ -102,8 +93,8 @@ export function useExtensionHost({
|
|||
}
|
||||
})
|
||||
|
||||
host.on("taskError", (err: string) => {
|
||||
setError(err)
|
||||
host.client.on("error", (err: Error) => {
|
||||
setError(err.message)
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
|
|
@ -111,7 +102,6 @@ export function useExtensionHost({
|
|||
|
||||
// Request initial state from extension (triggers
|
||||
// postStateToWebview which includes taskHistory).
|
||||
host.sendToExtension({ type: "webviewDidLaunch" })
|
||||
host.sendToExtension({ type: "requestCommands" })
|
||||
host.sendToExtension({ type: "requestModes" })
|
||||
|
||||
|
|
@ -136,28 +126,25 @@ export function useExtensionHost({
|
|||
}
|
||||
}, []) // Run once on mount
|
||||
|
||||
// Stable sendToExtension - uses ref to always access current host
|
||||
// This function reference never changes, preventing downstream useCallback/useMemo invalidations
|
||||
// Stable sendToExtension - uses ref to always access current host.
|
||||
// This function reference never changes, preventing downstream
|
||||
// useCallback/useMemo invalidations.
|
||||
const sendToExtension = useCallback((msg: WebviewMessage) => {
|
||||
hostRef.current?.sendToExtension(msg)
|
||||
}, [])
|
||||
|
||||
// Stable runTask - uses ref to always access current host
|
||||
// Stable runTask - uses ref to always access current host.
|
||||
const runTask = useCallback((prompt: string): Promise<void> => {
|
||||
if (!hostRef.current) {
|
||||
return Promise.reject(new Error("Extension host not ready"))
|
||||
}
|
||||
|
||||
return hostRef.current.runTask(prompt)
|
||||
}, [])
|
||||
|
||||
// Memoized return object to prevent unnecessary re-renders in consumers
|
||||
// Memoized return object to prevent unnecessary re-renders in consumers.
|
||||
return useMemo(
|
||||
() => ({
|
||||
isReady: isReadyRef.current,
|
||||
sendToExtension,
|
||||
runTask,
|
||||
cleanup,
|
||||
}),
|
||||
() => ({ isReady: isReadyRef.current, sendToExtension, runTask, cleanup }),
|
||||
[sendToExtension, runTask, cleanup],
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,14 +73,15 @@ export function useTaskSubmit({
|
|||
const globalCommand = getGlobalCommand(commandMatch[1])
|
||||
|
||||
if (globalCommand?.action === "clearTask") {
|
||||
// Reset CLI state and send clearTask to extension
|
||||
// Reset CLI state and send clearTask to extension.
|
||||
useCLIStore.getState().reset()
|
||||
// Reset component-level refs to avoid stale message tracking
|
||||
|
||||
// Reset component-level refs to avoid stale message tracking.
|
||||
seenMessageIds.current.clear()
|
||||
firstTextMessageSkipped.current = false
|
||||
sendToExtension({ type: "clearTask" })
|
||||
// Re-request state, commands and modes since reset() cleared them
|
||||
sendToExtension({ type: "webviewDidLaunch" })
|
||||
|
||||
// Re-request state, commands and modes since reset() cleared them.
|
||||
sendToExtension({ type: "requestCommands" })
|
||||
sendToExtension({ type: "requestModes" })
|
||||
return
|
||||
|
|
|
|||
|
|
@ -80,6 +80,9 @@ export {
|
|||
type FileStat,
|
||||
type Terminal,
|
||||
type CancellationToken,
|
||||
type IExtensionHost,
|
||||
type ExtensionHostEventMap,
|
||||
type ExtensionHostEventName,
|
||||
} from "./vscode.js"
|
||||
|
||||
// Export utilities
|
||||
|
|
|
|||
89
packages/vscode-shim/src/interfaces/extension-host.ts
Normal file
89
packages/vscode-shim/src/interfaces/extension-host.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* Interface defining the contract that an ExtensionHost must implement
|
||||
* to work with the vscode-shim WindowAPI.
|
||||
*
|
||||
* This interface is used implicitly by WindowAPI when accessing global.__extensionHost.
|
||||
* The ExtensionHost implementation (e.g., in apps/cli) must satisfy this contract.
|
||||
*/
|
||||
|
||||
import type { WebviewViewProvider } from "./webview.js"
|
||||
|
||||
/**
|
||||
* Core event map for ExtensionHost communication.
|
||||
* Maps event names to their payload types.
|
||||
*
|
||||
* - "extensionWebviewMessage": Messages from the extension to the webview/CLI
|
||||
* - "webviewMessage": Messages from the webview/CLI to the extension
|
||||
*/
|
||||
export interface ExtensionHostEventMap {
|
||||
extensionWebviewMessage: unknown
|
||||
webviewMessage: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Allowed event names for ExtensionHost communication.
|
||||
*/
|
||||
export type ExtensionHostEventName = keyof ExtensionHostEventMap
|
||||
|
||||
/**
|
||||
* ExtensionHost interface for bridging the vscode-shim with the actual extension host.
|
||||
*
|
||||
* The ExtensionHost acts as a message broker between the extension and the CLI/webview,
|
||||
* providing event-based communication and webview provider registration.
|
||||
*
|
||||
* @template TEventMap - Event map type that must include the core ExtensionHostEventMap events.
|
||||
* Implementations can extend this with additional events.
|
||||
*/
|
||||
export interface IExtensionHost<TEventMap extends ExtensionHostEventMap = ExtensionHostEventMap> {
|
||||
/**
|
||||
* Register a webview view provider with a specific view ID.
|
||||
* Called by WindowAPI.registerWebviewViewProvider to allow the extension host
|
||||
* to track registered providers.
|
||||
*
|
||||
* @param viewId - The unique identifier for the webview view
|
||||
* @param provider - The webview view provider to register
|
||||
*/
|
||||
registerWebviewProvider(viewId: string, provider: WebviewViewProvider): void
|
||||
|
||||
/**
|
||||
* Unregister a previously registered webview view provider.
|
||||
* Called when disposing of a webview registration.
|
||||
*
|
||||
* @param viewId - The unique identifier of the webview view to unregister
|
||||
*/
|
||||
unregisterWebviewProvider(viewId: string): void
|
||||
|
||||
/**
|
||||
* Check if the extension host is in its initial setup phase.
|
||||
* Used to determine if certain actions should be deferred until setup completes.
|
||||
*
|
||||
* @returns true if initial setup is in progress, false otherwise
|
||||
*/
|
||||
isInInitialSetup(): boolean
|
||||
|
||||
/**
|
||||
* Mark the webview as ready, signaling that initial setup has completed.
|
||||
* This should be called after resolveWebviewView completes successfully.
|
||||
*/
|
||||
markWebviewReady(): void
|
||||
|
||||
/**
|
||||
* Emit an event to registered listeners.
|
||||
* Used for forwarding messages from the extension to the webview/CLI.
|
||||
*
|
||||
* @param event - The event name to emit
|
||||
* @param message - The message payload to send with the event
|
||||
* @returns true if the event had listeners, false otherwise
|
||||
*/
|
||||
emit<K extends keyof TEventMap>(event: K, message: TEventMap[K]): boolean
|
||||
|
||||
/**
|
||||
* Register a listener for an event.
|
||||
* Used for receiving messages from the webview/CLI to the extension.
|
||||
*
|
||||
* @param event - The event name to listen for
|
||||
* @param listener - The callback function to invoke when the event is emitted
|
||||
* @returns The ExtensionHost instance for chaining
|
||||
*/
|
||||
on<K extends keyof TEventMap>(event: K, listener: (message: TEventMap[K]) => void): this
|
||||
}
|
||||
|
|
@ -129,6 +129,9 @@ export type {
|
|||
UriHandler,
|
||||
} from "./interfaces/webview.js"
|
||||
|
||||
// Extension host interface
|
||||
export type { IExtensionHost, ExtensionHostEventMap, ExtensionHostEventName } from "./interfaces/extension-host.js"
|
||||
|
||||
// Workspace interfaces
|
||||
export type {
|
||||
WorkspaceConfiguration,
|
||||
|
|
|
|||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
|
|
@ -109,6 +109,9 @@ importers:
|
|||
ink:
|
||||
specifier: ^6.6.0
|
||||
version: 6.6.0(@types/react@18.3.23)(react@19.2.3)
|
||||
p-wait-for:
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.2
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.2.3
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue