mirror of
https://github.com/cline/cline.git
synced 2026-09-09 15:02:23 +08:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
903dd0ec1c | ||
|
|
89287dbcf6 | ||
|
|
07a2d1721e | ||
|
|
7f3974a827 | ||
|
|
d40ab56aff | ||
|
|
bbdf445db7 | ||
|
|
d992a3bf21 | ||
|
|
ace95988f8 | ||
|
|
54726f1677 | ||
|
|
6308fef0a9 | ||
|
|
b3fc79b8ce | ||
|
|
ff05ec3bbe | ||
|
|
bde7049c01 | ||
|
|
7cd06744ad | ||
|
|
c88d3238cf | ||
|
|
852ca2348f | ||
|
|
7091ccf2c7 | ||
|
|
ad6c33ac5b | ||
|
|
71e312e92a | ||
|
|
5903840f79 |
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "PostToolUse running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "PostToolUse response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "PostToolUse hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "PreToolUse running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "PreToolUse response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "PreToolUse hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "TaskCancel running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "TaskCancel response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "TaskCancel hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "TaskResume running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "TaskResume response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "TaskResume hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "TaskStart running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "TaskStart response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "TaskStart hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "UserPromptSubmit running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "UserPromptSubmit response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "UserPromptSubmit hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -121,11 +121,6 @@ ERROR_SERVICE_API_KEY=your-posthog-error-tracking-api-key
|
||||
# GRPC_RECORDER_ENABLED=true
|
||||
# GRPC_RECORDER_FILE_NAME=test-recording
|
||||
|
||||
# Remote-workspace latency debugging / fallback flags
|
||||
# CLINE_DISABLE_PRESENTATION_SCHEDULER=true
|
||||
# CLINE_DISABLE_EPHEMERAL_MESSAGE_PERSISTENCE=true
|
||||
# CLINE_DISABLE_TASK_UI_DELTA_SYNC=true
|
||||
|
||||
# Test mode
|
||||
# E2E_TEST=true
|
||||
# IS_TEST=true
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## [3.75.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Latency improvements for remote workspaces
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stabilize flaky hooks tests
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove example hooks in favor of reading the docs
|
||||
|
||||
## [3.74.0]
|
||||
|
||||
### Added
|
||||
- Implement dynamic free model detection for Cline API
|
||||
- Add file read deduplication cache to prevent repeated reads
|
||||
- Add feature tips tooltip during thinking state
|
||||
|
||||
### Fixed
|
||||
- Replace error message when not logged in to Cline
|
||||
- Align ClineRulesToggleModal padding with ServersToggleModal
|
||||
- Skip WebP for GLM and Devstral models running through llama.cpp
|
||||
- Respect user-configured context window in LiteLLM getModel()
|
||||
- Honor explicit model IDs outside static catalog in W&B provider
|
||||
- Add missing Fireworks serverless models and pricing
|
||||
|
||||
## [3.73.0]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
# cline
|
||||
|
||||
## [2.9.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Latency improvements for remote workspaces
|
||||
|
||||
## [2.8.2]
|
||||
|
||||
### Fixed
|
||||
- Use `kanban@latest` in `cline kanban` to always fetch the newest version
|
||||
|
||||
## [2.8.1]
|
||||
|
||||
### Added
|
||||
- Implement dynamic free model detection for Cline API
|
||||
- Add file read deduplication cache to prevent repeated reads
|
||||
- Add feature tips tooltip during thinking state
|
||||
|
||||
### Fixed
|
||||
- Fix flaky CLI Enter-key handling across Windows/test environments
|
||||
- Replace error message when not logged in to Cline
|
||||
- Align ClineRulesToggleModal padding with ServersToggleModal
|
||||
- Skip WebP for GLM and Devstral models running through llama.cpp
|
||||
- Respect user-configured context window in LiteLLM getModel()
|
||||
- Honor explicit model IDs outside static catalog in W&B provider
|
||||
- Add missing Fireworks serverless models and pricing
|
||||
|
||||
## [2.8.0]
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cline",
|
||||
"version": "2.8.0",
|
||||
"version": "2.9.0",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"main": "dist/lib.mjs",
|
||||
"types": "dist/lib.d.ts",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Box, Text, useInput } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
|
||||
|
||||
interface ApiKeyInputProps {
|
||||
providerName: string
|
||||
@@ -39,7 +39,7 @@ export const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
onSubmit(value)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useTaskController } from "../context/TaskContext"
|
||||
import { useLastCompletedAskMessage } from "../hooks/useStateSubscriber"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
|
||||
import { jsonParseSafe } from "../utils/parser"
|
||||
|
||||
interface AskPromptProps {
|
||||
@@ -136,7 +136,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
} else if (promptType === "options") {
|
||||
// Number selection for options, or free text input
|
||||
const parts = jsonParseSafe(text, { options: [] as string[] })
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
// Submit free text on Enter
|
||||
if (textInput.trim()) {
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
@@ -145,7 +145,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
// Check if it's a number for option selection (only when no text typed yet)
|
||||
const num = parseInt(input, 10)
|
||||
const num = Number.parseInt(input, 10)
|
||||
if (textInput === "" && !Number.isNaN(num) && num >= 1 && num <= parts.options.length) {
|
||||
const selectedOption = parts.options[num - 1]
|
||||
sendResponse("messageResponse", selectedOption)
|
||||
@@ -156,7 +156,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
}
|
||||
} else if (promptType === "text") {
|
||||
// Text input mode
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
// Submit on Enter
|
||||
if (textInput.trim()) {
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
@@ -169,7 +169,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
}
|
||||
} else if (promptType === "plan_mode_text") {
|
||||
// Plan mode text input - allows text response or toggle to Act mode
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
// Submit on Enter
|
||||
if (textInput.trim()) {
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
@@ -185,7 +185,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
}
|
||||
} else if (promptType === "completion") {
|
||||
// Task completed - allow follow-up question or exit
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
if (textInput.trim()) {
|
||||
// Send follow-up question
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
@@ -401,43 +401,42 @@ function getCliMessagePrefixIcon(message: ClineMessage): string {
|
||||
default:
|
||||
return "❔"
|
||||
}
|
||||
} else {
|
||||
switch (message.say) {
|
||||
case "task":
|
||||
return "📋"
|
||||
case "error":
|
||||
return "❌"
|
||||
case "text":
|
||||
return "💬"
|
||||
case "reasoning":
|
||||
return "🧠"
|
||||
case "completion_result":
|
||||
return "✅"
|
||||
case "user_feedback":
|
||||
return "👤"
|
||||
case "command":
|
||||
case "command_output":
|
||||
return "⚙️"
|
||||
case "tool":
|
||||
return "🔧"
|
||||
case "browser_action":
|
||||
case "browser_action_launch":
|
||||
case "browser_action_result":
|
||||
return "🌐"
|
||||
case "mcp_server_request_started":
|
||||
case "mcp_server_response":
|
||||
return "🔌"
|
||||
case "api_req_started":
|
||||
case "api_req_finished":
|
||||
return "🔄"
|
||||
case "checkpoint_created":
|
||||
return "💾"
|
||||
case "info":
|
||||
return "ℹ️"
|
||||
case "generate_explanation":
|
||||
return "📝"
|
||||
default:
|
||||
return " "
|
||||
}
|
||||
}
|
||||
switch (message.say) {
|
||||
case "task":
|
||||
return "📋"
|
||||
case "error":
|
||||
return "❌"
|
||||
case "text":
|
||||
return "💬"
|
||||
case "reasoning":
|
||||
return "🧠"
|
||||
case "completion_result":
|
||||
return "✅"
|
||||
case "user_feedback":
|
||||
return "👤"
|
||||
case "command":
|
||||
case "command_output":
|
||||
return "⚙️"
|
||||
case "tool":
|
||||
return "🔧"
|
||||
case "browser_action":
|
||||
case "browser_action_launch":
|
||||
case "browser_action_result":
|
||||
return "🌐"
|
||||
case "mcp_server_request_started":
|
||||
case "mcp_server_response":
|
||||
return "🔌"
|
||||
case "api_req_started":
|
||||
case "api_req_finished":
|
||||
return "🔄"
|
||||
case "checkpoint_created":
|
||||
return "💾"
|
||||
case "info":
|
||||
return "ℹ️"
|
||||
case "generate_explanation":
|
||||
return "📝"
|
||||
default:
|
||||
return " "
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,14 @@ import { useClineFeaturedModels } from "../hooks/useClineFeaturedModels"
|
||||
import { useOcaAuth } from "../hooks/useOcaAuth"
|
||||
import { useScrollableList } from "../hooks/useScrollableList"
|
||||
import { type DetectedSources, detectImportSources, type ImportSource } from "../utils/import-configs"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { applyBedrockConfig, applyProviderConfig } from "../utils/provider-config"
|
||||
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
|
||||
import { applyBedrockConfig, applyProviderConfig, applyVertexConfig } from "../utils/provider-config"
|
||||
import { useValidProviders } from "../utils/providers"
|
||||
import { ApiKeyInput } from "./ApiKeyInput"
|
||||
import { StaticRobotFrame } from "./AsciiMotionCli"
|
||||
import { BedrockCustomModelFlow } from "./BedrockCustomModelFlow"
|
||||
import { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
|
||||
import { type VertexConfig, VertexSetup } from "./VertexSetup"
|
||||
import {
|
||||
FeaturedModelPicker,
|
||||
getFeaturedModelAtIndex,
|
||||
@@ -52,6 +53,7 @@ type AuthStep =
|
||||
| "cline_model"
|
||||
| "openai_codex_auth"
|
||||
| "bedrock"
|
||||
| "vertex"
|
||||
| "import"
|
||||
| "bedrock_custom"
|
||||
|
||||
@@ -79,12 +81,12 @@ const Select: React.FC<{
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
useInput(
|
||||
(_, key) => {
|
||||
(input, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
onSelect(items[selectedIndex].value)
|
||||
}
|
||||
},
|
||||
@@ -130,7 +132,7 @@ const TextInput: React.FC<{
|
||||
return
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
onSubmit(value)
|
||||
} else if (key.backspace || key.delete) {
|
||||
onChange(value.slice(0, -1))
|
||||
@@ -177,6 +179,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
const [importSources, setImportSources] = useState<DetectedSources>({ codex: false, opencode: false })
|
||||
const [importSource, setImportSource] = useState<ImportSource | null>(null)
|
||||
const [bedrockConfig, setBedrockConfig] = useState<BedrockConfig | null>(null)
|
||||
const [vertexConfig, setVertexConfig] = useState<VertexConfig | null>(null)
|
||||
|
||||
// OCA auth hook - enabled when step is oca_auth
|
||||
const handleOcaAuthSuccess = useCallback(async () => {
|
||||
@@ -369,16 +372,23 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
const handleProviderSelect = useCallback(
|
||||
(value: string) => {
|
||||
setSelectedProvider(value)
|
||||
if (value === "oca") {
|
||||
// Show employee check screen before starting auth
|
||||
setStep("oca_employee_check")
|
||||
} else if (value === "openai-codex") {
|
||||
setStep("openai_codex_auth")
|
||||
startOpenAiCodexAuth()
|
||||
} else if (value === "bedrock") {
|
||||
setStep("bedrock")
|
||||
} else {
|
||||
setStep("apikey")
|
||||
switch (value) {
|
||||
case "oca":
|
||||
setStep("oca_employee_check")
|
||||
break
|
||||
case "openai-codex":
|
||||
setStep("openai_codex_auth")
|
||||
startOpenAiCodexAuth()
|
||||
break
|
||||
case "bedrock":
|
||||
setStep("bedrock")
|
||||
break
|
||||
case "vertex":
|
||||
setStep("vertex")
|
||||
break
|
||||
default:
|
||||
setStep("apikey")
|
||||
break
|
||||
}
|
||||
},
|
||||
[startOcaAuth, startOpenAiCodexAuth],
|
||||
@@ -434,6 +444,12 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
modelId: model,
|
||||
controller,
|
||||
})
|
||||
} else if (selectedProvider === "vertex" && vertexConfig) {
|
||||
await applyVertexConfig({
|
||||
vertexConfig,
|
||||
modelId: model,
|
||||
controller,
|
||||
})
|
||||
} else {
|
||||
await applyProviderConfig({
|
||||
providerId: selectedProvider,
|
||||
@@ -454,7 +470,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setStep("error")
|
||||
}
|
||||
},
|
||||
[selectedProvider, apiKey, bedrockConfig, controller],
|
||||
[selectedProvider, apiKey, bedrockConfig, vertexConfig, controller],
|
||||
)
|
||||
|
||||
const handleModelIdSubmit = useCallback(
|
||||
@@ -502,6 +518,11 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setStep("modelid")
|
||||
}, [])
|
||||
|
||||
const handleVertexComplete = useCallback((config: VertexConfig) => {
|
||||
setVertexConfig(config)
|
||||
setStep("modelid")
|
||||
}, [])
|
||||
|
||||
const handleImportComplete = useCallback(() => {
|
||||
setStep("success")
|
||||
}, [])
|
||||
@@ -567,18 +588,17 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setApiKey("")
|
||||
setStep("provider")
|
||||
break
|
||||
case "modelid":
|
||||
case "modelid": {
|
||||
setModelId("")
|
||||
// Go back to cline_model if we came from there (Cline provider)
|
||||
if (selectedProvider === "cline") {
|
||||
setStep("cline_model")
|
||||
} else if (selectedProvider === "bedrock") {
|
||||
// Bedrock skips the API key step — go back to Bedrock setup
|
||||
setStep("bedrock")
|
||||
} else {
|
||||
setStep("apikey")
|
||||
// Each provider has a different step before model selection
|
||||
const prevStep: Record<string, AuthStep> = {
|
||||
cline: "cline_model",
|
||||
bedrock: "bedrock",
|
||||
vertex: "vertex",
|
||||
}
|
||||
setStep(prevStep[selectedProvider] ?? "apikey")
|
||||
break
|
||||
}
|
||||
case "baseurl":
|
||||
setBaseUrl("")
|
||||
setStep("modelid")
|
||||
@@ -604,6 +624,10 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setBedrockConfig(null)
|
||||
setStep("provider")
|
||||
break
|
||||
case "vertex":
|
||||
setVertexConfig(null)
|
||||
setStep("provider")
|
||||
break
|
||||
case "import":
|
||||
setImportSource(null)
|
||||
setStep("menu")
|
||||
@@ -786,6 +810,18 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
/>
|
||||
)
|
||||
|
||||
case "vertex":
|
||||
return (
|
||||
<VertexSetup
|
||||
isActive={step === "vertex"}
|
||||
onCancel={() => {
|
||||
setVertexConfig(null)
|
||||
setStep("provider")
|
||||
}}
|
||||
onComplete={handleVertexComplete}
|
||||
/>
|
||||
)
|
||||
|
||||
case "bedrock_custom":
|
||||
return (
|
||||
<BedrockCustomModelFlow
|
||||
@@ -837,6 +873,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
"cline_model",
|
||||
"openai_codex_auth",
|
||||
"bedrock",
|
||||
"vertex",
|
||||
"error",
|
||||
].includes(step)
|
||||
|
||||
@@ -853,7 +890,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setMenuIndex((prev) => (prev > 0 ? prev - 1 : mainMenuItems.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setMenuIndex((prev) => (prev < mainMenuItems.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
handleMainMenuSelect(mainMenuItems[menuIndex].value)
|
||||
}
|
||||
} else if (step === "provider") {
|
||||
@@ -861,7 +898,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setProviderIndex((prev) => (prev > 0 ? prev - 1 : providerItems.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setProviderIndex((prev) => (prev < providerItems.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
if (providerItems[providerIndex]) {
|
||||
handleProviderSelect(providerItems[providerIndex].value)
|
||||
}
|
||||
@@ -877,7 +914,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setClineModelIndex((prev) => (prev > 0 ? prev - 1 : maxIndex))
|
||||
} else if (key.downArrow) {
|
||||
setClineModelIndex((prev) => (prev < maxIndex ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
if (isBrowseAllSelected(clineModelIndex, featuredModels)) {
|
||||
setStep("modelid")
|
||||
} else {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isEnterKey } from "../utils/input"
|
||||
import { getModelList } from "./ModelPicker"
|
||||
import { SearchableList } from "./SearchableList"
|
||||
|
||||
@@ -43,7 +44,7 @@ export const BedrockCustomModelFlow: React.FC<BedrockCustomModelFlowProps> = ({
|
||||
if (step === "arn_input") {
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
handleArnSubmit()
|
||||
} else if (key.backspace || key.delete) {
|
||||
setCustomArn((prev) => prev.slice(0, -1))
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isEnterKey } from "../utils/input"
|
||||
|
||||
export type RestoreType = "task" | "workspace" | "taskAndWorkspace"
|
||||
|
||||
@@ -101,7 +102,7 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
|
||||
setSelectedCheckpoint((i) => Math.max(0, i - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedCheckpoint((i) => Math.min(checkpoints.length - 1, i + 1))
|
||||
} else if (key.return && checkpoints.length > 0) {
|
||||
} else if (isEnterKey(input, key) && checkpoints.length > 0) {
|
||||
setStage("restoreType")
|
||||
}
|
||||
} else if (stage === "restoreType") {
|
||||
@@ -109,7 +110,7 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
|
||||
setSelectedRestoreType((i) => Math.max(0, i - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedRestoreType((i) => Math.min(RESTORE_TYPE_OPTIONS.length - 1, i + 1))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
const checkpoint = checkpoints[selectedCheckpoint]
|
||||
const restoreType = RESTORE_TYPE_OPTIONS[selectedRestoreType]
|
||||
if (checkpoint && restoreType) {
|
||||
@@ -120,7 +121,7 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
|
||||
|
||||
// Quick number selection for checkpoints
|
||||
if (stage === "checkpoint") {
|
||||
const num = parseInt(input, 10)
|
||||
const num = Number.parseInt(input, 10)
|
||||
if (!Number.isNaN(num) && num >= 1 && num <= checkpoints.length) {
|
||||
setSelectedCheckpoint(num - 1)
|
||||
setStage("restoreType")
|
||||
|
||||
@@ -13,7 +13,7 @@ import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
|
||||
import { Panel } from "./Panel"
|
||||
|
||||
interface TaskHistoryItem {
|
||||
@@ -142,7 +142,7 @@ export const HistoryPanelContent: React.FC<HistoryPanelContentProps> = ({ onClos
|
||||
return
|
||||
}
|
||||
|
||||
if (key.return && items[selectedIndex]) {
|
||||
if (isEnterKey(input, key) && items[selectedIndex]) {
|
||||
handleSelect(items[selectedIndex])
|
||||
return
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
|
||||
import { StringRequest } from "@/shared/proto/cline/common"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
import { isEnterKey } from "../utils/input"
|
||||
|
||||
interface TaskHistoryItem {
|
||||
id: string
|
||||
@@ -40,7 +41,7 @@ interface HistoryViewProps {
|
||||
/**
|
||||
* Format separator
|
||||
*/
|
||||
function formatSeparator(char: string = "─", width: number = 80): string {
|
||||
function formatSeparator(char = "─", width = 80): string {
|
||||
return char.repeat(Math.max(width, 10))
|
||||
}
|
||||
|
||||
@@ -111,7 +112,7 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
|
||||
setSelectedIndex((prev) => Math.max(0, prev - 1))
|
||||
} else if (key.downArrow || input === "j") {
|
||||
setSelectedIndex((prev) => Math.min(pageItems.length - 1, prev + 1))
|
||||
} else if (key.return && pageItems[selectedIndex]) {
|
||||
} else if (isEnterKey(input, key) && pageItems[selectedIndex]) {
|
||||
onSelect(pageItems[selectedIndex])
|
||||
} else if (key.leftArrow && hasPrevPage) {
|
||||
handlePageChange(currentPage - 1)
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
importFromCodex,
|
||||
importFromOpenCode,
|
||||
} from "../utils/import-configs"
|
||||
import { isEnterKey } from "../utils/input"
|
||||
import { applyProviderConfig } from "../utils/provider-config"
|
||||
|
||||
type ImportStep = "select" | "confirm" | "saving" | "error"
|
||||
@@ -95,13 +96,13 @@ export const ImportView: React.FC<ImportViewProps> = ({ source, onComplete, onCa
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : keys.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) => (prev < keys.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
setStep("confirm")
|
||||
}
|
||||
} else if (step === "confirm") {
|
||||
if (key.upArrow || key.downArrow) {
|
||||
setConfirmIndex((prev) => (prev === 0 ? 1 : 0))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
if (confirmIndex === 0) {
|
||||
handleConfirm()
|
||||
} else {
|
||||
@@ -109,7 +110,7 @@ export const ImportView: React.FC<ImportViewProps> = ({ source, onComplete, onCa
|
||||
}
|
||||
}
|
||||
} else if (step === "error") {
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Box, Text, useInput } from "ink"
|
||||
import React, { useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isEnterKey } from "../utils/input"
|
||||
|
||||
export interface SelectListItem {
|
||||
id: string
|
||||
@@ -31,7 +32,7 @@ export function SelectList<T extends SelectListItem>({ items, onSelect, isActive
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : items.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((i) => (i < items.length - 1 ? i + 1 : 0))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(_input, key)) {
|
||||
const item = items[selectedIndex]
|
||||
if (item) {
|
||||
onSelect(item)
|
||||
|
||||
@@ -28,10 +28,11 @@ import { useStdinContext } from "../context/StdinContext"
|
||||
import { useClineFeaturedModels } from "../hooks/useClineFeaturedModels"
|
||||
import { useOcaAuth } from "../hooks/useOcaAuth"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { applyBedrockConfig, applyProviderConfig } from "../utils/provider-config"
|
||||
import { applyBedrockConfig, applyProviderConfig, applyVertexConfig } from "../utils/provider-config"
|
||||
import { ApiKeyInput } from "./ApiKeyInput"
|
||||
import { BedrockCustomModelFlow } from "./BedrockCustomModelFlow"
|
||||
import { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
|
||||
import { type VertexConfig, VertexSetup } from "./VertexSetup"
|
||||
import { Checkbox } from "./Checkbox"
|
||||
import {
|
||||
FeaturedModelPicker,
|
||||
@@ -167,6 +168,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
const [isPickingLanguage, setIsPickingLanguage] = useState(false)
|
||||
const [isEnteringApiKey, setIsEnteringApiKey] = useState(false)
|
||||
const [isConfiguringBedrock, setIsConfiguringBedrock] = useState(false)
|
||||
const [isConfiguringVertex, setIsConfiguringVertex] = useState(false)
|
||||
const [isWaitingForCodexAuth, setIsWaitingForCodexAuth] = useState(false)
|
||||
const [isShowingOcaEmployeeCheck, setIsShowingOcaEmployeeCheck] = useState(false)
|
||||
const [codexAuthError, setCodexAuthError] = useState<string | null>(null)
|
||||
@@ -1150,6 +1152,14 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
// Special handling for Vertex - needs project ID and region
|
||||
if (providerId === "vertex") {
|
||||
setPendingProvider(providerId)
|
||||
setIsPickingProvider(false)
|
||||
setIsConfiguringVertex(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this provider needs an API key
|
||||
const keyField = ProviderToApiKeyMap[providerId as ApiProvider]
|
||||
if (keyField) {
|
||||
@@ -1205,6 +1215,21 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
[controller, refreshModelIds],
|
||||
)
|
||||
|
||||
// Handle Vertex configuration complete
|
||||
const handleVertexComplete = useCallback(
|
||||
(vertexConfig: VertexConfig) => {
|
||||
// Update UI state first for responsiveness
|
||||
setProvider("vertex")
|
||||
refreshModelIds()
|
||||
setIsConfiguringVertex(false)
|
||||
setPendingProvider(null)
|
||||
|
||||
// Apply config and rebuild API handler in background
|
||||
applyVertexConfig({ vertexConfig, controller })
|
||||
},
|
||||
[controller, refreshModelIds],
|
||||
)
|
||||
|
||||
// Handle saving edited value
|
||||
const handleSave = useCallback(() => {
|
||||
const item = items[selectedIndex]
|
||||
@@ -1433,7 +1458,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
return
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported && !isEnteringApiKey && !isConfiguringBedrock && !isShowingOcaEmployeeCheck },
|
||||
{ isActive: isRawModeSupported && !isEnteringApiKey && !isConfiguringBedrock && !isConfiguringVertex && !isShowingOcaEmployeeCheck },
|
||||
)
|
||||
|
||||
// Render content
|
||||
@@ -1484,6 +1509,19 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (isConfiguringVertex) {
|
||||
return (
|
||||
<VertexSetup
|
||||
isActive={isConfiguringVertex}
|
||||
onCancel={() => {
|
||||
setIsConfiguringVertex(false)
|
||||
setPendingProvider(null)
|
||||
}}
|
||||
onComplete={handleVertexComplete}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isWaitingForCodexAuth) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
@@ -1814,6 +1852,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
isPickingLanguage ||
|
||||
isEnteringApiKey ||
|
||||
isConfiguringBedrock ||
|
||||
isConfiguringVertex ||
|
||||
isWaitingForCodexAuth ||
|
||||
!!codexAuthError ||
|
||||
isPickingOrganization ||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { refreshSkills } from "@/core/controller/file/refreshSkills"
|
||||
import { toggleSkill } from "@/core/controller/file/toggleSkill"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
|
||||
import { Panel } from "./Panel"
|
||||
|
||||
const SKILLS_MARKETPLACE_URL = "https://skills.sh/"
|
||||
@@ -143,7 +143,7 @@ export const SkillsPanelContent: React.FC<SkillsPanelContentProps> = ({ controll
|
||||
}
|
||||
|
||||
// Actions
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
if (isMarketplaceSelected) {
|
||||
openMarketplace()
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import VertexData from "@shared/providers/vertex.json"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useMemo, useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useScrollableList } from "../hooks/useScrollableList"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
|
||||
type VertexStep = "project_id" | "region"
|
||||
|
||||
export interface VertexConfig {
|
||||
vertexProjectId: string
|
||||
vertexRegion: string
|
||||
}
|
||||
|
||||
interface VertexSetupProps {
|
||||
isActive: boolean
|
||||
onComplete: (config: VertexConfig) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const VERTEX_REGIONS = VertexData.regions
|
||||
const REGION_ROWS = 8
|
||||
|
||||
/**
|
||||
* Inline text input for the project ID field
|
||||
*/
|
||||
const ProjectIdInput: React.FC<{
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: () => void
|
||||
onCancel: () => void
|
||||
isActive: boolean
|
||||
placeholder?: string
|
||||
hint?: string
|
||||
}> = ({ label, value, onChange, onSubmit, onCancel, isActive, placeholder, hint }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (isMouseEscapeSequence(input)) return
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
} else if (key.return) {
|
||||
onSubmit()
|
||||
} else if (key.backspace || key.delete) {
|
||||
onChange(value.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
onChange(value + input)
|
||||
}
|
||||
},
|
||||
{ isActive: isActive && isRawModeSupported },
|
||||
)
|
||||
|
||||
const description = hint || (placeholder ? `e.g. ${placeholder}` : undefined)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">{label}</Text>
|
||||
{description && <Text color="gray">{description}</Text>}
|
||||
<Text> </Text>
|
||||
<Box>
|
||||
<Text color="white">{value}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Enter to continue, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const VertexSetup: React.FC<VertexSetupProps> = ({ isActive, onComplete, onCancel }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
|
||||
const [step, setStep] = useState<VertexStep>("project_id")
|
||||
const [projectId, setProjectId] = useState("")
|
||||
const [regionSearch, setRegionSearch] = useState("")
|
||||
const [regionIndex, setRegionIndex] = useState(0)
|
||||
|
||||
const filteredRegions = useMemo(() => {
|
||||
const search = regionSearch.toLowerCase().trim()
|
||||
if (!search) {
|
||||
return VERTEX_REGIONS
|
||||
}
|
||||
return VERTEX_REGIONS.filter((r) => r.toLowerCase().includes(search))
|
||||
}, [regionSearch])
|
||||
|
||||
const {
|
||||
visibleStart: regionVisibleStart,
|
||||
visibleCount: regionVisibleCount,
|
||||
showTopIndicator: showRegionTop,
|
||||
showBottomIndicator: showRegionBottom,
|
||||
} = useScrollableList(filteredRegions.length, regionIndex, REGION_ROWS)
|
||||
|
||||
const visibleRegions = useMemo(
|
||||
() => filteredRegions.slice(regionVisibleStart, regionVisibleStart + regionVisibleCount),
|
||||
[filteredRegions, regionVisibleStart, regionVisibleCount],
|
||||
)
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
switch (step) {
|
||||
case "project_id":
|
||||
onCancel()
|
||||
break
|
||||
case "region":
|
||||
setStep("project_id")
|
||||
break
|
||||
}
|
||||
}, [step, onCancel])
|
||||
|
||||
const getSelectedRegion = useCallback(() => {
|
||||
if (filteredRegions.length > 0 && regionIndex >= 0 && regionIndex < filteredRegions.length) {
|
||||
return filteredRegions[regionIndex]
|
||||
}
|
||||
return regionSearch.trim() || "us-east5"
|
||||
}, [filteredRegions, regionIndex, regionSearch])
|
||||
|
||||
const finish = useCallback(() => {
|
||||
const config: VertexConfig = {
|
||||
vertexProjectId: projectId.trim(),
|
||||
vertexRegion: getSelectedRegion(),
|
||||
}
|
||||
onComplete(config)
|
||||
}, [projectId, getSelectedRegion, onComplete])
|
||||
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (isMouseEscapeSequence(input)) return
|
||||
|
||||
if (step === "region") {
|
||||
if (key.escape) {
|
||||
goBack()
|
||||
} else if (key.upArrow && filteredRegions.length > 0) {
|
||||
setRegionIndex((prev) => (prev > 0 ? prev - 1 : filteredRegions.length - 1))
|
||||
} else if (key.downArrow && filteredRegions.length > 0) {
|
||||
setRegionIndex((prev) => (prev < filteredRegions.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return && (filteredRegions.length > 0 || regionSearch.trim())) {
|
||||
finish()
|
||||
} else if (key.backspace || key.delete) {
|
||||
setRegionSearch((prev) => prev.slice(0, -1))
|
||||
setRegionIndex(0)
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
setRegionSearch((prev) => prev + input)
|
||||
setRegionIndex(0)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: isActive && isRawModeSupported && step === "region" },
|
||||
)
|
||||
|
||||
if (step === "project_id") {
|
||||
return (
|
||||
<ProjectIdInput
|
||||
hint="Your Google Cloud project ID (e.g. my-gcp-project)"
|
||||
isActive={isActive}
|
||||
label="Google Cloud Project ID"
|
||||
onCancel={goBack}
|
||||
onChange={setProjectId}
|
||||
onSubmit={() => {
|
||||
if (projectId.trim()) setStep("region")
|
||||
}}
|
||||
placeholder="my-gcp-project"
|
||||
value={projectId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
if (step === "region") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Google Cloud Region</Text>
|
||||
<Text> </Text>
|
||||
<Box>
|
||||
<Text color="gray">Search or enter custom region: </Text>
|
||||
<Text color="white">{regionSearch}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text> </Text>
|
||||
{showRegionTop && <Text color="gray">... {regionVisibleStart} more above</Text>}
|
||||
{visibleRegions.map((region, i) => {
|
||||
const actualIndex = regionVisibleStart + i
|
||||
return (
|
||||
<Box key={region}>
|
||||
<Text color={actualIndex === regionIndex ? COLORS.primaryBlue : undefined}>
|
||||
{actualIndex === regionIndex ? "❯ " : " "}
|
||||
{region}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
{showRegionBottom && (
|
||||
<Text color="gray">
|
||||
... {filteredRegions.length - regionVisibleStart - regionVisibleCount} more below
|
||||
</Text>
|
||||
)}
|
||||
<Text> </Text>
|
||||
<Text color="gray">Type to search, arrows to navigate, Enter to select, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export class CliDiffServiceClient implements DiffServiceClientInterface {
|
||||
* CLI implementation of EnvService - handles environment operations
|
||||
*/
|
||||
export class CliEnvServiceClient implements EnvServiceClientInterface {
|
||||
private clipboardContent: string = ""
|
||||
private clipboardContent = ""
|
||||
|
||||
private getTelemetrySetting(): proto.host.Setting {
|
||||
// Read from StateManager - defaults to ENABLED if not set or "unset"
|
||||
@@ -102,6 +102,8 @@ export class CliEnvServiceClient implements EnvServiceClientInterface {
|
||||
version: CLI_VERSION,
|
||||
platform: "Cline CLI - Node.js",
|
||||
clineType: ClineClient.Cli,
|
||||
// remoteName is intentionally omitted — the CLI runs locally on the user's machine.
|
||||
// If CLI-in-container scenarios arise, populate this field to enable remote cadence tuning.
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+28
-2
@@ -62,6 +62,8 @@ describe("CLI Commands", () => {
|
||||
.option("-k, --apikey <key>", "API key")
|
||||
.option("-m, --modelid <id>", "Model ID")
|
||||
.option("-b, --baseurl <url>", "Base URL")
|
||||
.option("--vertex-project-id <id>", "Google Cloud Project ID")
|
||||
.option("--vertex-region <region>", "Google Cloud Region")
|
||||
.option("-v, --verbose", "Verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
@@ -80,7 +82,7 @@ describe("CLI Commands", () => {
|
||||
|
||||
program
|
||||
.command("kanban")
|
||||
.description("Run npx kanban --agent cline")
|
||||
.description("Run npx kanban@latest --agent cline")
|
||||
.action(() => {})
|
||||
|
||||
// Default command for interactive mode
|
||||
@@ -97,7 +99,7 @@ describe("CLI Commands", () => {
|
||||
.option("--auto-condense", "Enable AI-powered context compaction instead of mechanical truncation")
|
||||
.option("--hooks-dir <path>", "Additional hooks directory")
|
||||
.option("--auto-approve-all", "Enable auto-approve all")
|
||||
.option("--kanban", "Run npx kanban --agent cline")
|
||||
.option("--kanban", "Run npx kanban@latest --agent cline")
|
||||
.action(() => {})
|
||||
})
|
||||
|
||||
@@ -340,6 +342,30 @@ describe("CLI Commands", () => {
|
||||
expect(authCmd.opts().apikey).toBe("key123")
|
||||
expect(authCmd.opts().modelid).toBe("claude-sonnet-4-20250514")
|
||||
})
|
||||
|
||||
it("should parse --vertex-project-id option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const args = ["--vertex-project-id", "my-gcp-project"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().vertexProjectId).toBe("my-gcp-project")
|
||||
})
|
||||
|
||||
it("should parse --vertex-region option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const args = ["--vertex-region", "us-east5"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().vertexRegion).toBe("us-east5")
|
||||
})
|
||||
|
||||
it("should parse vertex quick setup flags together", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const args = ["-p", "vertex", "-m", "gemini-3-flash-preview", "--vertex-project-id", "my-project", "--vertex-region", "us-east5"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().provider).toBe("vertex")
|
||||
expect(authCmd.opts().modelid).toBe("gemini-3-flash-preview")
|
||||
expect(authCmd.opts().vertexProjectId).toBe("my-project")
|
||||
expect(authCmd.opts().vertexRegion).toBe("us-east5")
|
||||
})
|
||||
})
|
||||
|
||||
describe("mcp command", () => {
|
||||
|
||||
+45
-8
@@ -256,12 +256,12 @@ function getNpxCommand(): string {
|
||||
}
|
||||
|
||||
function runKanbanAlias(): void {
|
||||
const child = spawn(getNpxCommand(), ["-y", "kanban", "--agent", "cline"], {
|
||||
const child = spawn(getNpxCommand(), ["-y", "kanban@latest", "--agent", "cline"], {
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
child.on("error", () => {
|
||||
printWarning("Failed to run 'npx kanban --agent cline'. Make sure npx is installed and available in PATH.")
|
||||
printWarning("Failed to run 'npx kanban@latest --agent cline'. Make sure npx is installed and available in PATH.")
|
||||
exit(1)
|
||||
})
|
||||
|
||||
@@ -715,9 +715,16 @@ async function showConfig(options: { config?: string }) {
|
||||
*/
|
||||
async function performQuickAuthSetup(
|
||||
ctx: CliContext,
|
||||
options: { provider: string; apikey: string; modelid: string; baseurl?: string },
|
||||
options: {
|
||||
provider: string
|
||||
apikey: string
|
||||
modelid: string
|
||||
baseurl?: string
|
||||
vertexProjectId?: string
|
||||
vertexRegion?: string
|
||||
},
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const { provider, apikey, modelid, baseurl } = options
|
||||
const { provider, apikey, modelid, baseurl, vertexProjectId, vertexRegion } = options
|
||||
|
||||
const normalizedProvider = provider.toLowerCase().trim()
|
||||
|
||||
@@ -733,6 +740,24 @@ async function performQuickAuthSetup(
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedProvider === "vertex") {
|
||||
if (!modelid || !vertexProjectId || !vertexRegion) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Vertex provider requires --modelid, --vertex-project-id, and --vertex-region flags.",
|
||||
}
|
||||
}
|
||||
const { applyVertexConfig } = await import("./utils/provider-config")
|
||||
await applyVertexConfig({
|
||||
vertexConfig: { vertexProjectId, vertexRegion },
|
||||
modelId: modelid,
|
||||
controller: ctx.controller,
|
||||
})
|
||||
StateManager.get().setGlobalState("welcomeViewCompleted", true)
|
||||
await StateManager.get().flushPendingState()
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
if (baseurl && !["openai", "openai-native"].includes(normalizedProvider)) {
|
||||
return { success: false, error: "Base URL is only supported for OpenAI and OpenAI-compatible providers" }
|
||||
}
|
||||
@@ -758,13 +783,21 @@ async function runAuth(options: {
|
||||
apikey?: string
|
||||
modelid?: string
|
||||
baseurl?: string
|
||||
vertexProjectId?: string
|
||||
vertexRegion?: string
|
||||
verbose?: boolean
|
||||
cwd?: string
|
||||
config?: string
|
||||
}) {
|
||||
const ctx = await initializeCli({ ...options, enableAuth: true })
|
||||
|
||||
const hasQuickSetupFlags = options.provider && options.apikey && options.modelid
|
||||
// Vertex uses project-id + region instead of API key (no apikey required).
|
||||
// Treat any vertex provider invocation with partial flags as a quick-setup attempt
|
||||
// so the error path inside performQuickAuthSetup is reached instead of silently
|
||||
// falling to interactive mode (which hangs in non-TTY/CI environments).
|
||||
const isVertexProvider = options.provider?.toLowerCase() === "vertex"
|
||||
const isVertexAttempt = isVertexProvider && (!!options.modelid || !!options.vertexProjectId || !!options.vertexRegion)
|
||||
const hasQuickSetupFlags = isVertexAttempt || (options.provider && options.apikey && options.modelid)
|
||||
|
||||
telemetryService.captureHostEvent("auth_command", hasQuickSetupFlags ? "quick_setup" : "interactive")
|
||||
|
||||
@@ -772,9 +805,11 @@ async function runAuth(options: {
|
||||
if (hasQuickSetupFlags) {
|
||||
const result = await performQuickAuthSetup(ctx, {
|
||||
provider: options.provider!,
|
||||
apikey: options.apikey!,
|
||||
apikey: options.apikey || "",
|
||||
modelid: options.modelid!,
|
||||
baseurl: options.baseurl,
|
||||
vertexProjectId: options.vertexProjectId,
|
||||
vertexRegion: options.vertexRegion,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
@@ -871,6 +906,8 @@ program
|
||||
.option("-k, --apikey <key>", "API key for the provider")
|
||||
.option("-m, --modelid <id>", "Model ID to configure (e.g., gpt-4o, claude-sonnet-4-6, kimi-k2.5)")
|
||||
.option("-b, --baseurl <url>", "Base URL (optional, only for openai provider)")
|
||||
.option("--vertex-project-id <id>", "Google Cloud Project ID (for vertex provider)")
|
||||
.option("--vertex-region <region>", "Google Cloud Region (for vertex provider)")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory for the task")
|
||||
.option("--config <path>", "Path to Cline configuration directory")
|
||||
@@ -899,7 +936,7 @@ program
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.action(() => checkForUpdates(CLI_VERSION))
|
||||
|
||||
program.command("kanban").description("Run npx kanban --agent cline").action(runKanbanAlias)
|
||||
program.command("kanban").description("Run npx kanban@latest --agent cline").action(runKanbanAlias)
|
||||
|
||||
// Dev command with subcommands
|
||||
const devCommand = program.command("dev").description("Developer tools and utilities")
|
||||
@@ -1050,7 +1087,7 @@ program
|
||||
.option("--auto-condense", "Enable AI-powered context compaction instead of mechanical truncation")
|
||||
.option("--hooks-dir <path>", "Path to additional hooks directory for runtime hook injection")
|
||||
.option("--acp", "Run in ACP (Agent Client Protocol) mode for editor integration")
|
||||
.option("--kanban", "Run npx kanban --agent cline")
|
||||
.option("--kanban", "Run npx kanban@latest --agent cline")
|
||||
.option("-T, --taskId <id>", "Resume an existing task by ID")
|
||||
.option("--continue", "Resume the most recent task from the current working directory")
|
||||
.action(async (prompt, options) => {
|
||||
|
||||
@@ -15,3 +15,13 @@ export function isMouseEscapeSequence(input: string): boolean {
|
||||
// They contain [< followed by numbers, semicolons, and end with M or m
|
||||
return input.includes("[<") && /\[<\d+;\d+;\d+[Mm]/.test(input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ink's key metadata can be inconsistent across platforms/test environments for Enter.
|
||||
* In particular, some Windows CI/test runs surface Enter as raw "\r" input without
|
||||
* setting key.return. Treat either representation as Enter so keyboard handlers remain
|
||||
* stable in production and in tests across platforms.
|
||||
*/
|
||||
export function isEnterKey(input: string, key: { return?: boolean }): boolean {
|
||||
return key.return === true || input === "\r" || input === "\n"
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { refreshVercelAiGatewayModels } from "@/core/controller/models/refreshVe
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import type { BedrockConfig } from "../components/BedrockSetup"
|
||||
import { getDefaultModelId } from "../components/ModelPicker"
|
||||
import type { VertexConfig } from "../components/VertexSetup"
|
||||
|
||||
export interface ApplyProviderConfigOptions {
|
||||
providerId: string
|
||||
@@ -150,3 +151,44 @@ export async function applyBedrockConfig(options: ApplyBedrockConfigOptions): Pr
|
||||
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApplyVertexConfigOptions {
|
||||
vertexConfig: VertexConfig
|
||||
modelId?: string
|
||||
controller?: Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Vertex AI provider configuration to state.
|
||||
* Handles GCP-specific fields (project ID, region).
|
||||
* Authentication uses Google Application Default Credentials (ADC).
|
||||
*/
|
||||
export async function applyVertexConfig(options: ApplyVertexConfigOptions): Promise<void> {
|
||||
const { vertexConfig, modelId, controller } = options
|
||||
const stateManager = StateManager.get()
|
||||
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: "vertex",
|
||||
planModeApiProvider: "vertex",
|
||||
vertexProjectId: vertexConfig.vertexProjectId,
|
||||
vertexRegion: vertexConfig.vertexRegion,
|
||||
}
|
||||
|
||||
const finalModelId = modelId || getDefaultModelId("vertex")
|
||||
if (finalModelId) {
|
||||
const actModelKey = getProviderModelIdKey("vertex" as ApiProvider, "act")
|
||||
const planModelKey = getProviderModelIdKey("vertex" as ApiProvider, "plan")
|
||||
if (actModelKey) config[actModelKey] = finalModelId
|
||||
if (planModelKey) config[planModelKey] = finalModelId
|
||||
}
|
||||
|
||||
stateManager.setApiConfiguration(config)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
if (controller?.task) {
|
||||
const currentMode = stateManager.getGlobalSettingsKey("mode")
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ Your App → Cline API (api.cline.bot) → Anthropic / OpenAI / Google / etc
|
||||
<Card title="Chat Completions" icon="message" href="/api/chat-completions">
|
||||
Full endpoint reference with request schemas, streaming, and tool calling.
|
||||
</Card>
|
||||
<Card title="SDK Examples" icon="code" href="/api/sdk-examples">
|
||||
<Card title="Code Examples" icon="code" href="/api/sdk-examples">
|
||||
Ready-to-copy examples for Python, Node.js, curl, and the Cline CLI.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "SDK Examples"
|
||||
sidebarTitle: "SDK Examples"
|
||||
title: "Code Examples"
|
||||
sidebarTitle: "Code Examples"
|
||||
description: "Use the Cline API from Python, Node.js, curl, the Cline CLI, and the VS Code extension."
|
||||
---
|
||||
|
||||
|
||||
+109
-15
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: "Cline SDK"
|
||||
sidebarTitle: "SDK (Programmatic Use)"
|
||||
description: "Embed Cline as a programmable coding agent in your Node.js applications using an ACP-compatible TypeScript API."
|
||||
---
|
||||
|
||||
@@ -180,13 +181,15 @@ await agent.prompt({
|
||||
|
||||
#### Stop Reasons
|
||||
|
||||
`prompt()` resolves with a `stopReason`:
|
||||
`prompt()` resolves with a `stopReason`. The ACP `StopReason` type defines the full set of possible values:
|
||||
|
||||
| Value | Meaning |
|
||||
|-------|---------|
|
||||
| `"end_turn"` | Agent finished normally (completed task or waiting for user input) |
|
||||
| `"error"` | An error occurred |
|
||||
|
||||
> **Note:** Cline currently returns `"end_turn"` or `"error"`. Other `StopReason` values like `"max_tokens"` or `"cancelled"` are part of the ACP type but may not be produced by the current implementation.
|
||||
|
||||
### Streaming Events
|
||||
|
||||
Subscribe to real-time output via `ClineSessionEmitter`. Each session has its own emitter.
|
||||
@@ -267,9 +270,8 @@ Each permission request includes an array of `PermissionOption` objects:
|
||||
| `kind` | Meaning |
|
||||
|--------|---------|
|
||||
| `allow_once` | Approve this single operation |
|
||||
| `allow_always` | Approve and remember for future operations |
|
||||
| `allow_always` | Approve and remember for future operations (sent for commands, tools, MCP servers) |
|
||||
| `reject_once` | Deny this single operation |
|
||||
| `reject_always` | Deny and remember for future operations |
|
||||
|
||||
**Important:** If no permission handler is set, all tool calls are rejected for safety.
|
||||
|
||||
@@ -319,7 +321,29 @@ await agent.authenticate({ methodId: "openai-codex-oauth" })
|
||||
|
||||
Both methods open a browser window for the OAuth flow and block until authentication completes (5-minute timeout for Cline OAuth).
|
||||
|
||||
For BYO (bring-your-own) API key providers, configure the key through the cline config directory before creating a session. The `authenticate()` call is not needed for BYO providers. We plan to support more auth providers in the near future.
|
||||
For BYO (bring-your-own) API key providers, you can pre-configure credentials using the Cline CLI before using the SDK:
|
||||
|
||||
```bash
|
||||
# Configure an Anthropic API key (default directory: ~/.cline/data/)
|
||||
cline auth -p anthropic -k "sk-ant-..." -m anthropic/claude-sonnet-4-20250514
|
||||
|
||||
# Configure an OpenRouter API key
|
||||
cline auth -p openrouter -k "sk-or-..." -m openrouter/anthropic/claude-sonnet-4
|
||||
```
|
||||
|
||||
This writes credentials to `~/.cline/data/`. Once configured, the SDK will use these credentials automatically — no `authenticate()` call needed.
|
||||
|
||||
**Using a custom directory:** If you specify a custom `clineDir` when creating `ClineAgent`, you must use the same path with `--config` when running `cline auth`:
|
||||
|
||||
```typescript
|
||||
// SDK code using custom directory
|
||||
const agent = new ClineAgent({ clineDir: "/custom/path" })
|
||||
```
|
||||
|
||||
```bash
|
||||
# CLI auth command must use the same path
|
||||
cline auth -p anthropic -k "sk-ant-..." -m anthropic/claude-sonnet-4-20250514 --config /custom/path
|
||||
```
|
||||
|
||||
### Cancellation
|
||||
|
||||
@@ -343,6 +367,8 @@ interface ClineAgentOptions {
|
||||
debug?: boolean
|
||||
/** Custom Cline config directory (default: ~/.cline) */
|
||||
clineDir?: string
|
||||
/** Additional runtime hooks directory */
|
||||
hooksDir?: string
|
||||
}
|
||||
```
|
||||
|
||||
@@ -368,13 +394,13 @@ const response = await agent.initialize({
|
||||
|
||||
// Response includes:
|
||||
{
|
||||
protocolVersion: "0.9.0",
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
promptCapabilities: { image: true, audio: false, embeddedContext: true },
|
||||
mcpCapabilities: { http: true, sse: false }
|
||||
},
|
||||
agentInfo: { name: "cline", version: "2.2.3" },
|
||||
agentInfo: { name: "cline", version: "<installed_version>" },
|
||||
authMethods: [
|
||||
{ id: "cline-oauth", name: "Sign in with Cline", description: "..." },
|
||||
{ id: "openai-codex-oauth", name: "Sign in with ChatGPT", description: "..." }
|
||||
@@ -382,6 +408,24 @@ const response = await agent.initialize({
|
||||
}
|
||||
```
|
||||
|
||||
#### Client Capabilities
|
||||
|
||||
The `clientCapabilities` object in `initialize()` declares what your environment supports. It is part of the ACP protocol handshake.
|
||||
|
||||
| Capability | Type | Description |
|
||||
|------------|------|-------------|
|
||||
| `fs.readTextFile` | `boolean` | Client supports file read requests |
|
||||
| `fs.writeTextFile` | `boolean` | Client supports file write requests |
|
||||
| `terminal` | `boolean` | Client supports terminal command execution |
|
||||
|
||||
**When using `ClineAgent` directly (SDK use)**, the agent always uses standalone providers for file operations and terminal commands — it reads/writes files and runs shell commands on the local machine regardless of what you pass here. Simply pass `{}`:
|
||||
|
||||
```typescript
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} })
|
||||
```
|
||||
|
||||
These capabilities only affect behavior when `ClineAgent` is used through the `AcpAgent` stdio wrapper (e.g., IDE integrations), where an ACP connection delegates operations back to the client.
|
||||
|
||||
#### `newSession(params): Promise<NewSessionResponse>`
|
||||
|
||||
Create a new conversation session.
|
||||
@@ -411,8 +455,8 @@ const session = await agent.newSession({
|
||||
currentModeId: "act"
|
||||
},
|
||||
models: {
|
||||
currentModelId: "anthropic/claude-sonnet-4-5-20241022",
|
||||
availableModels: [{ modelId: "anthropic/claude-3-5-sonnet-20241022", name: "..." }]
|
||||
currentModelId: "anthropic/claude-sonnet-4-20250514",
|
||||
availableModels: [{ modelId: "anthropic/claude-sonnet-4-20250514", name: "claude-sonnet-4-20250514" } /* ... */]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -487,11 +531,18 @@ await agent.shutdown()
|
||||
|
||||
#### `setPermissionHandler(handler)`
|
||||
|
||||
Set a callback to handle tool permission requests.
|
||||
Set a callback to handle tool permission requests. The handler receives a `RequestPermissionRequest` and must return a `Promise<RequestPermissionResponse>`.
|
||||
|
||||
```typescript
|
||||
agent.setPermissionHandler((request, resolve) => {
|
||||
resolve({ outcome: { outcome: "selected", optionId: "allow_once" } })
|
||||
agent.setPermissionHandler(async (request) => {
|
||||
// request.toolCall — details about what the agent wants to do
|
||||
// request.options — available choices (allow_once, reject_once, etc.)
|
||||
const allow = request.options.find(o => o.kind === "allow_once")
|
||||
return {
|
||||
outcome: allow
|
||||
? { outcome: "selected", optionId: allow.optionId }
|
||||
: { outcome: "cancelled" }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
@@ -513,13 +564,45 @@ for (const [sessionId, session] of agent.sessions) {
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
SDK methods throw standard JavaScript errors. Key error scenarios:
|
||||
|
||||
| Method | Error | Cause |
|
||||
|--------|-------|-------|
|
||||
| `newSession()` | `RequestError` (auth required) | No credentials configured — call `authenticate()` or pre-configure via CLI |
|
||||
| `prompt()` | `Error("Session not found")` | Invalid `sessionId` |
|
||||
| `prompt()` | `Error("already processing")` | Called `prompt()` while a previous prompt is still running on the same session |
|
||||
| `unstable_setSessionModel()` | `Error("Invalid modelId format")` | Model ID must be `"provider/modelId"` format (e.g., `"anthropic/claude-sonnet-4-20250514"`) |
|
||||
| `authenticate()` | `Error("Unknown authentication method")` | Invalid `methodId` — use `"cline-oauth"` or `"openai-codex-oauth"` |
|
||||
| `authenticate()` | `Error("Authentication timed out")` | OAuth flow not completed within 5 minutes |
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
} catch (error) {
|
||||
if (error.message?.includes("auth")) {
|
||||
// Need to authenticate first
|
||||
await agent.authenticate({ methodId: "cline-oauth" })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Session-level errors during `prompt()` execution are emitted on the session emitter rather than thrown:
|
||||
|
||||
```typescript
|
||||
emitter.on("error", (err) => {
|
||||
console.error("Session error:", err.message)
|
||||
})
|
||||
```
|
||||
|
||||
## Full Example: Auto-Approve Agent
|
||||
|
||||
```typescript
|
||||
import { ClineAgent } from "cline";
|
||||
|
||||
async function runTask(taskPrompt: string, cwd: string) {
|
||||
const agent = new ClineAgent({ clineDir: "/Users/maxpaulus/.cline" });
|
||||
const agent = new ClineAgent({ clineDir: "/path/to/.cline" });
|
||||
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
@@ -642,23 +725,34 @@ All types are re-exported from the `cline` package. Key types:
|
||||
|------|-------------|
|
||||
| `ClineAgent` | Main agent class |
|
||||
| `ClineSessionEmitter` | Typed event emitter for session events |
|
||||
| `ClineAgentOptions` | Constructor options |
|
||||
| `ClineAgentOptions` | Constructor options (`debug`, `clineDir`, `hooksDir`) |
|
||||
| `ClineAcpSession` | Session metadata (read-only) |
|
||||
| `ClineSessionEvents` | Event name → handler signature map |
|
||||
| `PermissionHandler` | `(request, resolve) => void` callback |
|
||||
| `PermissionResolver` | `(response) => void` callback |
|
||||
| `AcpSessionStatus` | Session lifecycle enum: `Idle`, `Processing`, `Cancelled` |
|
||||
| `AcpSessionState` | Session state tracking (status, pending tool calls) |
|
||||
| `PermissionHandler` | `(request: RequestPermissionRequest) => Promise<RequestPermissionResponse>` |
|
||||
| `RequestPermissionRequest` | Permission request details (sessionId, toolCall, options) |
|
||||
| `RequestPermissionResponse` | Permission response with outcome |
|
||||
| `PermissionOption` | Permission choice (`kind`, `optionId`, `name`) |
|
||||
| `SessionUpdate` | Union of all session update types |
|
||||
| `SessionUpdateType` | Discriminator values (`"agent_message_chunk"`, `"tool_call"`, etc.) |
|
||||
| `SessionUpdatePayload` | Typed payload for a given `SessionUpdateType` |
|
||||
| `SessionModelState` | Current model and available models |
|
||||
| `ToolCall` | Tool call details (id, title, kind, status, content) |
|
||||
| `ToolCallUpdate` | Partial update to an existing tool call |
|
||||
| `ToolCallStatus` | `"pending" \| "in_progress" \| "completed" \| "failed"` |
|
||||
| `ToolKind` | `"read" \| "edit" \| "delete" \| "execute" \| "search" \| ...` |
|
||||
| `StopReason` | `"end_turn" \| "cancelled" \| "error" \| "max_tokens" \| ...` |
|
||||
| `ContentBlock` | `TextContent \| ImageContent \| AudioContent \| ...` |
|
||||
| `TextContent` / `ImageContent` / `AudioContent` | Individual content block types |
|
||||
| `McpServer` | MCP server configuration (stdio, http) |
|
||||
| `ModelInfo` | Model metadata (`modelId`, `name`) |
|
||||
| `PromptRequest` / `PromptResponse` | Prompt call types |
|
||||
| `NewSessionRequest` / `NewSessionResponse` | Session creation types |
|
||||
| `InitializeRequest` / `InitializeResponse` | Initialization types |
|
||||
| `SetSessionModeRequest` / `SetSessionModeResponse` | Mode switching types |
|
||||
| `SetSessionModelRequest` / `SetSessionModelResponse` | Model switching types |
|
||||
| `TranslatedMessage` | Result of translating a Cline message to ACP updates |
|
||||
|
||||
See the [ACP Schema](https://agentclientprotocol.com/protocol/schema) for the full type definitions.
|
||||
|
||||
|
||||
+1
-1
@@ -101,6 +101,7 @@
|
||||
"pages": [
|
||||
"cline-cli/overview",
|
||||
"cline-cli/installation",
|
||||
"cline-sdk/overview",
|
||||
"cline-cli/interactive-mode",
|
||||
{
|
||||
"group": "Headless Mode",
|
||||
@@ -116,7 +117,6 @@
|
||||
},
|
||||
"cline-cli/configuration",
|
||||
"cline-cli/acp-editor-integrations",
|
||||
"cline-sdk/overview",
|
||||
"cline-cli/cli-reference"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,502 +0,0 @@
|
||||
# Technique Plan: Task UI Delta Sync for Active Task Execution
|
||||
|
||||
This document is the implementation plan for the **task UI delta sync** technique identified in `docs/remote-workspace-latency-branch-analysis-report.md` as the fourth most impactful technique in the branch and the strongest long-term transport architecture improvement.
|
||||
|
||||
The key idea is:
|
||||
|
||||
> **During active task execution, send targeted deltas rather than repeated full snapshots.**
|
||||
|
||||
This technique is more invasive than the first three top-ranked improvements, but it is strategically important because it moves the system toward a better model for remote workspaces: full-state snapshots for hydration and recovery, targeted deltas for live execution.
|
||||
|
||||
## How To Use This Plan
|
||||
|
||||
This plan should be executed on a dedicated extraction branch, while `eve_troubleshooting-remote-workspaces` is treated as the **fully developed reference implementation**.
|
||||
|
||||
That distinction matters. This technique is not a hypothetical architecture proposal; it is a plan for extracting and verifying a technique that already exists in integrated form in the reference branch. Developers working this plan should actively inspect the reference implementation for each step and pull implementation details from it deliberately.
|
||||
|
||||
Be smart about this. Because delta sync spans backend mutation publishing, transport contracts, and frontend application logic, the fastest way to make the development process stronger and smoother is to let the reference branch answer the “how did we already solve this edge case?” question early, rather than rediscovering it late.
|
||||
|
||||
## Developer Operating Posture
|
||||
|
||||
This is the most architecturally ambitious of the top four techniques. It changes how the system thinks about live task transport. That means the correct mindset is not “build a clever delta layer,” but:
|
||||
|
||||
- preserve snapshots as canonical hydration and recovery,
|
||||
- shrink active-execution transport to the minimum necessary changes,
|
||||
- and fall back to resync aggressively when invariants are violated.
|
||||
|
||||
The cross-cutting project wisdom still applies here:
|
||||
|
||||
> **Stop treating every streamed chunk as a durable, full-state, immediately-presented event.**
|
||||
|
||||
For this technique, the emphasis is on moving active execution away from **full-state** and toward **targeted transport**.
|
||||
|
||||
## Document Type, Audience, and Quality Bar
|
||||
|
||||
This is an **extraction implementation plan** for a **Staff+ level distributed systems / infrastructure engineer**. It assumes the reader is capable of reasoning about transport contracts, ordering invariants, state hydration, and recovery semantics.
|
||||
|
||||
The quality bar is especially high here because this technique crosses backend, transport, and frontend boundaries. The plan must therefore make it easy to answer:
|
||||
|
||||
- what the transport contract is,
|
||||
- what invariants must hold,
|
||||
- what recovery behavior is expected,
|
||||
- and how the extracted version will be validated against the reference implementation.
|
||||
|
||||
## Artifact Stack and Dependency Position
|
||||
|
||||
This doc should be read as part of the following artifact sequence:
|
||||
|
||||
1. `docs/remote-workspace-latency-branch-analysis-report.md` explains why delta sync is strategically valuable but later in the extraction order.
|
||||
2. `eve_troubleshooting-remote-workspaces` shows the integrated end state and should be consulted constantly.
|
||||
3. This document defines the extraction steps, invariants, and test strategy for a smaller implementation branch.
|
||||
|
||||
Because this technique is more coupled than the other top-four techniques, keeping that sequence explicit will make development much smoother.
|
||||
|
||||
## Minimal Coherent Extraction Boundary
|
||||
|
||||
The smallest coherent PR for this technique should usually include:
|
||||
|
||||
- shared delta type definitions,
|
||||
- backend publish/subscribe infrastructure,
|
||||
- message-state delta emission,
|
||||
- frontend delta application with sequencing and resync,
|
||||
- and tests covering ordering, divergence, and recovery.
|
||||
|
||||
What should **not** be split away if avoidable:
|
||||
|
||||
- sequence validation from delta application,
|
||||
- resync path from initial delta rollout,
|
||||
- backend emission from frontend application if the goal is an end-to-end usable slice,
|
||||
- and the fallback snapshot path that preserves product correctness.
|
||||
|
||||
## Common Failure Modes While Extracting
|
||||
|
||||
Watch for these failure modes explicitly:
|
||||
|
||||
- treating deltas as a replacement for snapshots rather than a companion to them,
|
||||
- making the reducer permissive instead of sequence-strict,
|
||||
- emitting deltas from the wrong abstraction boundary,
|
||||
- forgetting task-identity filtering and task-switch behavior,
|
||||
- and validating only happy-path ordered deltas without aggressive resync/fallback testing.
|
||||
|
||||
---
|
||||
|
||||
## Why This Technique Matters
|
||||
|
||||
Even after presentation scheduling, deferred persistence, and state coalescing, active execution can still generate meaningful transport churn. Full snapshots are fundamentally a coarse-grained mechanism. They resend lots of state that did not change.
|
||||
|
||||
In remote mode, that means unnecessary work across the whole pipeline:
|
||||
|
||||
- backend snapshot construction,
|
||||
- serialization,
|
||||
- remote transport,
|
||||
- frontend parsing,
|
||||
- broad state replacement / render churn.
|
||||
|
||||
Delta sync fixes the shape of the transport itself by sending only the state mutations that matter:
|
||||
|
||||
- message added,
|
||||
- message updated,
|
||||
- message deleted,
|
||||
- task metadata updated,
|
||||
- explicit resync signal.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Active task execution can advance the webview primarily through task UI deltas.
|
||||
- Full-state snapshots remain the canonical initialization and recovery path.
|
||||
- Delta application is sequence-safe and can resync on gap or divergence.
|
||||
- Backend message mutations publish minimal targeted deltas.
|
||||
- Frontend applies deltas with minimal state churn.
|
||||
- Task switches and stale deltas do not corrupt the UI.
|
||||
|
||||
---
|
||||
|
||||
## Files Most Likely to Change
|
||||
|
||||
- `src/shared/TaskUiDelta.ts`
|
||||
- `src/core/controller/ui/subscribeToTaskUiDeltas.ts`
|
||||
- `src/core/task/message-state.ts`
|
||||
- `src/core/controller/index.ts`
|
||||
- `webview-ui/src/context/ExtensionStateContext.tsx`
|
||||
- `webview-ui/src/context/taskUiDeltaState.ts`
|
||||
- `webview-ui/src/context/taskUiDebugCounters.ts`
|
||||
- related tests in backend and webview
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Implementation Plan
|
||||
|
||||
## Step 1 — Define the delta model and sequencing contract
|
||||
|
||||
### Goal
|
||||
|
||||
Create a small, explicit, versionable transport contract for active task execution changes.
|
||||
|
||||
### Mental model
|
||||
|
||||
Delta systems fail when they are “implicit.” They need an explicit contract for:
|
||||
|
||||
- what changed,
|
||||
- which task it belongs to,
|
||||
- in what order it must be applied,
|
||||
- what to do if order is broken.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Define delta event types.
|
||||
- [x] Ensure every delta contains `taskId` and `sequence`.
|
||||
- [x] Define resync behavior on missing/stale sequence.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In `src/shared/TaskUiDelta.ts`:
|
||||
- [x] define or refine delta union including:
|
||||
- [x] `message_added`
|
||||
- [x] `message_updated`
|
||||
- [x] `message_deleted`
|
||||
- [x] `task_metadata_updated`
|
||||
- [x] `task_state_resynced`
|
||||
- [x] document the sequencing contract in comments.
|
||||
- Decide that:
|
||||
- [x] deltas are only valid for the current task,
|
||||
- [x] sequence must increment monotonically by 1,
|
||||
- [x] a gap triggers full resync.
|
||||
|
||||
Do not improvise this contract from memory. Read the reference implementation branch carefully and preserve the exact mental model it uses for sequence monotonicity and recovery semantics.
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Unit test: type helpers or guards behave correctly.
|
||||
- [x] Unit test: sequence mismatch triggers resync result.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Build backend delta publishing infrastructure
|
||||
|
||||
### Goal
|
||||
|
||||
Provide a transport channel for task UI deltas parallel to existing state and partial-message subscriptions.
|
||||
|
||||
### Mental model
|
||||
|
||||
Full-state snapshots and deltas should coexist, not replace each other outright. The backend must be able to publish deltas cheaply while retaining the existing snapshot transport as a recovery path.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Add subscription/publisher mechanism for task UI deltas.
|
||||
- [x] Ensure it is failure-safe and non-blocking.
|
||||
- [x] Keep transport format minimal, ideally serialized delta JSON.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In `src/core/controller/ui/subscribeToTaskUiDeltas.ts`:
|
||||
- [x] implement backend subscription registry / broadcaster.
|
||||
- [x] add `sendTaskUiDelta(...)` helper.
|
||||
- [x] record payload-size metrics if useful.
|
||||
- If protobuf transport needs changes:
|
||||
- [x] ensure message contract is appropriately wired through `proto/cline/ui.proto` or equivalent.
|
||||
|
||||
This is a good example of where “be smart about this” matters. The developer should not just make the channel exist; they should make it easy to reason about, easy to debug, and obviously subordinate to the canonical snapshot path.
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Unit test: subscribers receive published deltas.
|
||||
- [x] Unit test: publisher handles no-subscriber case safely.
|
||||
- [x] Unit test: serialized payload shape is stable.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Publish deltas from message-state mutations
|
||||
|
||||
### Goal
|
||||
|
||||
Make the message-state layer emit task UI deltas whenever live task messages mutate.
|
||||
|
||||
### Mental model
|
||||
|
||||
The message-state layer is the natural source of truth for chat mutation events. If deltas are emitted here, the system stays aligned with actual message semantics rather than ad hoc UI-side guesses.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Emit `message_added` on add.
|
||||
- [x] Emit `message_updated` on update.
|
||||
- [x] Emit `message_deleted` on delete.
|
||||
- [x] Emit `task_state_resynced` on full replacement/set flows.
|
||||
- [x] Increment a per-task delta sequence on each publish.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In `src/core/task/message-state.ts`:
|
||||
- [x] wire `emitClineMessagesChanged(...)` to publish deltas when delta sync is enabled.
|
||||
- [x] use `taskState.taskUiDeltaSequence` as the monotonic sequence source.
|
||||
- [x] send minimal payloads for each mutation type.
|
||||
- Ensure ephemeral and durable mutations both publish the same deltas so live UI behavior does not depend on durability choice.
|
||||
|
||||
This step should be executed with the reference implementation branch open beside the extraction branch. The key engineering task is not simply “emit deltas,” but “emit deltas from the true state mutation boundary without creating semantic skew between durable and ephemeral paths.”
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Unit test: add publishes `message_added` with correct sequence.
|
||||
- [x] Unit test: update publishes `message_updated` with correct sequence.
|
||||
- [x] Unit test: delete publishes `message_deleted` with correct sequence.
|
||||
- [x] Unit test: set/overwrite publishes `task_state_resynced`.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Publish task metadata deltas outside message-state mutations
|
||||
|
||||
### Goal
|
||||
|
||||
Handle non-message hot-path changes, such as focus-chain and background-command metadata, without relying on full snapshots.
|
||||
|
||||
### Mental model
|
||||
|
||||
Some of the most annoying snapshot churn comes from small metadata updates that are orthogonal to the message list. These deserve their own lightweight path.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Add controller helper for metadata delta publication.
|
||||
- [x] Route focus-chain and background-command metadata through it.
|
||||
- [x] Fall back to snapshot posting when no current task or invalid task context exists.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In `src/core/controller/index.ts`:
|
||||
- [x] add or refine `postTaskMetadataDelta(...)`.
|
||||
- [x] only publish deltas when target task matches current active task.
|
||||
- [x] otherwise request a normal full-state post as fallback.
|
||||
|
||||
Keep the fallback path boring and reliable. Smart engineering here means preferring explicit fallback to snapshot sync over any attempt to get fancy when task identity or activity context is ambiguous.
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Unit test: metadata delta publishes for current active task.
|
||||
- [x] Unit test: mismatched/non-active task falls back to snapshot path.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Implement frontend delta application and ordering safety
|
||||
|
||||
### Goal
|
||||
|
||||
Make the webview able to apply deltas incrementally while detecting sequence gaps and requesting resync.
|
||||
|
||||
### Mental model
|
||||
|
||||
Frontend delta handling must be strict, not permissive. If it misses a sequence or applies a delta for the wrong task, stale UI bugs will appear and be hard to debug.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Track latest applied sequence in the frontend.
|
||||
- [x] Ignore deltas for non-current tasks.
|
||||
- [x] Trigger resync on sequence mismatch.
|
||||
- [x] Apply message add/update/delete with minimal array churn.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In `webview-ui/src/context/taskUiDeltaState.ts`:
|
||||
- [x] validate `delta.sequence === latestSequence + 1`.
|
||||
- [x] return `resync` on mismatch.
|
||||
- [x] ignore deltas for non-current tasks while still advancing sequence semantics intentionally if that is the chosen policy.
|
||||
- [x] apply message mutations minimally.
|
||||
- In `webview-ui/src/context/ExtensionStateContext.tsx`:
|
||||
- [x] subscribe to delta stream,
|
||||
- [x] feed deltas into reducer/helper,
|
||||
- [x] trigger full-state resync when helper returns `resync`.
|
||||
|
||||
The frontend side should be implemented with a bias toward correctness and repairability. If you find yourself making the delta reducer permissive to “keep things working,” stop and compare with the reference implementation. The right answer is usually stricter sequencing plus easier resync.
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Webview test: ordered deltas produce correct final state.
|
||||
- [x] Webview test: sequence gap triggers resync path.
|
||||
- [x] Webview test: stale/non-current-task delta is ignored safely.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Keep full-state snapshots as canonical hydration and recovery path
|
||||
|
||||
### Goal
|
||||
|
||||
Ensure deltas complement snapshots rather than replacing them unsafely.
|
||||
|
||||
### Mental model
|
||||
|
||||
Snapshots are still the canonical state source for:
|
||||
|
||||
- initial load,
|
||||
- task switch,
|
||||
- reconnect/reopen,
|
||||
- recovery after divergence.
|
||||
|
||||
Deltas should advance current state, not become the sole source of truth.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Preserve `subscribeToState` as initialization path.
|
||||
- [x] Reset delta sequence on full snapshot hydration.
|
||||
- [x] Trigger snapshot fetch on divergence.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In `ExtensionStateContext.tsx`:
|
||||
- [x] after receiving a fresh full snapshot, reset latest delta sequence tracking.
|
||||
- [x] on resync request, fetch latest state and replace current state.
|
||||
- Ensure startup / reload still works even if no deltas arrive.
|
||||
|
||||
This step is essential to keeping the rest of Cline’s product surfaces healthy. Delta sync should improve active execution, not quietly turn startup, reopen, or task switching into undefined behavior.
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Regression test: initial load hydrates correctly without prior deltas.
|
||||
- [x] Regression test: reopening or task switching still works.
|
||||
- [x] Regression test: full snapshot repairs intentionally diverged delta state.
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Minimize frontend churn when applying deltas
|
||||
|
||||
### Goal
|
||||
|
||||
Capture the benefit of deltas by applying them with minimal structural churn in React state.
|
||||
|
||||
### Mental model
|
||||
|
||||
A delta transport is less valuable if the frontend responds by rebuilding large portions of state anyway. The frontend should patch the smallest possible region.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Update only the changed message when possible.
|
||||
- [x] Avoid replacing `clineMessages` unless necessary.
|
||||
- [x] Keep metadata updates narrow.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In `webview-ui/src/context/taskUiDeltaState.ts`:
|
||||
- [x] add/update should preserve array identity only where safe and replace minimal slices.
|
||||
- [x] delete should only filter when message exists.
|
||||
- [x] metadata updates should shallow-merge only changed fields.
|
||||
|
||||
Be smart about this at the React-state level too: if the frontend re-renders large portions of the tree on every delta, then the transport win will be partially squandered.
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Webview test: unchanged update payload does not cause unnecessary state replacement.
|
||||
- [x] Webview test: active message row updates correctly under repeated deltas.
|
||||
|
||||
---
|
||||
|
||||
## Step 8 — Instrument, debug, and validate remote-mode benefit
|
||||
|
||||
### Goal
|
||||
|
||||
Make the delta system observable and prove it reduces snapshot dependence during active execution.
|
||||
|
||||
### Mental model
|
||||
|
||||
Delta systems are harder to reason about than snapshots, so they need better visibility. Developers should be able to see:
|
||||
|
||||
- how many deltas were applied,
|
||||
- how many full states were still applied,
|
||||
- how often resync happened.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Add debug counters for full-state applications, partial-message applications, delta applications, and resync requests.
|
||||
- [x] Compare default mode vs delta-disabled mode in validation harness.
|
||||
- [x] Ensure feature flag exists for safe staged rollout.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In `webview-ui/src/context/taskUiDebugCounters.ts`:
|
||||
- [x] add counters for delta application and resync requests.
|
||||
- In `.env.example` / `latency.ts`:
|
||||
- [x] preserve `CLINE_DISABLE_TASK_UI_DELTA_SYNC` or equivalent.
|
||||
- In validation tooling:
|
||||
- [x] compare `stateUpdateCount`, `taskDeltaCount`, and payload bytes across variants.
|
||||
|
||||
Since the reference implementation already exists, one of the strongest ways to smooth development is to validate the extracted technique against both disabled-mode behavior and the known-good reference branch behavior.
|
||||
|
||||
### Tests
|
||||
|
||||
- [ ] Validation harness scenario: delta-enabled mode reduces full-state payload bytes during active execution.
|
||||
- [x] Validation harness scenario: delta-disabled variant falls back cleanly to snapshot behavior.
|
||||
- [x] Unit test: debug counters increment correctly where applicable.
|
||||
|
||||
---
|
||||
|
||||
## Step 9 — Validate the technique in large-file-write and long-running task scenarios
|
||||
|
||||
### Goal
|
||||
|
||||
Confirm that delta sync specifically helps long, noisy task executions, including large-file operations.
|
||||
|
||||
### Mental model
|
||||
|
||||
Large-file writes are not only about the write tool itself. They often generate a lot of nearby live task activity that becomes expensive when transported as snapshots. Delta sync should reduce that excess movement.
|
||||
|
||||
That is why this technique still matters for large-file-write scenarios, even though it is not the first thing to land: it attacks the remaining active-execution transport cost after the first three higher-ROI techniques have already reduced hot-path churn.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Add scenario coverage for long active execution with many message mutations.
|
||||
- [x] Compare delta-enabled vs delta-disabled behavior.
|
||||
- [x] Verify convergence at the end of execution.
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Integration/validation scenario: long-running execution with many message updates works correctly under delta sync.
|
||||
- [x] Regression test: final UI state matches snapshot-based state.
|
||||
- [x] Regression test: no stale message duplication or ordering bug appears after many updates.
|
||||
|
||||
---
|
||||
|
||||
## Developer Checklist Summary
|
||||
|
||||
- [x] Define delta model and sequencing contract
|
||||
- [x] Build backend delta subscription/publishing infrastructure
|
||||
- [x] Publish deltas from message-state mutations
|
||||
- [x] Publish metadata deltas for non-message hot paths
|
||||
- [x] Implement frontend delta application with strict ordering safety
|
||||
- [x] Preserve full snapshots for hydration and recovery
|
||||
- [x] Minimize frontend churn during delta application
|
||||
- [x] Add observability, flags, and validation coverage
|
||||
- [x] Validate large-file / long-running execution scenarios
|
||||
|
||||
## Extraction Progress Notes
|
||||
|
||||
- Implemented the core task UI delta transport and reducer path in commit `05d7cc315` (`Add task UI delta sync transport and reducers`).
|
||||
- Added extraction-branch follow-up coverage in commit `8839a5bf6` (`Add task UI delta sync test coverage and env flag helper`), including backend delta broadcaster tests, latency/env-flag helper coverage, reducer sequencing tests, and a webview context delta hydration test.
|
||||
- Added latency-analysis helpers and validation scripts for comparing delta-enabled vs delta-disabled runs (`src/services/telemetry/taskLatencySummary.ts`, `scripts/validate-latency-scenarios.ts`, `scripts/analyze-task-latency-metrics.mjs`, and `scripts/compare-task-latency-metrics.mjs`).
|
||||
- Added message-state regression coverage in commit `cdee38396` (`Add message-state task UI delta regression tests`) and fixed verification follow-up issues in commit `5548080d9`.
|
||||
- Added controller metadata delta coverage (`src/test/controller-task-ui-metadata.test.ts`) plus webview resync/task-switch regression coverage in `webview-ui/src/context/ExtensionStateContext.test.tsx`.
|
||||
- Added frontend churn/debug counter coverage in `webview-ui/src/context/taskUiDeltaState.test.ts` and `webview-ui/src/context/taskUiDebugCounters.test.ts`.
|
||||
- Wired focus-chain metadata and background-command metadata through task-specific delta publication, with snapshot fallback when task identity is ambiguous.
|
||||
- Preserved snapshot hydration/resync semantics alongside delta application and added frontend debug counters for snapshot, partial-message, delta, and resync activity.
|
||||
- Built the standalone validation target, fixed the latency harness mock response path for `latency_validation`, and ran `scripts/validate-latency-scenarios.ts` end-to-end across local/remote and delta-enabled/delta-disabled variants.
|
||||
- Validation now shows clean snapshot fallback when delta sync is disabled (`taskDeltaCount: 0`, `taskDeltaPayloadBytes: 0`, `completed: true`) while delta-enabled variants deliver 31 task deltas / 14,690 delta bytes and still converge successfully.
|
||||
- Extended the validation harness with a `long_running` scenario (`latency_validation_long`) that drives 152 partial-message events and 165 task UI deltas while still converging to 7 unique final messages with no duplicate/stale rows in either local or remote mode.
|
||||
- The long-running scenario now provides direct enabled-vs-disabled comparison data via `totalTransportBytes`, `taskDeltaCount`, `finalUniqueMessageCount`, and `hasDuplicateMessagesAtCompletion`.
|
||||
- Added semantic final-state signatures to the validation harness so each delta-enabled variant is compared directly against the `delta_disabled` baseline. The current runs now report `allVariantsMatchBaseline: true` for both `basic` and `long_running` scenarios in local and remote modes.
|
||||
- Reduced unconditional snapshot posting inside the task loop by gating several active-execution `postStateToWebview()` calls behind the delta-sync flag. This removed snapshot posts for finalized `api_req_started` updates, usage-chunk metric refreshes, and several ask/say paths when delta sync is enabled.
|
||||
- Latest validation runs now show lower absolute snapshot traffic than the earlier harness runs (for example, remote `long_running` `statePayloadBytes` dropped from ~111.8 KB to ~101.2 KB, and `stateUpdateCount` from 16 to 15), confirming that some active-execution full-state churn was successfully removed.
|
||||
- Installed dependencies, regenerated protos, and verified the focused backend and webview coverage locally. Successful verification included:
|
||||
- `npm run test:unit -- src/test/controller-task-ui-metadata.test.ts src/core/controller/ui/subscribeToTaskUiDeltas.test.ts src/test/message-state-handler.test.ts src/core/task/__tests__/latency.test.ts src/services/telemetry/__tests__/taskLatencySummary.test.ts`
|
||||
- `cd webview-ui && npm run test -- src/context/taskUiDeltaState.test.ts src/context/taskUiDebugCounters.test.ts src/context/ExtensionStateContext.test.tsx`
|
||||
- The webview verification now passes without the earlier React `act(...)` warning noise after wrapping streamed state updates in `act(...)`.
|
||||
- Remaining validation gaps:
|
||||
- In the current synthetic scenarios, `statePayloadBytes` remain effectively unchanged **between** delta-enabled and delta-disabled variants, even though the absolute snapshot volume is lower than before. This indicates the remaining snapshot traffic is dominated by canonical hydration / task-boundary state posts that both variants still legitimately share.
|
||||
- The next likely step is to separate *hydration/task-boundary* snapshot bytes from *active-execution* snapshot bytes in the validation harness (or eliminate additional task-boundary snapshot posts that are still unnecessary in delta-enabled mode) so the delta-specific reduction can be demonstrated directly.
|
||||
|
||||
---
|
||||
|
||||
## Final Mental Model Recap
|
||||
|
||||
- **Full snapshots establish truth.**
|
||||
- **Deltas advance truth during active execution.**
|
||||
- **If ordering breaks, resync instead of guessing.**
|
||||
- **The point is not cleverness; the point is to avoid shipping unchanged state over and over in remote mode.**
|
||||
|
||||
That is the mindset developers should keep while implementing this technique.
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.73.0",
|
||||
"version": "3.75.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.73.0",
|
||||
"version": "3.75.0",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
".",
|
||||
@@ -162,7 +162,7 @@
|
||||
},
|
||||
"cli": {
|
||||
"name": "cline",
|
||||
"version": "2.8.0",
|
||||
"version": "2.9.0",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.73.0",
|
||||
"version": "3.75.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"workspaces": [
|
||||
".",
|
||||
|
||||
@@ -232,10 +232,6 @@ message ShowWebviewEvent {
|
||||
bool preserve_editor_focus = 1; // When true, webview should not steal focus from editor
|
||||
}
|
||||
|
||||
message TaskUiDeltaEvent {
|
||||
string delta_json = 1;
|
||||
}
|
||||
|
||||
// UiService provides methods for managing UI interactions
|
||||
service UiService {
|
||||
// Scrolls to a specific settings section in the settings view
|
||||
@@ -271,9 +267,6 @@ service UiService {
|
||||
// Subscribe to partial message updates (streaming Cline messages as they're built)
|
||||
rpc subscribeToPartialMessage(EmptyRequest) returns (stream ClineMessage);
|
||||
|
||||
// Subscribe to task UI delta updates for active task execution state
|
||||
rpc subscribeToTaskUiDeltas(EmptyRequest) returns (stream TaskUiDeltaEvent);
|
||||
|
||||
// Initialize webview when it launches
|
||||
rpc initializeWebview(EmptyRequest) returns (Empty);
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ message GetHostVersionResponse {
|
||||
optional string cline_type = 3;
|
||||
// The version of the cline host environment, e.g. 33.2.10 for extension, or 1.0.6 for JetBrains.
|
||||
optional string cline_version = 4;
|
||||
// The remote environment name when the host is connected to a remote workspace
|
||||
// (for example `ssh-remote`, `dev-container`, or `codespaces`).
|
||||
optional string remote_name = 5;
|
||||
}
|
||||
|
||||
enum Setting {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { summarizeTaskLatencyEvents } from "../src/services/telemetry/taskLatencySummary"
|
||||
|
||||
function parseEventLines(raw) {
|
||||
return raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line))
|
||||
.map((entry) => entry.properties ?? entry)
|
||||
.filter((entry) => entry)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const inputPath = process.argv[2]
|
||||
if (!inputPath) {
|
||||
console.error("Usage: node scripts/analyze-task-latency-metrics.mjs <path-to-jsonl>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(process.cwd(), inputPath)
|
||||
const raw = await fs.readFile(absolutePath, "utf8")
|
||||
const events = parseEventLines(raw)
|
||||
const summary = summarizeTaskLatencyEvents(events)
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { compareTaskLatencySummaries, summarizeTaskLatencyEvents } from "../src/services/telemetry/taskLatencySummary"
|
||||
|
||||
function parseEventLines(raw) {
|
||||
return raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line))
|
||||
.map((entry) => entry.properties ?? entry)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
async function loadSummary(inputPath) {
|
||||
const absolutePath = path.resolve(process.cwd(), inputPath)
|
||||
const raw = await fs.readFile(absolutePath, "utf8")
|
||||
return summarizeTaskLatencyEvents(parseEventLines(raw))
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const baselinePath = process.argv[2]
|
||||
const candidatePath = process.argv[3]
|
||||
if (!baselinePath || !candidatePath) {
|
||||
console.error("Usage: node scripts/compare-task-latency-metrics.mjs <baseline-jsonl> <candidate-jsonl>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const baseline = await loadSummary(baselinePath)
|
||||
const candidate = await loadSummary(candidatePath)
|
||||
console.log(JSON.stringify(compareTaskLatencySummaries(baseline, candidate), null, 2))
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,441 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import { type ChildProcess, spawn } from "node:child_process"
|
||||
import { once } from "node:events"
|
||||
import net from "node:net"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { credentials } from "@grpc/grpc-js"
|
||||
import { AccountServiceClient } from "../src/generated/grpc-js/cline/account"
|
||||
import { StateServiceClient } from "../src/generated/grpc-js/cline/state"
|
||||
import { TaskServiceClient } from "../src/generated/grpc-js/cline/task"
|
||||
import { UiServiceClient } from "../src/generated/grpc-js/cline/ui"
|
||||
|
||||
type ValidationMode = "local" | "remote"
|
||||
|
||||
type ValidationVariant = {
|
||||
name: string
|
||||
env: Record<string, string>
|
||||
}
|
||||
|
||||
type ValidationScenario = {
|
||||
name: string
|
||||
prompt: string
|
||||
}
|
||||
|
||||
type ScenarioResult = {
|
||||
scenario: string
|
||||
variant: string
|
||||
mode: ValidationMode
|
||||
newTaskRpcMs: number
|
||||
firstStateMs: number | null
|
||||
firstPartialMessageMs: number | null
|
||||
firstTaskDeltaMs: number | null
|
||||
completionMs: number | null
|
||||
stateUpdateCount: number
|
||||
partialMessageCount: number
|
||||
taskDeltaCount: number
|
||||
statePayloadBytes: number
|
||||
taskDeltaPayloadBytes: number
|
||||
totalTransportBytes: number
|
||||
messageCountAtCompletion: number | null
|
||||
finalUniqueMessageCount: number | null
|
||||
hasDuplicateMessagesAtCompletion: boolean | null
|
||||
finalStateSignature: string | null
|
||||
completed: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
type ScenarioComparison = {
|
||||
scenario: string
|
||||
mode: ValidationMode
|
||||
baselineVariant: string
|
||||
baselineSignature: string | null
|
||||
baselineSemanticSignature: string | null
|
||||
allVariantsMatchBaseline: boolean
|
||||
mismatchedVariants: string[]
|
||||
}
|
||||
|
||||
function buildSemanticMessageSignature(message: any) {
|
||||
const messageType = message.type
|
||||
const sayType = message.say ?? null
|
||||
const askType = message.ask ?? null
|
||||
|
||||
let normalizedText: string | null = message.text ?? null
|
||||
if (sayType === "api_req_started" || sayType === "hook_status" || sayType === "checkpoint_created") {
|
||||
normalizedText = null
|
||||
}
|
||||
if (messageType === "ask") {
|
||||
normalizedText = null
|
||||
}
|
||||
|
||||
return {
|
||||
type: messageType,
|
||||
say: sayType,
|
||||
ask: askType,
|
||||
text: normalizedText,
|
||||
partial: message.partial ?? false,
|
||||
images: Array.isArray(message.images) ? message.images.length : 0,
|
||||
files: Array.isArray(message.files) ? message.files.length : 0,
|
||||
}
|
||||
}
|
||||
|
||||
function buildSemanticStateSignature(state: any, clineMessages: any[]): string {
|
||||
return JSON.stringify({
|
||||
clineMessages: clineMessages.map(buildSemanticMessageSignature),
|
||||
currentFocusChainChecklist: state.currentFocusChainChecklist ?? null,
|
||||
backgroundCommandRunning: state.backgroundCommandRunning ?? false,
|
||||
backgroundCommandTaskId: state.backgroundCommandTaskId ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
const PROJECT_ROOT = path.resolve(SCRIPT_DIR, "..")
|
||||
|
||||
const variants: ValidationVariant[] = [
|
||||
{ name: "default", env: {} },
|
||||
{
|
||||
name: "presentation_disabled",
|
||||
env: {
|
||||
CLINE_DISABLE_PRESENTATION_SCHEDULER: "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ephemeral_disabled",
|
||||
env: {
|
||||
CLINE_DISABLE_EPHEMERAL_MESSAGE_PERSISTENCE: "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delta_disabled",
|
||||
env: {
|
||||
CLINE_DISABLE_TASK_UI_DELTA_SYNC: "true",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const scenarios: ValidationScenario[] = [
|
||||
{ name: "basic", prompt: "latency_validation" },
|
||||
{ name: "long_running", prompt: "latency_validation_long" },
|
||||
]
|
||||
|
||||
async function waitForPort(port: number, timeoutMs = 20_000): Promise<void> {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(port, "127.0.0.1", () => {
|
||||
socket.destroy()
|
||||
resolve()
|
||||
})
|
||||
socket.on("error", reject)
|
||||
})
|
||||
return
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
throw new Error(`Timed out waiting for port ${port}`)
|
||||
}
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Unable to allocate port")))
|
||||
return
|
||||
}
|
||||
const { port } = address
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve(port)
|
||||
})
|
||||
})
|
||||
server.on("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
function unaryCall<T>(fn: (callback: (error: Error | null, response: T) => void) => void): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
fn((error, response) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function startServer(mode: ValidationMode, envOverrides: Record<string, string>) {
|
||||
const grpcPort = await getFreePort()
|
||||
const hostbridgePort = await getFreePort()
|
||||
const env: Record<string, string> = {
|
||||
...process.env,
|
||||
PROTOBUS_PORT: String(grpcPort),
|
||||
HOSTBRIDGE_PORT: String(hostbridgePort),
|
||||
E2E_TEST: "true",
|
||||
CLINE_ENVIRONMENT: "local",
|
||||
TEST_HOSTBRIDGE_REMOTE_NAME: mode === "remote" ? "ssh-remote" : "",
|
||||
TEST_HOSTBRIDGE_PLATFORM: mode === "remote" ? "VS Code Remote" : "VS Code",
|
||||
GRPC_RECORDER_ENABLED: "false",
|
||||
...envOverrides,
|
||||
}
|
||||
|
||||
const child = spawn("npx", ["tsx", path.join(PROJECT_ROOT, "scripts", "test-standalone-core-api-server.ts")], {
|
||||
cwd: PROJECT_ROOT,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
|
||||
child.stdout?.on("data", () => {
|
||||
// Drain stdout so the spawned server cannot block on a full pipe buffer.
|
||||
})
|
||||
|
||||
let stderr = ""
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk.toString()
|
||||
})
|
||||
|
||||
await waitForPort(grpcPort)
|
||||
return { child, grpcPort, hostbridgePort, getStderr: () => stderr }
|
||||
}
|
||||
|
||||
async function stopServer(child: ChildProcess) {
|
||||
if (child.killed || child.exitCode !== null) {
|
||||
return
|
||||
}
|
||||
child.kill("SIGINT")
|
||||
try {
|
||||
await Promise.race([once(child, "exit"), new Promise((resolve) => setTimeout(resolve, 5_000))])
|
||||
} catch {
|
||||
child.kill("SIGKILL")
|
||||
}
|
||||
}
|
||||
|
||||
async function runScenario(
|
||||
mode: ValidationMode,
|
||||
variant: ValidationVariant,
|
||||
scenario: ValidationScenario,
|
||||
): Promise<ScenarioResult> {
|
||||
const server = await startServer(mode, variant.env)
|
||||
const address = `127.0.0.1:${server.grpcPort}`
|
||||
const accountClient = new AccountServiceClient(address, credentials.createInsecure())
|
||||
const stateClient = new StateServiceClient(address, credentials.createInsecure())
|
||||
const taskClient = new TaskServiceClient(address, credentials.createInsecure())
|
||||
const uiClient = new UiServiceClient(address, credentials.createInsecure())
|
||||
|
||||
let currentTaskId: string | undefined
|
||||
const startedAt = Date.now()
|
||||
let firstStateMs: number | null = null
|
||||
let firstPartialMessageMs: number | null = null
|
||||
let firstTaskDeltaMs: number | null = null
|
||||
let completionMs: number | null = null
|
||||
let stateUpdateCount = 0
|
||||
let partialMessageCount = 0
|
||||
let taskDeltaCount = 0
|
||||
let statePayloadBytes = 0
|
||||
let taskDeltaPayloadBytes = 0
|
||||
let messageCountAtCompletion: number | null = null
|
||||
let finalUniqueMessageCount: number | null = null
|
||||
let hasDuplicateMessagesAtCompletion: boolean | null = null
|
||||
let finalStateSignature: string | null = null
|
||||
let completed = false
|
||||
|
||||
const stateStream = stateClient.subscribeToState({})
|
||||
const partialStream = uiClient.subscribeToPartialMessage({})
|
||||
const deltaStream = uiClient.subscribeToTaskUiDeltas({})
|
||||
|
||||
for (const stream of [stateStream, partialStream, deltaStream]) {
|
||||
stream.on("error", (streamError: any) => {
|
||||
if (streamError?.code === 1 || streamError?.details === "Cancelled on client") {
|
||||
return
|
||||
}
|
||||
console.error("validation stream error", streamError)
|
||||
})
|
||||
}
|
||||
|
||||
stateStream.on("data", (response: { stateJson?: string }) => {
|
||||
stateUpdateCount += 1
|
||||
const stateJson = response.stateJson || "{}"
|
||||
statePayloadBytes += Buffer.byteLength(stateJson, "utf8")
|
||||
if (firstStateMs === null) {
|
||||
firstStateMs = Date.now() - startedAt
|
||||
}
|
||||
try {
|
||||
const state = JSON.parse(stateJson)
|
||||
const activeTaskId = state.currentTaskItem?.id
|
||||
if (activeTaskId) {
|
||||
currentTaskId = activeTaskId
|
||||
}
|
||||
const clineMessages = Array.isArray(state.clineMessages) ? state.clineMessages : []
|
||||
const hasCompletion = clineMessages.some(
|
||||
(message: any) => message.ask === "completion_result" || message.ask === "resume_completed_task",
|
||||
)
|
||||
if (hasCompletion && completionMs === null) {
|
||||
completionMs = Date.now() - startedAt
|
||||
messageCountAtCompletion = clineMessages.length
|
||||
const uniqueMessageTs = new Set(clineMessages.map((message: any) => message.ts))
|
||||
finalUniqueMessageCount = uniqueMessageTs.size
|
||||
hasDuplicateMessagesAtCompletion = uniqueMessageTs.size !== clineMessages.length
|
||||
finalStateSignature = buildSemanticStateSignature(state, clineMessages)
|
||||
completed = true
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors in validation harness
|
||||
}
|
||||
})
|
||||
|
||||
partialStream.on("data", (message: { say?: string; text?: string }) => {
|
||||
partialMessageCount += 1
|
||||
if (firstPartialMessageMs === null && (message.say === "text" || message.say === "reasoning")) {
|
||||
firstPartialMessageMs = Date.now() - startedAt
|
||||
}
|
||||
})
|
||||
|
||||
deltaStream.on("data", (event: { deltaJson?: string }) => {
|
||||
taskDeltaCount += 1
|
||||
const deltaJson = event.deltaJson || ""
|
||||
taskDeltaPayloadBytes += Buffer.byteLength(deltaJson, "utf8")
|
||||
if (firstTaskDeltaMs === null) {
|
||||
try {
|
||||
const delta = JSON.parse(deltaJson)
|
||||
if (delta.type?.startsWith("message_") || delta.type === "task_metadata_updated") {
|
||||
firstTaskDeltaMs = Date.now() - startedAt
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let newTaskRpcMs = 0
|
||||
let error: string | undefined
|
||||
|
||||
try {
|
||||
await unaryCall<{ value?: string }>((callback) => accountClient.accountLoginClicked({}, callback as any))
|
||||
await unaryCall((callback) => accountClient.getUserOrganizations({}, callback as any))
|
||||
|
||||
const rpcStartedAt = Date.now()
|
||||
const newTaskResponse = await unaryCall<{ value?: string }>((callback) =>
|
||||
taskClient.newTask(
|
||||
{
|
||||
metadata: undefined,
|
||||
text: scenario.prompt,
|
||||
images: [],
|
||||
files: [],
|
||||
taskSettings: undefined,
|
||||
},
|
||||
callback as any,
|
||||
),
|
||||
)
|
||||
newTaskRpcMs = Date.now() - rpcStartedAt
|
||||
currentTaskId = newTaskResponse.value || currentTaskId
|
||||
|
||||
const timeoutAt = Date.now() + 20_000
|
||||
while (!completed && Date.now() < timeoutAt) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
if (!completed) {
|
||||
error = "Scenario timed out before completion"
|
||||
}
|
||||
} catch (scenarioError) {
|
||||
error = scenarioError instanceof Error ? scenarioError.message : String(scenarioError)
|
||||
}
|
||||
|
||||
stateStream.cancel()
|
||||
partialStream.cancel()
|
||||
deltaStream.cancel()
|
||||
accountClient.close()
|
||||
stateClient.close()
|
||||
taskClient.close()
|
||||
uiClient.close()
|
||||
await stopServer(server.child)
|
||||
|
||||
return {
|
||||
scenario: scenario.name,
|
||||
variant: variant.name,
|
||||
mode,
|
||||
newTaskRpcMs,
|
||||
firstStateMs,
|
||||
firstPartialMessageMs,
|
||||
firstTaskDeltaMs,
|
||||
completionMs,
|
||||
stateUpdateCount,
|
||||
partialMessageCount,
|
||||
taskDeltaCount,
|
||||
statePayloadBytes,
|
||||
taskDeltaPayloadBytes,
|
||||
totalTransportBytes: statePayloadBytes + taskDeltaPayloadBytes,
|
||||
messageCountAtCompletion,
|
||||
finalUniqueMessageCount,
|
||||
hasDuplicateMessagesAtCompletion,
|
||||
finalStateSignature,
|
||||
completed,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
function compareScenarioResults(results: ScenarioResult[]): ScenarioComparison[] {
|
||||
const comparisons = new Map<string, ScenarioComparison>()
|
||||
|
||||
for (const result of results) {
|
||||
const key = `${result.scenario}:${result.mode}`
|
||||
const comparison = comparisons.get(key)
|
||||
if (!comparison) {
|
||||
comparisons.set(key, {
|
||||
scenario: result.scenario,
|
||||
mode: result.mode,
|
||||
baselineVariant: "delta_disabled",
|
||||
baselineSignature: result.variant === "delta_disabled" ? result.finalStateSignature : null,
|
||||
baselineSemanticSignature: result.variant === "delta_disabled" ? result.finalStateSignature : null,
|
||||
allVariantsMatchBaseline: true,
|
||||
mismatchedVariants: [],
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (result.variant === "delta_disabled") {
|
||||
comparison.baselineSignature = result.finalStateSignature
|
||||
comparison.baselineSemanticSignature = result.finalStateSignature
|
||||
}
|
||||
}
|
||||
|
||||
for (const result of results) {
|
||||
const key = `${result.scenario}:${result.mode}`
|
||||
const comparison = comparisons.get(key)
|
||||
if (!comparison || result.variant === comparison.baselineVariant) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (comparison.baselineSignature !== result.finalStateSignature) {
|
||||
comparison.allVariantsMatchBaseline = false
|
||||
comparison.mismatchedVariants.push(result.variant)
|
||||
}
|
||||
}
|
||||
|
||||
return [...comparisons.values()]
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const results: ScenarioResult[] = []
|
||||
for (const scenario of scenarios) {
|
||||
for (const mode of ["local", "remote"] as const) {
|
||||
for (const variant of variants) {
|
||||
results.push(await runScenario(mode, variant, scenario))
|
||||
}
|
||||
}
|
||||
}
|
||||
const comparisons = compareScenarioResults(results)
|
||||
console.log(JSON.stringify({ results, comparisons }, null, 2))
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -60,6 +60,54 @@ describe("OpenRouterHandler", () => {
|
||||
])
|
||||
})
|
||||
|
||||
it("should read cache_write_tokens from prompt_tokens_details", async () => {
|
||||
const handler = new OpenRouterHandler({
|
||||
openRouterApiKey: "test-api-key",
|
||||
})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
choices: [{}],
|
||||
usage: {
|
||||
prompt_tokens: 1000,
|
||||
completion_tokens: 200,
|
||||
prompt_tokens_details: {
|
||||
cached_tokens: 500,
|
||||
cache_write_tokens: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
sinon.stub(handler, "getModel").returns({
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
info: openRouterDefaultModelInfo,
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{
|
||||
type: "usage",
|
||||
cacheWriteTokens: 300,
|
||||
cacheReadTokens: 500,
|
||||
inputTokens: 200,
|
||||
outputTokens: 200,
|
||||
totalCost: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
type ParallelToolCallsTestCase = {
|
||||
modelId: string
|
||||
enableParallelToolCalling: boolean
|
||||
|
||||
@@ -4,10 +4,12 @@ import axios from "axios"
|
||||
import OpenAI from "openai"
|
||||
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
|
||||
import { ClineEnv } from "@/config"
|
||||
import { refreshClineRecommendedModels } from "@/core/controller/models/refreshClineRecommendedModels"
|
||||
import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { buildClineExtraHeaders } from "@/services/EnvUtils"
|
||||
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@/shared/ClineAccount"
|
||||
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@/shared/cline/recommended-models"
|
||||
import type { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch, getAxiosSettings } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
@@ -31,7 +33,11 @@ interface ClineHandlerOptions extends CommonApiHandlerOptions {
|
||||
enableParallelToolCalling?: boolean
|
||||
}
|
||||
|
||||
const CLINE_FREE_MODELS = ["minimax/minimax-m2.5", "kwaipilot/kat-coder-pro", "z-ai/glm-5"]
|
||||
function normalizeModelId(modelId: string): string {
|
||||
return modelId.trim().toLowerCase()
|
||||
}
|
||||
|
||||
const CLINE_FREE_MODEL_IDS = new Set(CLINE_RECOMMENDED_MODELS_FALLBACK.free.map((model) => normalizeModelId(model.id)))
|
||||
|
||||
export class ClineHandler implements ApiHandler {
|
||||
private options: ClineHandlerOptions
|
||||
@@ -50,6 +56,20 @@ export class ClineHandler implements ApiHandler {
|
||||
this._authService = AuthService.getInstance()
|
||||
}
|
||||
|
||||
private async getFreeModelIdSet(): Promise<Set<string>> {
|
||||
try {
|
||||
const models = await refreshClineRecommendedModels()
|
||||
const freeModelIds = models.free.map((model) => normalizeModelId(model.id)).filter((modelId) => modelId.length > 0)
|
||||
if (freeModelIds.length > 0) {
|
||||
return new Set(freeModelIds)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Error resolving Cline free model IDs from recommended models:", error)
|
||||
}
|
||||
|
||||
return CLINE_FREE_MODEL_IDS
|
||||
}
|
||||
|
||||
private async ensureClient(): Promise<OpenAI> {
|
||||
const clineAccountAuthToken = this.options.clineApiKey || (await this._authService.getAuthToken())
|
||||
if (!clineAccountAuthToken) {
|
||||
@@ -112,6 +132,7 @@ export class ClineHandler implements ApiHandler {
|
||||
this.lastRequestId = undefined
|
||||
|
||||
let didOutputUsage = false
|
||||
const freeModelIds = await this.getFreeModelIdSet()
|
||||
|
||||
const stream = await createOpenRouterStream(
|
||||
client,
|
||||
@@ -208,7 +229,7 @@ export class ClineHandler implements ApiHandler {
|
||||
// @ts-expect-error-next-line
|
||||
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
|
||||
const modelId = this.getModel().id
|
||||
const isFreeModel = CLINE_FREE_MODELS.includes(modelId)
|
||||
const isFreeModel = freeModelIds.has(normalizeModelId(modelId))
|
||||
|
||||
if (isFreeModel) {
|
||||
totalCost = 0
|
||||
@@ -229,7 +250,7 @@ export class ClineHandler implements ApiHandler {
|
||||
// Fallback to generation endpoint if usage chunk not returned
|
||||
if (!didOutputUsage) {
|
||||
Logger.warn("Cline API did not return usage chunk, fetching from generation endpoint")
|
||||
const apiStreamUsage = await this.getApiStreamUsage()
|
||||
const apiStreamUsage = await this.getApiStreamUsage(freeModelIds)
|
||||
if (apiStreamUsage) {
|
||||
yield apiStreamUsage
|
||||
}
|
||||
@@ -240,9 +261,10 @@ export class ClineHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
|
||||
async getApiStreamUsage(freeModelIds?: Set<string>): Promise<ApiStreamUsageChunk | undefined> {
|
||||
if (this.lastGenerationId) {
|
||||
try {
|
||||
const resolvedFreeModelIds = freeModelIds || (await this.getFreeModelIdSet())
|
||||
const clineAccountAuthToken = await this._authService.getAuthToken()
|
||||
if (!clineAccountAuthToken) {
|
||||
throw new Error(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE)
|
||||
@@ -262,7 +284,7 @@ export class ClineHandler implements ApiHandler {
|
||||
const generation = response.data
|
||||
let totalCost = generation?.total_cost || 0
|
||||
const modelId = this.getModel().id
|
||||
const isFreeModel = CLINE_FREE_MODELS.includes(modelId)
|
||||
const isFreeModel = resolvedFreeModelIds.has(normalizeModelId(modelId))
|
||||
|
||||
if (isFreeModel) {
|
||||
totalCost = 0
|
||||
|
||||
@@ -394,18 +394,29 @@ export class OcaHandler implements ApiHandler {
|
||||
strict: tool.function.strict ?? true, // Responses API defaults to strict mode
|
||||
}))
|
||||
|
||||
const responsesParams: OpenAI.Responses.ResponseCreateParamsStreaming = {
|
||||
model: this.options.ocaModelId || liteLlmDefaultModelId,
|
||||
input,
|
||||
stream: true,
|
||||
tools: responseTools,
|
||||
}
|
||||
let temperature: number | undefined = this.options.ocaModelInfo?.temperature ?? 0
|
||||
const maxOutputTokens: number | undefined = this.options.ocaModelInfo?.maxTokens
|
||||
|
||||
const ocaModelInfo = this.options.ocaModelInfo
|
||||
if (!ocaModelInfo) {
|
||||
throw new Error("Oracle Code Assist (OCA) model info is required for Responses API")
|
||||
}
|
||||
if (ocaModelInfo.supportsReasoning) {
|
||||
|
||||
const reasoningOn = !!ocaModelInfo.supportsReasoning
|
||||
if (reasoningOn) {
|
||||
temperature = undefined
|
||||
}
|
||||
|
||||
const responsesParams: OpenAI.Responses.ResponseCreateParamsStreaming = {
|
||||
model: this.options.ocaModelId || liteLlmDefaultModelId,
|
||||
input,
|
||||
stream: true,
|
||||
tools: responseTools,
|
||||
...(typeof temperature === "number" ? { temperature } : {}),
|
||||
...(typeof maxOutputTokens === "number" && maxOutputTokens > 0 ? { max_output_tokens: maxOutputTokens } : {}),
|
||||
}
|
||||
|
||||
if (reasoningOn) {
|
||||
responsesParams.reasoning = { effort: this.options.ocaReasoningEffort as any, summary: "auto" }
|
||||
}
|
||||
|
||||
|
||||
@@ -154,11 +154,16 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
// @ts-expect-error-next-line -- OpenRouter returns cache_write_tokens for Anthropic models
|
||||
const cacheWriteTokens = chunk.usage.prompt_tokens_details?.cache_write_tokens || 0
|
||||
yield {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheWriteTokens,
|
||||
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
inputTokens: (chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
|
||||
inputTokens:
|
||||
(chunk.usage.prompt_tokens || 0) -
|
||||
(chunk.usage.prompt_tokens_details?.cached_tokens || 0) -
|
||||
(cacheWriteTokens || 0),
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-expect-error-next-line
|
||||
totalCost: (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0),
|
||||
@@ -185,7 +190,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
// Logger.log("OpenRouter generation details:", generation)
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheWriteTokens: generation?.native_tokens_cache_write || 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
// openrouter generation endpoint fails often
|
||||
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
|
||||
|
||||
@@ -52,74 +52,41 @@ export async function createOpenRouterStream(
|
||||
openAiMessages = sanitizeGeminiMessages(openAiMessages, model.id)
|
||||
|
||||
// prompt caching: https://openrouter.ai/docs/prompt-caching
|
||||
// this was initially specifically for claude models (some models may 'support prompt caching' automatically without this)
|
||||
// handles direct model.id match logic
|
||||
switch (model.id) {
|
||||
case "anthropic/claude-opus-4.6":
|
||||
case "anthropic/claude-haiku-4.5":
|
||||
case "anthropic/claude-4.5-haiku":
|
||||
case "anthropic/claude-sonnet-4.6":
|
||||
case "anthropic/claude-4.6-sonnet":
|
||||
case "anthropic/claude-sonnet-4.5":
|
||||
case "anthropic/claude-4.5-sonnet": // OpenRouter accidentally included this in model list for a brief moment, and users may be using this model id. And to support prompt caching, we need to add it here.
|
||||
case "anthropic/claude-sonnet-4":
|
||||
case "anthropic/claude-opus-4.5":
|
||||
case "anthropic/claude-opus-4.1":
|
||||
case "anthropic/claude-opus-4":
|
||||
case "anthropic/claude-3.7-sonnet":
|
||||
case "anthropic/claude-3.7-sonnet:beta":
|
||||
case "anthropic/claude-3.7-sonnet:thinking":
|
||||
case "anthropic/claude-3-7-sonnet":
|
||||
case "anthropic/claude-3-7-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet":
|
||||
case "anthropic/claude-3.5-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet-20240620":
|
||||
case "anthropic/claude-3.5-sonnet-20240620:beta":
|
||||
case "anthropic/claude-3-5-haiku":
|
||||
case "anthropic/claude-3-5-haiku:beta":
|
||||
case "anthropic/claude-3-5-haiku-20241022":
|
||||
case "anthropic/claude-3-5-haiku-20241022:beta":
|
||||
case "anthropic/claude-3-haiku":
|
||||
case "anthropic/claude-3-haiku:beta":
|
||||
case "anthropic/claude-3-opus":
|
||||
case "anthropic/claude-3-opus:beta":
|
||||
case "minimax/minimax-m2":
|
||||
case "minimax/minimax-m2.1":
|
||||
case "minimax/minimax-m2.1-lightning":
|
||||
case "minimax/minimax-m2.5":
|
||||
openAiMessages[0] = {
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: systemPrompt,
|
||||
// @ts-expect-error-next-line
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
}
|
||||
// Add cache_control to the last two user messages
|
||||
// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
|
||||
const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2)
|
||||
lastTwoUserMessages.forEach((msg) => {
|
||||
if (typeof msg.content === "string") {
|
||||
msg.content = [{ type: "text", text: msg.content }]
|
||||
}
|
||||
if (Array.isArray(msg.content)) {
|
||||
// NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end.
|
||||
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
|
||||
// Anthropic and MiniMax models require explicit cache_control blocks to enable prompt caching on OpenRouter.
|
||||
// Other providers (OpenAI, Google) handle caching automatically without cache_control blocks.
|
||||
const needsCacheControl = model.id.startsWith("anthropic/") || model.id.startsWith("minimax/")
|
||||
|
||||
if (!lastTextPart) {
|
||||
lastTextPart = { type: "text", text: "..." }
|
||||
msg.content.push(lastTextPart)
|
||||
}
|
||||
if (needsCacheControl) {
|
||||
openAiMessages[0] = {
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: systemPrompt,
|
||||
// @ts-expect-error-next-line
|
||||
lastTextPart["cache_control"] = { type: "ephemeral" }
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
}
|
||||
// Add cache_control to the last two user messages
|
||||
// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
|
||||
const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2)
|
||||
lastTwoUserMessages.forEach((msg) => {
|
||||
if (typeof msg.content === "string") {
|
||||
msg.content = [{ type: "text", text: msg.content }]
|
||||
}
|
||||
if (Array.isArray(msg.content)) {
|
||||
// NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end.
|
||||
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
|
||||
|
||||
if (!lastTextPart) {
|
||||
lastTextPart = { type: "text", text: "..." }
|
||||
msg.content.push(lastTextPart)
|
||||
}
|
||||
})
|
||||
break
|
||||
default:
|
||||
break
|
||||
// @ts-expect-error-next-line
|
||||
lastTextPart["cache_control"] = { type: "ephemeral" }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let temperature: number | undefined = 0
|
||||
|
||||
@@ -59,7 +59,6 @@ import { appendClineStealthModels } from "./models/refreshOpenRouterModels"
|
||||
import { checkCliInstallation } from "./state/checkCliInstallation"
|
||||
import { sendStateUpdate } from "./state/subscribeToState"
|
||||
import { sendChatButtonClickedEvent } from "./ui/subscribeToChatButtonClicked"
|
||||
import { sendTaskUiDelta } from "./ui/subscribeToTaskUiDeltas"
|
||||
|
||||
/*
|
||||
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
|
||||
@@ -500,37 +499,9 @@ export class Controller {
|
||||
}
|
||||
this.backgroundCommandRunning = running
|
||||
this.backgroundCommandTaskId = nextTaskId
|
||||
if (this.task && nextTaskId && this.task.taskId === nextTaskId) {
|
||||
void this.postTaskMetadataDelta({
|
||||
backgroundCommandRunning: running,
|
||||
backgroundCommandTaskId: nextTaskId,
|
||||
})
|
||||
return
|
||||
}
|
||||
void this.postStateToWebview()
|
||||
}
|
||||
|
||||
async postTaskMetadataDelta(
|
||||
metadata: Partial<
|
||||
Pick<ExtensionState, "currentFocusChainChecklist" | "backgroundCommandRunning" | "backgroundCommandTaskId">
|
||||
>,
|
||||
taskId?: string,
|
||||
) {
|
||||
const targetTask = this.task
|
||||
const resolvedTaskId = taskId ?? targetTask?.taskId
|
||||
if (!targetTask || !resolvedTaskId || targetTask.taskId !== resolvedTaskId) {
|
||||
await this.postStateToWebview()
|
||||
return
|
||||
}
|
||||
|
||||
await sendTaskUiDelta({
|
||||
type: "task_metadata_updated",
|
||||
taskId: resolvedTaskId,
|
||||
sequence: ++targetTask.taskState.taskUiDeltaSequence,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
async cancelBackgroundCommand(): Promise<void> {
|
||||
const didCancel = await this.task?.cancelBackgroundCommand()
|
||||
if (!didCancel) {
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { TaskUiDeltaEvent } from "@shared/proto/cline/ui"
|
||||
import { strict as assert } from "assert"
|
||||
import { describe, it } from "mocha"
|
||||
import { registerTaskUiDeltaCallback, sendTaskUiDelta, subscribeToTaskUiDeltas } from "./subscribeToTaskUiDeltas"
|
||||
|
||||
describe("subscribeToTaskUiDeltas", () => {
|
||||
it("broadcasts serialized task deltas to active stream subscribers", async () => {
|
||||
const received: TaskUiDeltaEvent[] = []
|
||||
const callbackReceived: Array<{ type: string; sequence: number }> = []
|
||||
const unsubscribe = registerTaskUiDeltaCallback((delta) => {
|
||||
callbackReceived.push({ type: delta.type, sequence: delta.sequence })
|
||||
})
|
||||
|
||||
await subscribeToTaskUiDeltas({} as any, EmptyRequest.create({}), async (message) => {
|
||||
received.push(message)
|
||||
})
|
||||
|
||||
await sendTaskUiDelta({
|
||||
type: "message_updated",
|
||||
taskId: "task-1",
|
||||
sequence: 1,
|
||||
message: { ts: 123, type: "say", say: "text", text: "delta-text" },
|
||||
})
|
||||
const stats = await sendTaskUiDelta({
|
||||
type: "message_updated",
|
||||
taskId: "task-1",
|
||||
sequence: 2,
|
||||
message: { ts: 124, type: "say", say: "text", text: "delta-text-2" },
|
||||
})
|
||||
|
||||
assert.equal(received.length, 2)
|
||||
assert.ok(received[0]?.deltaJson)
|
||||
assert.ok(stats)
|
||||
assert.ok((stats?.payloadBytes ?? 0) > 0)
|
||||
assert.ok((stats?.broadcastDurationMs ?? -1) >= 0)
|
||||
assert.ok((stats?.streamSubscriberCount ?? 0) >= 1)
|
||||
assert.ok((stats?.callbackSubscriberCount ?? 0) >= 1)
|
||||
|
||||
const parsed = JSON.parse(received[0]!.deltaJson)
|
||||
assert.equal(parsed.type, "message_updated")
|
||||
assert.equal(parsed.taskId, "task-1")
|
||||
assert.equal(parsed.sequence, 1)
|
||||
assert.equal(parsed.message.text, "delta-text")
|
||||
const parsedSecond = JSON.parse(received[1]!.deltaJson)
|
||||
assert.equal(parsedSecond.sequence, 2)
|
||||
assert.equal(parsedSecond.message.text, "delta-text-2")
|
||||
assert.deepStrictEqual(callbackReceived, [
|
||||
{ type: "message_updated", sequence: 1 },
|
||||
{ type: "message_updated", sequence: 2 },
|
||||
])
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it("removes stream subscribers that throw during delivery", async () => {
|
||||
const received: TaskUiDeltaEvent[] = []
|
||||
|
||||
await subscribeToTaskUiDeltas({} as any, EmptyRequest.create({}), async () => {
|
||||
throw new Error("stream disconnected")
|
||||
})
|
||||
|
||||
await subscribeToTaskUiDeltas({} as any, EmptyRequest.create({}), async (message) => {
|
||||
received.push(message)
|
||||
})
|
||||
|
||||
await sendTaskUiDelta({
|
||||
type: "task_state_resynced",
|
||||
taskId: "task-1",
|
||||
sequence: 1,
|
||||
})
|
||||
|
||||
await sendTaskUiDelta({
|
||||
type: "task_metadata_updated",
|
||||
taskId: "task-1",
|
||||
sequence: 2,
|
||||
metadata: { backgroundCommandRunning: true, backgroundCommandTaskId: "task-1" },
|
||||
})
|
||||
|
||||
assert.equal(received.length, 2)
|
||||
const first = JSON.parse(received[0]!.deltaJson)
|
||||
const second = JSON.parse(received[1]!.deltaJson)
|
||||
assert.equal(first.type, "task_state_resynced")
|
||||
assert.equal(second.type, "task_metadata_updated")
|
||||
assert.equal(second.metadata.backgroundCommandRunning, true)
|
||||
})
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { TaskUiDeltaEvent } from "@shared/proto/cline/ui"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { TaskUiDelta } from "@/shared/TaskUiDelta"
|
||||
import { getRequestRegistry, StreamingResponseHandler } from "../grpc-handler"
|
||||
import type { Controller } from "../index"
|
||||
|
||||
const activeTaskUiDeltaSubscriptions = new Set<StreamingResponseHandler<TaskUiDeltaEvent>>()
|
||||
export type TaskUiDeltaCallback = (delta: TaskUiDelta) => void
|
||||
const callbackSubscriptions = new Set<TaskUiDeltaCallback>()
|
||||
|
||||
export type TaskUiDeltaDeliveryStats = {
|
||||
payloadBytes: number
|
||||
broadcastDurationMs: number
|
||||
streamSubscriberCount: number
|
||||
callbackSubscriberCount: number
|
||||
}
|
||||
|
||||
export function registerTaskUiDeltaCallback(callback: TaskUiDeltaCallback): () => void {
|
||||
callbackSubscriptions.add(callback)
|
||||
return () => {
|
||||
callbackSubscriptions.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
export async function subscribeToTaskUiDeltas(
|
||||
_controller: Controller,
|
||||
_request: EmptyRequest,
|
||||
responseStream: StreamingResponseHandler<TaskUiDeltaEvent>,
|
||||
requestId?: string,
|
||||
): Promise<void> {
|
||||
activeTaskUiDeltaSubscriptions.add(responseStream)
|
||||
|
||||
const cleanup = () => {
|
||||
activeTaskUiDeltaSubscriptions.delete(responseStream)
|
||||
}
|
||||
|
||||
if (requestId) {
|
||||
getRequestRegistry().registerRequest(requestId, cleanup, { type: "task_ui_delta_subscription" }, responseStream)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendTaskUiDelta(delta: TaskUiDelta): Promise<TaskUiDeltaDeliveryStats | undefined> {
|
||||
let deltaJson: string
|
||||
try {
|
||||
deltaJson = JSON.stringify(delta)
|
||||
} catch (error) {
|
||||
Logger.error("Error serializing task UI delta:", error)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadBytes = Buffer.byteLength(deltaJson, "utf8")
|
||||
telemetryService.captureGrpcResponseSize(payloadBytes, "cline.UiService", "subscribeToTaskUiDeltas")
|
||||
const startedAt = performance.now()
|
||||
|
||||
const promises = Array.from(activeTaskUiDeltaSubscriptions).map(async (responseStream) => {
|
||||
try {
|
||||
await responseStream(TaskUiDeltaEvent.create({ deltaJson }), false)
|
||||
} catch (error) {
|
||||
Logger.error("Error sending task UI delta:", error)
|
||||
activeTaskUiDeltaSubscriptions.delete(responseStream)
|
||||
}
|
||||
})
|
||||
|
||||
for (const callback of callbackSubscriptions) {
|
||||
try {
|
||||
callback(delta)
|
||||
} catch (error) {
|
||||
Logger.error("Error sending task UI delta to callback subscriber:", error)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
return {
|
||||
payloadBytes,
|
||||
broadcastDurationMs: Math.max(0, performance.now() - startedAt),
|
||||
streamSubscriberCount: activeTaskUiDeltaSubscriptions.size,
|
||||
callbackSubscriberCount: callbackSubscriptions.size,
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,16 @@ import "should"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { HookOutput } from "../../../shared/proto/cline/hooks"
|
||||
import { HookFactory } from "../hook-factory"
|
||||
import { createHookTestEnv, HookTestEnv, loadFixture, stubHookDirs, writeHookScriptForPlatform } from "./test-utils"
|
||||
import { createHookTestEnv, HookTestEnv, stubHookDirs, withFixtureRunner, writeHookScriptForPlatform } from "./test-utils"
|
||||
|
||||
describe("TaskComplete Hook", () => {
|
||||
let tempDir: string
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let getEnv: () => { tempDir: string }
|
||||
let hookTestEnv: HookTestEnv
|
||||
const getErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
||||
|
||||
const writeHookScript = async (hookPath: string, nodeScript: string): Promise<void> => {
|
||||
await writeHookScriptForPlatform(hookPath, nodeScript)
|
||||
@@ -432,72 +434,73 @@ console.log(JSON.stringify({
|
||||
})
|
||||
|
||||
describe("Fixture-Based Tests", () => {
|
||||
it("should work with success fixture", async () => {
|
||||
await loadFixture("hooks/taskcomplete/success", getEnv().tempDir)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("TaskComplete")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskComplete: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
result: "Test task",
|
||||
command: "",
|
||||
it("should validate representative fixtures end-to-end", async () => {
|
||||
const scenarios: Array<{
|
||||
fixtureName: string
|
||||
resultText: string
|
||||
assert: (result: HookOutput) => void
|
||||
}> = [
|
||||
{
|
||||
fixtureName: "success",
|
||||
resultText: "Test task",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("TaskComplete hook executed successfully")
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("TaskComplete hook executed successfully")
|
||||
})
|
||||
|
||||
it("should work with error fixture", async () => {
|
||||
await loadFixture("hooks/taskcomplete/error", getEnv().tempDir)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("TaskComplete")
|
||||
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskComplete: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
result: "Test task",
|
||||
command: "",
|
||||
},
|
||||
{
|
||||
fixtureName: "context-injection",
|
||||
resultText: "Build a todo app",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("COMPLETED: Build a todo app")
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/TaskComplete.*exited with code 1/)
|
||||
},
|
||||
]
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
await withFixtureRunner(
|
||||
"TaskComplete",
|
||||
`hooks/taskcomplete/${scenario.fixtureName}`,
|
||||
hookTestEnv,
|
||||
async (runner) => {
|
||||
const result = await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskComplete: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
result: scenario.resultText,
|
||||
command: "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
scenario.assert(result)
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("should work with context-injection fixture", async () => {
|
||||
await loadFixture("hooks/taskcomplete/context-injection", getEnv().tempDir)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("TaskComplete")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskComplete: {
|
||||
taskMetadata: {
|
||||
it("should preserve fixture-based failure behavior", async () => {
|
||||
await withFixtureRunner("TaskComplete", "hooks/taskcomplete/error", hookTestEnv, async (runner) => {
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
result: "Build a todo app",
|
||||
command: "",
|
||||
},
|
||||
},
|
||||
taskComplete: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
result: "Test task",
|
||||
command: "",
|
||||
},
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: unknown) {
|
||||
getErrorMessage(error).should.match(/TaskComplete.*exited with code 1/)
|
||||
}
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("COMPLETED: Build a todo app")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,8 +3,9 @@ import "should"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { HookOutput } from "../../../shared/proto/cline/hooks"
|
||||
import { HookFactory } from "../hook-factory"
|
||||
import { createHookTestEnv, HookTestEnv, stubHookDirs, writeHookScriptForPlatform } from "./test-utils"
|
||||
import { createHookTestEnv, HookTestEnv, stubHookDirs, withFixtureRunner, writeHookScriptForPlatform } from "./test-utils"
|
||||
|
||||
describe("TaskResume Hook", () => {
|
||||
let tempDir: string
|
||||
@@ -12,6 +13,16 @@ describe("TaskResume Hook", () => {
|
||||
let hookTestEnv: HookTestEnv
|
||||
const WINDOWS_HOOK_TEST_TIMEOUT_MS = 15000
|
||||
|
||||
type FixtureScenario = {
|
||||
fixtureName: string
|
||||
lastMessageTs: string
|
||||
messageCount: string
|
||||
conversationHistoryDeleted: string
|
||||
assert: (result: HookOutput) => void
|
||||
}
|
||||
|
||||
const getErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
||||
|
||||
const writeHookScript = async (hookPath: string, nodeScript: string): Promise<void> => {
|
||||
await writeHookScriptForPlatform(hookPath, nodeScript)
|
||||
}
|
||||
@@ -146,7 +157,11 @@ console.log(JSON.stringify({
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle very old timestamps (days ago)", async () => {
|
||||
it("should handle very old timestamps (days ago)", async function () {
|
||||
if (process.platform === "win32") {
|
||||
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
|
||||
const hookScript = `#!/usr/bin/env node
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
@@ -537,149 +552,114 @@ console.log(JSON.stringify({
|
||||
})
|
||||
|
||||
describe("Fixture-Based Tests", () => {
|
||||
const loadFixtureAndCreateRunner = async (fixtureName: string) => {
|
||||
const { loadFixture } = await import("./test-utils")
|
||||
await loadFixture(`hooks/taskresume/${fixtureName}`, tempDir)
|
||||
|
||||
const factory = new HookFactory()
|
||||
return await factory.create("TaskResume")
|
||||
}
|
||||
|
||||
it("should work with success fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("success")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
taskResume: {
|
||||
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
|
||||
previousState: {
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("TaskResume hook executed successfully")
|
||||
})
|
||||
|
||||
it("should work with recent-resume fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("recent-resume")
|
||||
|
||||
const twoMinutesAgo = Date.now() - 2 * 60 * 1000
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
taskResume: {
|
||||
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
|
||||
previousState: {
|
||||
lastMessageTs: twoMinutesAgo.toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.match(/Recently paused task/)
|
||||
})
|
||||
|
||||
it("should work with long-pause fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("long-pause")
|
||||
|
||||
const twoDaysAgo = Date.now() - 48 * 60 * 60 * 1000
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
taskResume: {
|
||||
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
|
||||
previousState: {
|
||||
lastMessageTs: twoDaysAgo.toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.match(/paused 48 hours ago/)
|
||||
})
|
||||
|
||||
it("should work with context-deleted fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("context-deleted")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
taskResume: {
|
||||
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
|
||||
previousState: {
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "50",
|
||||
conversationHistoryDeleted: "true",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.match(/truncated/)
|
||||
})
|
||||
|
||||
it("should work with message-count fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("message-count")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
taskResume: {
|
||||
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
|
||||
previousState: {
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "25",
|
||||
conversationHistoryDeleted: "false",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("TASK_CONTEXT: Resuming task with 25 previous messages")
|
||||
})
|
||||
|
||||
it("should work with context-injection fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("context-injection")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
taskResume: {
|
||||
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
|
||||
previousState: {
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("WORKSPACE_RULES: Task test-task resumed - review previous context")
|
||||
})
|
||||
|
||||
it("should work with error fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("error")
|
||||
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task",
|
||||
taskResume: {
|
||||
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
|
||||
previousState: {
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
},
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/exited with code 1/)
|
||||
it("should validate representative fixtures end-to-end", async function () {
|
||||
if (process.platform === "win32") {
|
||||
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
const scenarios: FixtureScenario[] = [
|
||||
{
|
||||
fixtureName: "success",
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("TaskResume hook executed successfully")
|
||||
},
|
||||
},
|
||||
{
|
||||
fixtureName: "recent-resume",
|
||||
lastMessageTs: (Date.now() - 2 * 60 * 1000).toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.match(/Recently paused task/)
|
||||
},
|
||||
},
|
||||
{
|
||||
fixtureName: "long-pause",
|
||||
lastMessageTs: (Date.now() - 48 * 60 * 60 * 1000).toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.match(/paused 48 hours ago/)
|
||||
},
|
||||
},
|
||||
{
|
||||
fixtureName: "context-deleted",
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "50",
|
||||
conversationHistoryDeleted: "true",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.match(/truncated/)
|
||||
},
|
||||
},
|
||||
{
|
||||
fixtureName: "message-count",
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "25",
|
||||
conversationHistoryDeleted: "false",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("TASK_CONTEXT: Resuming task with 25 previous messages")
|
||||
},
|
||||
},
|
||||
{
|
||||
fixtureName: "context-injection",
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal(
|
||||
"WORKSPACE_RULES: Task test-task resumed - review previous context",
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
await withFixtureRunner("TaskResume", `hooks/taskresume/${scenario.fixtureName}`, hookTestEnv, async (runner) => {
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
taskResume: {
|
||||
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
|
||||
previousState: {
|
||||
lastMessageTs: scenario.lastMessageTs,
|
||||
messageCount: scenario.messageCount,
|
||||
conversationHistoryDeleted: scenario.conversationHistoryDeleted,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
scenario.assert(result)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it("should preserve fixture-based failure behavior", async () => {
|
||||
await withFixtureRunner("TaskResume", "hooks/taskresume/error", hookTestEnv, async (runner) => {
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task",
|
||||
taskResume: {
|
||||
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
|
||||
previousState: {
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
},
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: unknown) {
|
||||
getErrorMessage(error).should.match(/exited with code 1/)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,14 +3,16 @@ import "should"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { HookOutput } from "../../../shared/proto/cline/hooks"
|
||||
import { HookFactory } from "../hook-factory"
|
||||
import { createHookTestEnv, HookTestEnv, loadFixture, stubHookDirs, writeHookScriptForPlatform } from "./test-utils"
|
||||
import { createHookTestEnv, HookTestEnv, stubHookDirs, withFixtureRunner, writeHookScriptForPlatform } from "./test-utils"
|
||||
|
||||
describe("TaskStart Hook", () => {
|
||||
let tempDir: string
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let getEnv: () => { tempDir: string }
|
||||
let hookTestEnv: HookTestEnv
|
||||
const getErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
||||
|
||||
const writeHookScript = async (hookPath: string, nodeScript: string): Promise<void> => {
|
||||
await writeHookScriptForPlatform(hookPath, nodeScript)
|
||||
@@ -421,69 +423,60 @@ console.log(JSON.stringify({
|
||||
})
|
||||
|
||||
describe("Fixture-Based Tests", () => {
|
||||
it("should work with success fixture", async () => {
|
||||
await loadFixture("hooks/taskstart/success", getEnv().tempDir)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("TaskStart")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "Test task",
|
||||
it("should validate representative fixtures end-to-end", async () => {
|
||||
const scenarios: Array<{ fixtureName: string; assert: (result: HookOutput) => void }> = [
|
||||
{
|
||||
fixtureName: "success",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("TaskStart hook executed successfully")
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("TaskStart hook executed successfully")
|
||||
})
|
||||
|
||||
it("should work with blocking fixture", async () => {
|
||||
await loadFixture("hooks/taskstart/blocking", getEnv().tempDir)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("TaskStart")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "Test task",
|
||||
{
|
||||
fixtureName: "blocking",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.true()
|
||||
result.errorMessage?.should.equal("Task execution blocked by hook")
|
||||
},
|
||||
},
|
||||
})
|
||||
]
|
||||
|
||||
result.cancel.should.be.true()
|
||||
result.errorMessage?.should.equal("Task execution blocked by hook")
|
||||
})
|
||||
|
||||
it("should work with error fixture", async () => {
|
||||
await loadFixture("hooks/taskstart/error", getEnv().tempDir)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("TaskStart")
|
||||
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "Test task",
|
||||
for (const scenario of scenarios) {
|
||||
await withFixtureRunner("TaskStart", `hooks/taskstart/${scenario.fixtureName}`, hookTestEnv, async (runner) => {
|
||||
const result = await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "Test task",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
scenario.assert(result)
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/TaskStart.*exited with code 1/)
|
||||
}
|
||||
})
|
||||
|
||||
it("should preserve fixture-based failure behavior", async () => {
|
||||
await withFixtureRunner("TaskStart", "hooks/taskstart/error", hookTestEnv, async (runner) => {
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "Test task",
|
||||
},
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: unknown) {
|
||||
getErrorMessage(error).should.match(/TaskStart.*exited with code 1/)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import { HookOutput } from "../../../shared/proto/cline/hooks"
|
||||
import * as diskModule from "../../storage/disk"
|
||||
import { StateManager } from "../../storage/StateManager"
|
||||
import { HookDiscoveryCache } from "../HookDiscoveryCache"
|
||||
import { Hooks, NamedHookInput } from "../hook-factory"
|
||||
import { HookFactory, Hooks, NamedHookInput } from "../hook-factory"
|
||||
|
||||
// Define HookName locally since it's not exported from hook-factory
|
||||
type HookName = keyof Hooks
|
||||
@@ -576,3 +576,49 @@ export async function loadFixture(fixtureName: string, destDir: string): Promise
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an isolated hook test environment, loads a fixture into it, creates a runner,
|
||||
* and guarantees cleanup once the callback completes.
|
||||
*
|
||||
* This is useful for fixture suites that want to iterate through multiple scenarios
|
||||
* without sharing hook directories, discovery cache state, or filesystem artifacts
|
||||
* between scenarios.
|
||||
*/
|
||||
export async function withFixtureRunner<Name extends HookName, TResult>(
|
||||
hookName: Name,
|
||||
fixtureName: string,
|
||||
callback: (runner: Awaited<ReturnType<HookFactory["create"]>>, env: HookTestEnv) => Promise<TResult>,
|
||||
): Promise<TResult>
|
||||
export async function withFixtureRunner<Name extends HookName, TResult>(
|
||||
hookName: Name,
|
||||
fixtureName: string,
|
||||
env: HookTestEnv,
|
||||
callback: (runner: Awaited<ReturnType<HookFactory["create"]>>, env: HookTestEnv) => Promise<TResult>,
|
||||
): Promise<TResult>
|
||||
export async function withFixtureRunner<Name extends HookName, TResult>(
|
||||
hookName: Name,
|
||||
fixtureName: string,
|
||||
envOrCallback: HookTestEnv | ((runner: Awaited<ReturnType<HookFactory["create"]>>, env: HookTestEnv) => Promise<TResult>),
|
||||
maybeCallback?: (runner: Awaited<ReturnType<HookFactory["create"]>>, env: HookTestEnv) => Promise<TResult>,
|
||||
): Promise<TResult> {
|
||||
const usingExistingEnv = typeof envOrCallback !== "function"
|
||||
const env = usingExistingEnv ? envOrCallback : await createHookTestEnv()
|
||||
const runCallback = usingExistingEnv ? maybeCallback : envOrCallback
|
||||
if (!runCallback) {
|
||||
throw new Error("withFixtureRunner requires a callback")
|
||||
}
|
||||
try {
|
||||
await fs.rm(env.hooksDir, { recursive: true, force: true })
|
||||
await createHooksDirectory(env.tempDir)
|
||||
resetHookCache()
|
||||
await loadFixture(fixtureName, env.tempDir)
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create(hookName)
|
||||
return await runCallback(runner, env)
|
||||
} finally {
|
||||
if (!usingExistingEnv) {
|
||||
await env.cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,23 @@ import "should"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { HookOutput } from "../../../shared/proto/cline/hooks"
|
||||
import { HookFactory } from "../hook-factory"
|
||||
import { createHookTestEnv, HookTestEnv, stubHookDirs, writeHookScriptForPlatform } from "./test-utils"
|
||||
import { createHookTestEnv, HookTestEnv, stubHookDirs, withFixtureRunner, writeHookScriptForPlatform } from "./test-utils"
|
||||
|
||||
describe("UserPromptSubmit Hook", () => {
|
||||
let tempDir: string
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let hookTestEnv: HookTestEnv
|
||||
const WINDOWS_HOOK_TEST_TIMEOUT_MS = 15000
|
||||
|
||||
type FixtureScenario = {
|
||||
fixtureName: string
|
||||
prompt: string
|
||||
assert: (result: HookOutput) => void
|
||||
}
|
||||
|
||||
const getErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
||||
|
||||
const writeHookScript = async (hookPath: string, nodeScript: string): Promise<void> => {
|
||||
await writeHookScriptForPlatform(hookPath, nodeScript)
|
||||
@@ -245,8 +255,8 @@ process.exit(1)`
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/exited with code 1/)
|
||||
} catch (error: unknown) {
|
||||
getErrorMessage(error).should.match(/exited with code 1/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -358,161 +368,127 @@ console.log(JSON.stringify({
|
||||
describe("Fixture-Based Tests", () => {
|
||||
// These tests demonstrate using pre-written fixtures from the fixtures directory
|
||||
// Fixtures serve as both test data and examples for manual testing
|
||||
const isWindows = process.platform === "win32"
|
||||
|
||||
// Helper to load a fixture and create a runner
|
||||
const loadFixtureAndCreateRunner = async (fixtureName: string) => {
|
||||
const { loadFixture } = await import("./test-utils")
|
||||
await loadFixture(`hooks/userpromptsubmit/${fixtureName}`, tempDir)
|
||||
it("should validate representative fixtures end-to-end", async function () {
|
||||
if (isWindows) {
|
||||
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
const factory = new HookFactory()
|
||||
return await factory.create("UserPromptSubmit")
|
||||
}
|
||||
|
||||
it("should work with success fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("success")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
const scenarios: FixtureScenario[] = [
|
||||
{
|
||||
fixtureName: "success",
|
||||
prompt: "Create a feature",
|
||||
attachments: [],
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("Prompt approved")
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("Prompt approved")
|
||||
})
|
||||
|
||||
it("should work with blocking fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("blocking")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
{
|
||||
fixtureName: "blocking",
|
||||
prompt: "Do something forbidden",
|
||||
attachments: [],
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.true()
|
||||
result.errorMessage?.should.equal("Prompt violates policy")
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.true()
|
||||
result.errorMessage?.should.equal("Prompt violates policy")
|
||||
})
|
||||
|
||||
it("should work with context-injection fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("context-injection")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
{
|
||||
fixtureName: "context-injection",
|
||||
prompt: "Build something",
|
||||
attachments: [],
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("CONTEXT_INJECTION: User is in plan mode")
|
||||
},
|
||||
},
|
||||
})
|
||||
{
|
||||
fixtureName: "multiline",
|
||||
prompt: "Line 1\nLine 2\nLine 3",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("Line count: 3")
|
||||
},
|
||||
},
|
||||
{
|
||||
fixtureName: "special-chars",
|
||||
prompt: "Test @user #feature $cost",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("Special chars preserved")
|
||||
},
|
||||
},
|
||||
{
|
||||
fixtureName: "empty-prompt",
|
||||
prompt: "",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("Prompt length: 0")
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("CONTEXT_INJECTION: User is in plan mode")
|
||||
if (!isWindows) {
|
||||
scenarios.push({
|
||||
fixtureName: "large-prompt",
|
||||
prompt: "x".repeat(10000),
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("Prompt size: 10000")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
await withFixtureRunner(
|
||||
"UserPromptSubmit",
|
||||
`hooks/userpromptsubmit/${scenario.fixtureName}`,
|
||||
hookTestEnv,
|
||||
async (runner) => {
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
prompt: scenario.prompt,
|
||||
attachments: [],
|
||||
},
|
||||
})
|
||||
|
||||
scenario.assert(result)
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("should work with error fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("error")
|
||||
|
||||
try {
|
||||
await runner.run({
|
||||
it("should cover malformed-json fixture path", async () => {
|
||||
await withFixtureRunner("UserPromptSubmit", "hooks/userpromptsubmit/malformed-json", hookTestEnv, async (runner) => {
|
||||
const malformedResult = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
prompt: "Test",
|
||||
attachments: [],
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/exited with code 1/)
|
||||
}
|
||||
|
||||
malformedResult.cancel.should.be.false()
|
||||
;(
|
||||
malformedResult.contextModification === undefined || malformedResult.contextModification === ""
|
||||
).should.be.true()
|
||||
})
|
||||
})
|
||||
|
||||
it("should work with malformed-json fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("malformed-json")
|
||||
|
||||
// When hook exits 0 but has malformed JSON, it returns success without context
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
prompt: "Test",
|
||||
attachments: [],
|
||||
},
|
||||
it("should cover failing fixture path", async () => {
|
||||
await withFixtureRunner("UserPromptSubmit", "hooks/userpromptsubmit/error", hookTestEnv, async (runner) => {
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
prompt: "Test",
|
||||
attachments: [],
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: unknown) {
|
||||
getErrorMessage(error).should.match(/exited with code 1/)
|
||||
}
|
||||
})
|
||||
|
||||
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
|
||||
result.cancel.should.be.false()
|
||||
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
|
||||
})
|
||||
|
||||
it("should work with multiline fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("multiline")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
prompt: "Line 1\nLine 2\nLine 3",
|
||||
attachments: [],
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("Line count: 3")
|
||||
})
|
||||
|
||||
it("should work with large-prompt fixture", async function () {
|
||||
// On Windows this fixture path duplicates coverage from
|
||||
// "should handle large prompts" and can be timing-sensitive due to
|
||||
// PowerShell process startup in CI.
|
||||
if (process.platform === "win32") {
|
||||
this.skip()
|
||||
}
|
||||
|
||||
const runner = await loadFixtureAndCreateRunner("large-prompt")
|
||||
|
||||
const largePrompt = "x".repeat(10000)
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
prompt: largePrompt,
|
||||
attachments: [],
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("Prompt size: 10000")
|
||||
})
|
||||
|
||||
it("should work with special-chars fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("special-chars")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
prompt: "Test @user #feature $cost",
|
||||
attachments: [],
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("Special chars preserved")
|
||||
})
|
||||
|
||||
it("should work with empty-prompt fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("empty-prompt")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
prompt: "",
|
||||
attachments: [],
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("Prompt length: 0")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import type { PresentationPriority } from "./presentation-types"
|
||||
|
||||
export type { PresentationPriority }
|
||||
|
||||
type TaskPresentationSchedulerOptions = {
|
||||
flush: () => Promise<void>
|
||||
getDelayMs: (priority: PresentationPriority) => number
|
||||
setTimeoutFn?: typeof setTimeout
|
||||
clearTimeoutFn?: typeof clearTimeout
|
||||
onFlushError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
export class TaskPresentationScheduler {
|
||||
private scheduledTimer: ReturnType<typeof setTimeout> | undefined
|
||||
private scheduledPriority: PresentationPriority | undefined
|
||||
private pendingPriority: PresentationPriority | undefined
|
||||
private flushInProgress = false
|
||||
private currentFlushCompletion: Promise<{ error?: unknown }> | undefined
|
||||
private disposed = false
|
||||
|
||||
private readonly flush: () => Promise<void>
|
||||
private readonly getDelayMs: (priority: PresentationPriority) => number
|
||||
private readonly setTimeoutFn: typeof setTimeout
|
||||
private readonly clearTimeoutFn: typeof clearTimeout
|
||||
private readonly onFlushError?: (error: unknown) => void
|
||||
|
||||
constructor(options: TaskPresentationSchedulerOptions) {
|
||||
this.flush = options.flush
|
||||
this.getDelayMs = options.getDelayMs
|
||||
this.setTimeoutFn = options.setTimeoutFn ?? setTimeout
|
||||
this.clearTimeoutFn = options.clearTimeoutFn ?? clearTimeout
|
||||
this.onFlushError = options.onFlushError
|
||||
}
|
||||
|
||||
requestFlush(priority: PresentationPriority = "normal"): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingPriority = this.mergePriority(this.pendingPriority, priority)
|
||||
|
||||
if (this.flushInProgress) {
|
||||
// pendingPriority is already set above; runFlushCycle's post-flush
|
||||
// continuation will pick it up after the in-flight flush completes.
|
||||
return
|
||||
}
|
||||
|
||||
if (this.pendingPriority === "immediate") {
|
||||
if (this.scheduledTimer) {
|
||||
this.clearTimeoutFn(this.scheduledTimer)
|
||||
this.scheduledTimer = undefined
|
||||
this.scheduledPriority = undefined
|
||||
}
|
||||
void this.runFlushCycle({ rethrowErrors: false })
|
||||
return
|
||||
}
|
||||
|
||||
const nextPriority = this.pendingPriority ?? "normal"
|
||||
|
||||
if (this.scheduledTimer) {
|
||||
if (this.scheduledPriority === nextPriority) {
|
||||
return
|
||||
}
|
||||
|
||||
this.clearTimeoutFn(this.scheduledTimer)
|
||||
this.scheduledTimer = undefined
|
||||
this.scheduledPriority = undefined
|
||||
}
|
||||
|
||||
if (!this.pendingPriority) {
|
||||
return
|
||||
}
|
||||
|
||||
const delayMs = this.getDelayMs(nextPriority)
|
||||
this.scheduledPriority = nextPriority
|
||||
this.scheduledTimer = this.setTimeoutFn(() => {
|
||||
this.scheduledTimer = undefined
|
||||
this.scheduledPriority = undefined
|
||||
void this.runFlushCycle({ rethrowErrors: false })
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush immediately and await completion.
|
||||
*
|
||||
* Guarantees that at least one flush runs at "immediate" priority after this
|
||||
* call returns, even if a concurrent flush cycle consumed the pending priority
|
||||
* before this call could start its own cycle.
|
||||
*
|
||||
* If the scheduler has already been disposed this is a no-op and resolves
|
||||
* without error. Callers that need a guarantee that the final presentation
|
||||
* was delivered should ensure `dispose()` has not been called before
|
||||
* invoking `flushNow()` (the task streaming finalization path does this
|
||||
* correctly because `dispose()` is only called during `abortTask()`).
|
||||
*/
|
||||
async flushNow(): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.scheduledTimer) {
|
||||
this.clearTimeoutFn(this.scheduledTimer)
|
||||
this.scheduledTimer = undefined
|
||||
this.scheduledPriority = undefined
|
||||
}
|
||||
|
||||
// If a flush is already in-flight, wait for it to complete. After it
|
||||
// finishes, the post-flush continuation in runFlushCycle may have already
|
||||
// consumed our pendingPriority. We therefore set pendingPriority *after*
|
||||
// the in-flight flush resolves so it cannot be stolen by the continuation.
|
||||
if (this.flushInProgress) {
|
||||
await (this.currentFlushCompletion ?? Promise.resolve())
|
||||
// Another concurrent caller may have started a new flush cycle after
|
||||
// the same in-flight flush resolved. If one is now in progress, wait
|
||||
// for it too — we need a flush to run *after* we set pendingPriority.
|
||||
while (this.flushInProgress) {
|
||||
await (this.currentFlushCompletion ?? Promise.resolve())
|
||||
}
|
||||
}
|
||||
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
// Now that no flush is in-flight, set pendingPriority and run our own cycle.
|
||||
this.pendingPriority = this.mergePriority(this.pendingPriority, "immediate")
|
||||
await this.runFlushCycle({ rethrowErrors: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any pending timers and clear queued state without marking the scheduler
|
||||
* as disposed. Use this between API request retries within the same task to prevent
|
||||
* stale timers from firing against reset streaming state.
|
||||
*
|
||||
* Note: any flush that is already in-flight when reset() is called will complete
|
||||
* naturally. The flush callback (presentAssistantMessage) will operate on the
|
||||
* already-reset task state, but since currentStreamingContentIndex will be 0 and
|
||||
* assistantMessageContent will be empty, it will hit the out-of-bounds early-return
|
||||
* path and do nothing harmful.
|
||||
*/
|
||||
reset(): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
if (this.scheduledTimer) {
|
||||
this.clearTimeoutFn(this.scheduledTimer)
|
||||
this.scheduledTimer = undefined
|
||||
}
|
||||
this.scheduledPriority = undefined
|
||||
this.pendingPriority = undefined
|
||||
// Note: we intentionally do NOT clear flushInProgress or currentFlushCompletion
|
||||
// here. If a flush is in-flight it will complete naturally. The reset only
|
||||
// prevents *new* timer-driven flushes from firing on stale state.
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
if (this.scheduledTimer) {
|
||||
this.clearTimeoutFn(this.scheduledTimer)
|
||||
this.scheduledTimer = undefined
|
||||
}
|
||||
this.scheduledPriority = undefined
|
||||
this.pendingPriority = undefined
|
||||
|
||||
const inFlightFlush = this.currentFlushCompletion
|
||||
if (inFlightFlush) {
|
||||
await inFlightFlush
|
||||
}
|
||||
}
|
||||
|
||||
private async runFlushCycle(options: { rethrowErrors: boolean }): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
while (true) {
|
||||
if (this.flushInProgress) {
|
||||
// flushNow() handles the in-flight case itself before calling runFlushCycle,
|
||||
// so this branch is only reached from requestFlush() (which returns early when
|
||||
// flushInProgress is true) — meaning this path should not be hit in practice.
|
||||
// Guard it defensively anyway.
|
||||
const inFlightResult = await this.currentFlushCompletion
|
||||
if (options.rethrowErrors && inFlightResult?.error) {
|
||||
throw inFlightResult.error
|
||||
}
|
||||
// Re-check flushInProgress: another concurrent caller may have already
|
||||
// started a new flush cycle after the same in-flight flush resolved.
|
||||
// Without this guard both callers would proceed past the pendingPriority
|
||||
// check and start concurrent flushes against the same presentation state.
|
||||
if (this.flushInProgress || this.disposed || !this.pendingPriority) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.pendingPriority) {
|
||||
return
|
||||
}
|
||||
|
||||
this.flushInProgress = true
|
||||
this.pendingPriority = undefined
|
||||
|
||||
this.currentFlushCompletion = (async () => {
|
||||
try {
|
||||
await this.flush()
|
||||
return {}
|
||||
} catch (error) {
|
||||
this.onFlushError?.(error)
|
||||
return { error }
|
||||
} finally {
|
||||
this.flushInProgress = false
|
||||
}
|
||||
})()
|
||||
|
||||
const result = await this.currentFlushCompletion
|
||||
this.currentFlushCompletion = undefined
|
||||
if (result.error && options.rethrowErrors) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
const priorityToRun = this.pendingPriority
|
||||
if (!priorityToRun) {
|
||||
return
|
||||
}
|
||||
|
||||
if (priorityToRun !== "immediate") {
|
||||
this.requestFlush(priorityToRun)
|
||||
return
|
||||
}
|
||||
|
||||
// Continue the loop synchronously for immediate follow-up work. Because
|
||||
// there is no await between clearing currentFlushCompletion above and
|
||||
// re-entering the loop here, no other caller can observe an interleaved
|
||||
// "idle" state before the immediate flush is started.
|
||||
}
|
||||
}
|
||||
|
||||
private mergePriority(current: PresentationPriority | undefined, next: PresentationPriority): PresentationPriority {
|
||||
if (!current) {
|
||||
return next
|
||||
}
|
||||
|
||||
const rank: Record<PresentationPriority, number> = {
|
||||
normal: 0,
|
||||
immediate: 1,
|
||||
}
|
||||
|
||||
return rank[next] > rank[current] ? next : current
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,10 @@ export class TaskState {
|
||||
didEditFile = false
|
||||
lastToolName = "" // Track last tool used for consecutive call detection
|
||||
|
||||
// File read deduplication cache - prevents the model from endlessly reading the same files
|
||||
// Maps absolute file path → { readCount: times read in this task, mtime: last modified timestamp, imageBlock: optional image data for multimodal models }
|
||||
fileReadCache: Map<string, { readCount: number; mtime: number; imageBlock?: Anthropic.ImageBlockParam }> = new Map()
|
||||
|
||||
// Error tracking
|
||||
consecutiveMistakeCount = 0
|
||||
doubleCheckCompletionPending = false
|
||||
@@ -62,7 +66,6 @@ export class TaskState {
|
||||
apiRequestsSinceLastTodoUpdate = 0
|
||||
currentFocusChainChecklist: string | null = null
|
||||
todoListWasUpdatedByUser = false
|
||||
taskUiDeltaSequence = 0
|
||||
|
||||
// Task Abort / Cancellation
|
||||
abort = false
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { strict as assert } from "node:assert"
|
||||
import { Task } from "@core/task"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { describe, it } from "mocha"
|
||||
import sinon from "sinon"
|
||||
|
||||
async function flushMicrotasks(iterations = 5) {
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
function createFakeTask(taskState: {
|
||||
abort: boolean
|
||||
askResponse: string | undefined
|
||||
askResponseText: string | undefined
|
||||
askResponseImages: string[] | undefined
|
||||
askResponseFiles: string[] | undefined
|
||||
lastMessageTs: number | undefined
|
||||
}) {
|
||||
const clineMessages: ClineMessage[] = []
|
||||
|
||||
const fakeTask = {
|
||||
taskState,
|
||||
messageStateHandler: {
|
||||
addToClineMessages: async (message: ClineMessage) => {
|
||||
clineMessages.push(message)
|
||||
},
|
||||
getClineMessages: () => clineMessages,
|
||||
},
|
||||
postStateToWebview: async () => undefined,
|
||||
runNotificationHook: async () => undefined,
|
||||
}
|
||||
|
||||
return { clineMessages, fakeTask }
|
||||
}
|
||||
|
||||
describe("Task.ask", () => {
|
||||
it("keeps resume asks waiting for a user response even when the task is aborted", async () => {
|
||||
const clock = sinon.useFakeTimers()
|
||||
const taskState: {
|
||||
abort: boolean
|
||||
askResponse: string | undefined
|
||||
askResponseText: string | undefined
|
||||
askResponseImages: string[] | undefined
|
||||
askResponseFiles: string[] | undefined
|
||||
lastMessageTs: number | undefined
|
||||
} = {
|
||||
abort: true,
|
||||
askResponse: undefined,
|
||||
askResponseText: undefined,
|
||||
askResponseImages: undefined,
|
||||
askResponseFiles: undefined,
|
||||
lastMessageTs: undefined,
|
||||
}
|
||||
const { clineMessages, fakeTask } = createFakeTask(taskState)
|
||||
|
||||
try {
|
||||
const askPromise = (
|
||||
Task.prototype as unknown as {
|
||||
ask: (type: "resume_task") => Promise<{ response: string; text?: string }>
|
||||
}
|
||||
).ask.call(fakeTask, "resume_task")
|
||||
|
||||
let settled = false
|
||||
void askPromise.then(
|
||||
() => {
|
||||
settled = true
|
||||
},
|
||||
() => {
|
||||
settled = true
|
||||
},
|
||||
)
|
||||
|
||||
await flushMicrotasks()
|
||||
assert.equal(clineMessages.length, 1)
|
||||
assert.equal(clineMessages[0].ask, "resume_task")
|
||||
assert.notEqual(taskState.lastMessageTs, undefined)
|
||||
|
||||
await clock.tickAsync(1_000)
|
||||
assert.equal(settled, false)
|
||||
assert.equal(taskState.askResponse, undefined)
|
||||
|
||||
taskState.askResponse = "yesButtonClicked"
|
||||
taskState.askResponseText = "resume"
|
||||
|
||||
await clock.tickAsync(100)
|
||||
const result = await askPromise
|
||||
|
||||
assert.equal(result.response, "yesButtonClicked")
|
||||
assert.equal(result.text, "resume")
|
||||
} finally {
|
||||
clock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps resume-completed asks waiting for a user response even when the task is aborted", async () => {
|
||||
const clock = sinon.useFakeTimers()
|
||||
const taskState: {
|
||||
abort: boolean
|
||||
askResponse: string | undefined
|
||||
askResponseText: string | undefined
|
||||
askResponseImages: string[] | undefined
|
||||
askResponseFiles: string[] | undefined
|
||||
lastMessageTs: number | undefined
|
||||
} = {
|
||||
abort: true,
|
||||
askResponse: undefined,
|
||||
askResponseText: undefined,
|
||||
askResponseImages: undefined,
|
||||
askResponseFiles: undefined,
|
||||
lastMessageTs: undefined,
|
||||
}
|
||||
const { clineMessages, fakeTask } = createFakeTask(taskState)
|
||||
|
||||
try {
|
||||
const askPromise = (
|
||||
Task.prototype as unknown as {
|
||||
ask: (type: "resume_completed_task") => Promise<{ response: string; text?: string }>
|
||||
}
|
||||
).ask.call(fakeTask, "resume_completed_task")
|
||||
|
||||
let settled = false
|
||||
void askPromise.then(
|
||||
() => {
|
||||
settled = true
|
||||
},
|
||||
() => {
|
||||
settled = true
|
||||
},
|
||||
)
|
||||
|
||||
await flushMicrotasks()
|
||||
assert.equal(clineMessages.length, 1)
|
||||
assert.equal(clineMessages[0].ask, "resume_completed_task")
|
||||
assert.notEqual(taskState.lastMessageTs, undefined)
|
||||
|
||||
await clock.tickAsync(1_000)
|
||||
assert.equal(settled, false)
|
||||
assert.equal(taskState.askResponse, undefined)
|
||||
|
||||
taskState.askResponse = "yesButtonClicked"
|
||||
taskState.askResponseText = "resume completed"
|
||||
|
||||
await clock.tickAsync(100)
|
||||
const result = await askPromise
|
||||
|
||||
assert.equal(result.response, "yesButtonClicked")
|
||||
assert.equal(result.text, "resume completed")
|
||||
} finally {
|
||||
clock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
it("still wakes non-resume asks when abort is triggered after the ask is shown", async () => {
|
||||
const clock = sinon.useFakeTimers()
|
||||
const taskState: {
|
||||
abort: boolean
|
||||
askResponse: string | undefined
|
||||
askResponseText: string | undefined
|
||||
askResponseImages: string[] | undefined
|
||||
askResponseFiles: string[] | undefined
|
||||
lastMessageTs: number | undefined
|
||||
} = {
|
||||
abort: false,
|
||||
askResponse: undefined,
|
||||
askResponseText: undefined,
|
||||
askResponseImages: undefined,
|
||||
askResponseFiles: undefined,
|
||||
lastMessageTs: undefined,
|
||||
}
|
||||
const { clineMessages, fakeTask } = createFakeTask(taskState)
|
||||
|
||||
try {
|
||||
const askPromise = (
|
||||
Task.prototype as unknown as {
|
||||
ask: (type: "completion_result") => Promise<{ response: string }>
|
||||
}
|
||||
).ask.call(fakeTask, "completion_result")
|
||||
|
||||
await flushMicrotasks()
|
||||
assert.equal(clineMessages.length, 1)
|
||||
assert.equal(clineMessages[0].ask, "completion_result")
|
||||
|
||||
const rejectionPromise = assert.rejects(askPromise, /Cline instance aborted/)
|
||||
taskState.abort = true
|
||||
|
||||
await clock.tickAsync(100)
|
||||
await rejectionPromise
|
||||
} finally {
|
||||
clock.restore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { strict as assert } from "node:assert"
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import { registerPartialMessageCallback } from "@core/controller/ui/subscribeToPartialMessage"
|
||||
import { Task } from "@core/task"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { ClineDefaultTool } from "@shared/tools"
|
||||
import { describe, it } from "mocha"
|
||||
|
||||
describe("Task.processNativeToolCalls", () => {
|
||||
it("finalizes a partial text row before handing off to native tool calls", async () => {
|
||||
const clineMessages: ClineMessage[] = [
|
||||
{
|
||||
ts: 1,
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: "partial text before tool handoff",
|
||||
partial: true,
|
||||
},
|
||||
]
|
||||
|
||||
let saveCalls = 0
|
||||
const emittedPartialMessages: Array<{ partial: boolean; text: string }> = []
|
||||
const unsubscribe = registerPartialMessageCallback((message) => {
|
||||
emittedPartialMessages.push({
|
||||
partial: message.partial,
|
||||
text: message.text,
|
||||
})
|
||||
})
|
||||
|
||||
const toolBlocks: ToolUse[] = [
|
||||
{
|
||||
type: "tool_use",
|
||||
name: ClineDefaultTool.ASK,
|
||||
params: {
|
||||
question: "Need clarification",
|
||||
},
|
||||
partial: true,
|
||||
isNativeToolCall: true,
|
||||
call_id: "call-1",
|
||||
},
|
||||
]
|
||||
|
||||
const fakeTask = {
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => clineMessages,
|
||||
saveClineMessagesAndUpdateHistory: async () => {
|
||||
saveCalls += 1
|
||||
},
|
||||
},
|
||||
taskState: {
|
||||
assistantMessageContent: [],
|
||||
currentStreamingContentIndex: 0,
|
||||
userMessageContentReady: true,
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
await (
|
||||
Task.prototype as unknown as { processNativeToolCalls: (text: string, blocks: ToolUse[]) => Promise<void> }
|
||||
).processNativeToolCalls.call(fakeTask, "visible streamed text", toolBlocks)
|
||||
|
||||
assert.equal(clineMessages[0].text, "visible streamed text")
|
||||
assert.equal(clineMessages[0].partial, false)
|
||||
assert.equal(saveCalls, 1)
|
||||
assert.deepEqual(emittedPartialMessages, [{ partial: false, text: "visible streamed text" }])
|
||||
|
||||
assert.deepEqual(fakeTask.taskState.assistantMessageContent, [
|
||||
{ type: "text", content: "visible streamed text", partial: false },
|
||||
...toolBlocks,
|
||||
])
|
||||
assert.equal(fakeTask.taskState.currentStreamingContentIndex, 1)
|
||||
assert.equal(fakeTask.taskState.userMessageContentReady, false)
|
||||
} finally {
|
||||
unsubscribe()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,253 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import sinon from "sinon"
|
||||
|
||||
import { TaskPresentationScheduler } from "../TaskPresentationScheduler"
|
||||
|
||||
describe("TaskPresentationScheduler", () => {
|
||||
it("rethrows flush errors from flushNow so callers do not hang on hidden failures", async () => {
|
||||
const scheduler = new TaskPresentationScheduler({
|
||||
flush: async () => {
|
||||
throw new Error("flush failed")
|
||||
},
|
||||
getDelayMs: () => 10,
|
||||
})
|
||||
|
||||
await scheduler
|
||||
.flushNow()
|
||||
.then(() => {
|
||||
throw new Error("expected flushNow to reject")
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
error.message.should.equal("flush failed")
|
||||
})
|
||||
})
|
||||
|
||||
it("coalesces multiple normal-priority requests into a single timer", () => {
|
||||
const clock = sinon.useFakeTimers()
|
||||
const flushSpy = sinon.spy(async () => {})
|
||||
|
||||
const scheduler = new TaskPresentationScheduler({
|
||||
flush: flushSpy,
|
||||
getDelayMs: () => 50,
|
||||
})
|
||||
|
||||
scheduler.requestFlush("normal")
|
||||
scheduler.requestFlush("normal")
|
||||
scheduler.requestFlush("normal")
|
||||
|
||||
clock.tick(49)
|
||||
flushSpy.callCount.should.equal(0)
|
||||
|
||||
clock.tick(1)
|
||||
flushSpy.callCount.should.equal(1)
|
||||
|
||||
clock.restore()
|
||||
})
|
||||
|
||||
it("waits for an in-flight flush and runs the requested immediate flush before resolving flushNow", async () => {
|
||||
let resolveFirstFlush: (() => void) | undefined
|
||||
let flushCount = 0
|
||||
|
||||
const scheduler = new TaskPresentationScheduler({
|
||||
flush: async () => {
|
||||
flushCount += 1
|
||||
if (flushCount === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveFirstFlush = resolve
|
||||
})
|
||||
}
|
||||
},
|
||||
getDelayMs: () => 0,
|
||||
})
|
||||
|
||||
scheduler.requestFlush("immediate")
|
||||
await Promise.resolve()
|
||||
|
||||
let didResolve = false
|
||||
const flushNowPromise = scheduler.flushNow().then(() => {
|
||||
didResolve = true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
flushCount.should.equal(1)
|
||||
didResolve.should.equal(false)
|
||||
|
||||
resolveFirstFlush?.()
|
||||
await flushNowPromise
|
||||
|
||||
flushCount.should.equal(2)
|
||||
didResolve.should.equal(true)
|
||||
})
|
||||
|
||||
it("does not rethrow errors from an overlapping in-flight flush when flushNow is called", async () => {
|
||||
let rejectFirstFlush: ((error: Error) => void) | undefined
|
||||
let flushCount = 0
|
||||
|
||||
const scheduler = new TaskPresentationScheduler({
|
||||
flush: async () => {
|
||||
flushCount += 1
|
||||
if (flushCount === 1) {
|
||||
await new Promise<void>((_, reject) => {
|
||||
rejectFirstFlush = reject
|
||||
})
|
||||
}
|
||||
},
|
||||
getDelayMs: () => 0,
|
||||
})
|
||||
|
||||
scheduler.requestFlush("immediate")
|
||||
await Promise.resolve()
|
||||
|
||||
let flushNowResolved = false
|
||||
const flushNowPromise = scheduler.flushNow().then(() => {
|
||||
flushNowResolved = true
|
||||
})
|
||||
rejectFirstFlush?.(new Error("flush failed"))
|
||||
|
||||
await flushNowPromise
|
||||
flushNowResolved.should.equal(true)
|
||||
flushCount.should.equal(2)
|
||||
})
|
||||
|
||||
it("flushNow guarantees a flush even when the post-flush continuation consumed pendingPriority", async () => {
|
||||
// Regression test for the race condition where:
|
||||
// 1. A timer fires → runFlushCycle starts, sets flushInProgress=true, clears pendingPriority
|
||||
// 2. flushNow() is called → sets pendingPriority="immediate", enters runFlushCycle
|
||||
// 3. runFlushCycle sees flushInProgress, awaits currentFlushCompletion
|
||||
// 4. In-flight flush completes → post-flush continuation sees pendingPriority="immediate",
|
||||
// calls runFlushCycle recursively → clears pendingPriority, runs flush #2
|
||||
// 5. flushNow()'s runFlushCycle resumes → pendingPriority is now undefined → would return
|
||||
// without flushing (the bug)
|
||||
//
|
||||
// The fix: flushNow() waits for all in-flight flushes to drain *before* setting
|
||||
// pendingPriority, so the continuation cannot steal it.
|
||||
|
||||
let resolveFirstFlush: (() => void) | undefined
|
||||
let flushCount = 0
|
||||
|
||||
const scheduler = new TaskPresentationScheduler({
|
||||
flush: async () => {
|
||||
flushCount += 1
|
||||
if (flushCount === 1) {
|
||||
// First flush: pause so flushNow() arrives while it's in-flight
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveFirstFlush = resolve
|
||||
})
|
||||
}
|
||||
},
|
||||
getDelayMs: () => 0,
|
||||
})
|
||||
|
||||
// Start the first flush (via immediate requestFlush)
|
||||
scheduler.requestFlush("immediate")
|
||||
// Yield so the async flush body starts executing
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// flushNow() is called while flush #1 is paused mid-execution
|
||||
let flushNowResolved = false
|
||||
const flushNowPromise = scheduler.flushNow().then(() => {
|
||||
flushNowResolved = true
|
||||
})
|
||||
|
||||
// Unblock flush #1
|
||||
resolveFirstFlush?.()
|
||||
await flushNowPromise
|
||||
|
||||
// flushNow must have triggered a second flush after flush #1 completed
|
||||
flushNowResolved.should.equal(true)
|
||||
flushCount.should.equal(2)
|
||||
})
|
||||
|
||||
it("runs an immediate follow-up flush requested during an in-flight flush", async () => {
|
||||
let resolveFirstFlush: (() => void) | undefined
|
||||
let flushCount = 0
|
||||
|
||||
const scheduler = new TaskPresentationScheduler({
|
||||
flush: async () => {
|
||||
flushCount += 1
|
||||
if (flushCount === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveFirstFlush = resolve
|
||||
})
|
||||
}
|
||||
},
|
||||
getDelayMs: () => 0,
|
||||
})
|
||||
|
||||
scheduler.requestFlush("immediate")
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
scheduler.requestFlush("immediate")
|
||||
resolveFirstFlush?.()
|
||||
|
||||
await scheduler.flushNow()
|
||||
flushCount.should.equal(3)
|
||||
})
|
||||
|
||||
it("reset() cancels pending timers without marking the scheduler as disposed", () => {
|
||||
const clock = sinon.useFakeTimers()
|
||||
const flushSpy = sinon.spy(async () => {})
|
||||
|
||||
const scheduler = new TaskPresentationScheduler({
|
||||
flush: flushSpy,
|
||||
getDelayMs: () => 50,
|
||||
})
|
||||
|
||||
scheduler.requestFlush("normal")
|
||||
scheduler.reset()
|
||||
|
||||
// The pending timer should have been cancelled
|
||||
clock.tick(100)
|
||||
flushSpy.callCount.should.equal(0)
|
||||
|
||||
// Scheduler should still be usable after reset (not disposed)
|
||||
scheduler.requestFlush("normal")
|
||||
clock.tick(50)
|
||||
flushSpy.callCount.should.equal(1)
|
||||
|
||||
clock.restore()
|
||||
})
|
||||
|
||||
it("immediate priority bypasses the timer and flushes synchronously", () => {
|
||||
const clock = sinon.useFakeTimers()
|
||||
const flushSpy = sinon.spy(async () => {})
|
||||
|
||||
const scheduler = new TaskPresentationScheduler({
|
||||
flush: flushSpy,
|
||||
getDelayMs: () => 100,
|
||||
})
|
||||
|
||||
scheduler.requestFlush("immediate")
|
||||
// immediate fires via void runFlushCycle, which starts synchronously
|
||||
flushSpy.callCount.should.equal(1)
|
||||
|
||||
clock.restore()
|
||||
})
|
||||
|
||||
it("upgrades a pending normal timer to immediate when immediate is requested", () => {
|
||||
const clock = sinon.useFakeTimers()
|
||||
const flushSpy = sinon.spy(async () => {})
|
||||
|
||||
const scheduler = new TaskPresentationScheduler({
|
||||
flush: flushSpy,
|
||||
getDelayMs: () => 100,
|
||||
})
|
||||
|
||||
scheduler.requestFlush("normal")
|
||||
clock.tick(50)
|
||||
flushSpy.callCount.should.equal(0)
|
||||
|
||||
// Upgrade to immediate — should cancel the timer and flush now
|
||||
scheduler.requestFlush("immediate")
|
||||
flushSpy.callCount.should.equal(1)
|
||||
|
||||
// Original timer should not fire again
|
||||
clock.tick(100)
|
||||
flushSpy.callCount.should.equal(1)
|
||||
|
||||
clock.restore()
|
||||
})
|
||||
})
|
||||
@@ -1,136 +1,52 @@
|
||||
import { strict as assert } from "assert"
|
||||
import { afterEach, describe, it } from "mocha"
|
||||
import {
|
||||
getEnvironmentDetailsStaticCacheTtlMs,
|
||||
getPresentationCadenceMs,
|
||||
getRequestBoundaryCacheTtlMs,
|
||||
getStateUpdateCadenceMs,
|
||||
getUsageUpdateCadenceMs,
|
||||
isEphemeralMessagePersistenceDisabled,
|
||||
isPresentationSchedulingDisabled,
|
||||
isRemoteWorkspaceEnvironment,
|
||||
isTaskUiDeltaSyncDisabled,
|
||||
shouldWaitForTerminalCooldown,
|
||||
summarizeChunkToWebviewDelays,
|
||||
} from "../latency"
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
|
||||
describe("task latency helpers", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.CLINE_PRESENTATION_CADENCE_MS
|
||||
delete process.env.CLINE_REMOTE_PRESENTATION_CADENCE_MS
|
||||
delete process.env.CLINE_STATE_UPDATE_CADENCE_MS
|
||||
delete process.env.CLINE_REMOTE_STATE_UPDATE_CADENCE_MS
|
||||
delete process.env.CLINE_USAGE_UPDATE_CADENCE_MS
|
||||
delete process.env.CLINE_REMOTE_USAGE_UPDATE_CADENCE_MS
|
||||
delete process.env.CLINE_REQUEST_BOUNDARY_CACHE_TTL_MS
|
||||
delete process.env.CLINE_REMOTE_REQUEST_BOUNDARY_CACHE_TTL_MS
|
||||
delete process.env.CLINE_ENVIRONMENT_DETAILS_STATIC_CACHE_TTL_MS
|
||||
delete process.env.CLINE_REMOTE_ENVIRONMENT_DETAILS_STATIC_CACHE_TTL_MS
|
||||
delete process.env.CLINE_DISABLE_PRESENTATION_SCHEDULER
|
||||
delete process.env.CLINE_DISABLE_EPHEMERAL_MESSAGE_PERSISTENCE
|
||||
delete process.env.CLINE_DISABLE_TASK_UI_DELTA_SYNC
|
||||
import { isRemoteWorkspaceEnvironment } from "../latency"
|
||||
|
||||
describe("latency", () => {
|
||||
it("detects remote workspaces from explicit remoteName metadata", () => {
|
||||
isRemoteWorkspaceEnvironment({
|
||||
platform: "Visual Studio Code",
|
||||
version: "1.103.0",
|
||||
remoteName: "ssh-remote",
|
||||
}).should.equal(true)
|
||||
})
|
||||
|
||||
it("detects remote workspaces from remoteName, platform, and version metadata", () => {
|
||||
assert.equal(isRemoteWorkspaceEnvironment({ remoteName: "ssh-remote" }), true)
|
||||
assert.equal(isRemoteWorkspaceEnvironment({ platform: "VS Code Remote" }), true)
|
||||
assert.equal(isRemoteWorkspaceEnvironment({ version: "Remote Server 1.0" }), true)
|
||||
assert.equal(isRemoteWorkspaceEnvironment({ platform: "darwin", version: "1.0.0", remoteName: null }), false)
|
||||
it("detects remote workspaces for dev-container and codespaces remoteName values", () => {
|
||||
isRemoteWorkspaceEnvironment({ remoteName: "dev-container" }).should.equal(true)
|
||||
isRemoteWorkspaceEnvironment({ remoteName: "codespaces" }).should.equal(true)
|
||||
})
|
||||
|
||||
it("uses remote-aware presentation and state update cadences", () => {
|
||||
assert.equal(getPresentationCadenceMs(false, "immediate"), 0)
|
||||
assert.equal(getPresentationCadenceMs(false, "normal"), 40)
|
||||
assert.equal(getPresentationCadenceMs(true, "normal"), 90)
|
||||
assert.equal(getPresentationCadenceMs(true, "low"), 125)
|
||||
|
||||
assert.equal(getStateUpdateCadenceMs(false, "immediate"), 0)
|
||||
assert.equal(getStateUpdateCadenceMs(false, "normal"), 16)
|
||||
assert.equal(getStateUpdateCadenceMs(true, "normal"), 110)
|
||||
assert.equal(getStateUpdateCadenceMs(true, "low"), 150)
|
||||
assert.equal(getUsageUpdateCadenceMs(false), 250)
|
||||
assert.equal(getUsageUpdateCadenceMs(true), 400)
|
||||
assert.equal(getRequestBoundaryCacheTtlMs(false), 500)
|
||||
assert.equal(getRequestBoundaryCacheTtlMs(true), 1000)
|
||||
assert.equal(getEnvironmentDetailsStaticCacheTtlMs(false), 30_000)
|
||||
assert.equal(getEnvironmentDetailsStaticCacheTtlMs(true), 60_000)
|
||||
it("does not classify hosts as remote when remoteName is absent", () => {
|
||||
isRemoteWorkspaceEnvironment({
|
||||
platform: "Visual Studio Code",
|
||||
version: "1.103.0",
|
||||
remoteName: undefined,
|
||||
}).should.equal(false)
|
||||
})
|
||||
|
||||
it("respects cadence overrides from environment variables", () => {
|
||||
process.env.CLINE_PRESENTATION_CADENCE_MS = "22"
|
||||
process.env.CLINE_REMOTE_PRESENTATION_CADENCE_MS = "77"
|
||||
process.env.CLINE_STATE_UPDATE_CADENCE_MS = "18"
|
||||
process.env.CLINE_REMOTE_STATE_UPDATE_CADENCE_MS = "99"
|
||||
process.env.CLINE_USAGE_UPDATE_CADENCE_MS = "333"
|
||||
process.env.CLINE_REMOTE_USAGE_UPDATE_CADENCE_MS = "555"
|
||||
process.env.CLINE_REQUEST_BOUNDARY_CACHE_TTL_MS = "444"
|
||||
process.env.CLINE_REMOTE_REQUEST_BOUNDARY_CACHE_TTL_MS = "888"
|
||||
process.env.CLINE_ENVIRONMENT_DETAILS_STATIC_CACHE_TTL_MS = "1234"
|
||||
process.env.CLINE_REMOTE_ENVIRONMENT_DETAILS_STATIC_CACHE_TTL_MS = "5678"
|
||||
|
||||
assert.equal(getPresentationCadenceMs(false, "normal"), 22)
|
||||
assert.equal(getPresentationCadenceMs(true, "normal"), 77)
|
||||
assert.equal(getStateUpdateCadenceMs(false, "normal"), 18)
|
||||
assert.equal(getStateUpdateCadenceMs(true, "normal"), 99)
|
||||
assert.equal(getUsageUpdateCadenceMs(false), 333)
|
||||
assert.equal(getUsageUpdateCadenceMs(true), 555)
|
||||
assert.equal(getRequestBoundaryCacheTtlMs(false), 444)
|
||||
assert.equal(getRequestBoundaryCacheTtlMs(true), 888)
|
||||
assert.equal(getEnvironmentDetailsStaticCacheTtlMs(false), 1234)
|
||||
assert.equal(getEnvironmentDetailsStaticCacheTtlMs(true), 5678)
|
||||
it("does not classify hosts as remote when remoteName is null", () => {
|
||||
isRemoteWorkspaceEnvironment({
|
||||
platform: "Visual Studio Code",
|
||||
version: "1.103.0",
|
||||
remoteName: null,
|
||||
}).should.equal(false)
|
||||
})
|
||||
|
||||
it("supports development flags for disabling schedulers and delta sync", () => {
|
||||
process.env.CLINE_DISABLE_PRESENTATION_SCHEDULER = "true"
|
||||
process.env.CLINE_DISABLE_EPHEMERAL_MESSAGE_PERSISTENCE = "1"
|
||||
process.env.CLINE_DISABLE_TASK_UI_DELTA_SYNC = "yes"
|
||||
it("does not false-positive on platform or version strings containing 'remote'", () => {
|
||||
// Previously the heuristic would have returned true for these — now it must not.
|
||||
isRemoteWorkspaceEnvironment({
|
||||
platform: "Remote IDE",
|
||||
version: "1.0.0",
|
||||
}).should.equal(false)
|
||||
|
||||
assert.equal(isPresentationSchedulingDisabled(), true)
|
||||
assert.equal(isEphemeralMessagePersistenceDisabled(), true)
|
||||
assert.equal(isTaskUiDeltaSyncDisabled(), true)
|
||||
isRemoteWorkspaceEnvironment({
|
||||
platform: "Visual Studio Code",
|
||||
version: "1.0.0-remote-fix",
|
||||
}).should.equal(false)
|
||||
})
|
||||
|
||||
it("waits for terminal cooldown only when there is active heat or a recent edit", () => {
|
||||
assert.equal(
|
||||
shouldWaitForTerminalCooldown({
|
||||
busyTerminalIds: [],
|
||||
isProcessHot: () => true,
|
||||
didEditFile: false,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
shouldWaitForTerminalCooldown({
|
||||
busyTerminalIds: [1, 2],
|
||||
isProcessHot: () => false,
|
||||
didEditFile: false,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
shouldWaitForTerminalCooldown({
|
||||
busyTerminalIds: [1, 2],
|
||||
isProcessHot: (terminalId) => terminalId === 2,
|
||||
didEditFile: false,
|
||||
}),
|
||||
true,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
shouldWaitForTerminalCooldown({
|
||||
busyTerminalIds: [1],
|
||||
isProcessHot: () => false,
|
||||
didEditFile: true,
|
||||
}),
|
||||
true,
|
||||
)
|
||||
it("does not classify hosts as remote when no fields are provided", () => {
|
||||
isRemoteWorkspaceEnvironment({}).should.equal(false)
|
||||
})
|
||||
|
||||
it("summarizes chunk-to-webview delays with median and p95 percentiles", () => {
|
||||
assert.deepStrictEqual(summarizeChunkToWebviewDelays([]), { medianMs: 0, p95Ms: 0 })
|
||||
assert.deepStrictEqual(summarizeChunkToWebviewDelays([10, 20, 30, 40, 50]), { medianMs: 30, p95Ms: 50 })
|
||||
assert.deepStrictEqual(summarizeChunkToWebviewDelays([5, 15, 25, 35]), { medianMs: 15, p95Ms: 35 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,7 +24,6 @@ export interface FocusChainDependencies {
|
||||
mode: Mode
|
||||
stateManager: StateManager
|
||||
postStateToWebview: () => Promise<void>
|
||||
postTaskMetadataDelta: (metadata: { currentFocusChainChecklist?: string | null }) => Promise<void>
|
||||
say: (type: ClineSay, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
|
||||
focusChainSettings: FocusChainSettings
|
||||
}
|
||||
@@ -34,7 +33,6 @@ export class FocusChainManager {
|
||||
private taskState: TaskState
|
||||
private stateManager: StateManager
|
||||
private postStateToWebview: () => Promise<void>
|
||||
private postTaskMetadataDelta: (metadata: { currentFocusChainChecklist?: string | null }) => Promise<void>
|
||||
private say: (
|
||||
type: ClineSay,
|
||||
text?: string,
|
||||
@@ -52,7 +50,6 @@ export class FocusChainManager {
|
||||
this.taskState = dependencies.taskState
|
||||
this.stateManager = dependencies.stateManager
|
||||
this.postStateToWebview = dependencies.postStateToWebview
|
||||
this.postTaskMetadataDelta = dependencies.postTaskMetadataDelta
|
||||
this.say = dependencies.say
|
||||
this.focusChainSettings = dependencies.focusChainSettings
|
||||
}
|
||||
@@ -88,7 +85,7 @@ export class FocusChainManager {
|
||||
})
|
||||
.on("unlink", async () => {
|
||||
this.taskState.currentFocusChainChecklist = null
|
||||
await this.postTaskMetadataDelta({ currentFocusChainChecklist: null })
|
||||
await this.postStateToWebview()
|
||||
})
|
||||
.on("error", (error) => {
|
||||
Logger.error(`[Task ${this.taskId}] Failed to watch focus chain file:`, error)
|
||||
@@ -123,7 +120,7 @@ export class FocusChainManager {
|
||||
this.taskState.currentFocusChainChecklist = markdownTodoList
|
||||
this.taskState.todoListWasUpdatedByUser = true
|
||||
|
||||
await this.postTaskMetadataDelta({ currentFocusChainChecklist: markdownTodoList })
|
||||
await this.postStateToWebview()
|
||||
telemetryService.captureFocusChainListWritten(this.taskId)
|
||||
} else {
|
||||
Logger.log(
|
||||
@@ -304,14 +301,12 @@ export class FocusChainManager {
|
||||
// Write the model's update to the markdown file
|
||||
try {
|
||||
await this.writeFocusChainToDisk(taskProgress.trim())
|
||||
await this.postTaskMetadataDelta({ currentFocusChainChecklist: taskProgress.trim() })
|
||||
|
||||
// Send the task_progress message to the UI immediately
|
||||
await this.say("task_progress", taskProgress.trim())
|
||||
} catch (error) {
|
||||
Logger.error(`[Task ${this.taskId}] focus chain list: Failed to write to markdown file:`, error)
|
||||
// Fall back to creating a task_progress message directly if file write fails
|
||||
await this.postTaskMetadataDelta({ currentFocusChainChecklist: taskProgress.trim() })
|
||||
await this.say("task_progress", taskProgress.trim())
|
||||
Logger.log(`[Task ${this.taskId}] focus chain list: Sent fallback task_progress message to UI`)
|
||||
}
|
||||
@@ -321,7 +316,6 @@ export class FocusChainManager {
|
||||
if (markdownTodoList) {
|
||||
const _previousList = this.taskState.currentFocusChainChecklist
|
||||
this.taskState.currentFocusChainChecklist = markdownTodoList
|
||||
await this.postTaskMetadataDelta({ currentFocusChainChecklist: markdownTodoList })
|
||||
|
||||
// Create a task_progress message to display the focus chain list in the UI
|
||||
await this.say("task_progress", markdownTodoList)
|
||||
|
||||
+134
-36
@@ -114,10 +114,17 @@ import { Controller } from "../controller"
|
||||
import { executeHook } from "../hooks/hook-executor"
|
||||
import { StateManager } from "../storage/StateManager"
|
||||
import { FocusChainManager } from "./focus-chain"
|
||||
import { isTaskUiDeltaSyncDisabled } from "./latency"
|
||||
import {
|
||||
getPresentationCadenceMs,
|
||||
isPresentationSchedulingDisabled,
|
||||
isRemoteWorkspaceEnvironment,
|
||||
type TaskLatencyTrigger,
|
||||
} from "./latency"
|
||||
import { MessageStateHandler } from "./message-state"
|
||||
import type { PresentationPriority } from "./presentation-types"
|
||||
import { StreamChunkCoordinator } from "./StreamChunkCoordinator"
|
||||
import { StreamResponseHandler } from "./StreamResponseHandler"
|
||||
import { TaskPresentationScheduler } from "./TaskPresentationScheduler"
|
||||
import { TaskState } from "./TaskState"
|
||||
import { ToolExecutor } from "./ToolExecutor"
|
||||
import { detectAvailableCliTools, extractProviderDomainFromUrl, updateApiReqMsg } from "./utils"
|
||||
@@ -257,7 +264,11 @@ export class Task {
|
||||
|
||||
// Command executor for running shell commands (extracted from executeCommandTool)
|
||||
private commandExecutor!: CommandExecutor
|
||||
private readonly taskUiDeltaSyncDisabled = isTaskUiDeltaSyncDisabled()
|
||||
private isRemoteWorkspaceEnvironment = false
|
||||
private remoteWorkspaceDetectionSettled = false
|
||||
private readonly remoteWorkspaceDetectionPromise: Promise<void>
|
||||
private readonly presentationScheduler: TaskPresentationScheduler
|
||||
private readonly presentationSchedulingDisabled = isPresentationSchedulingDisabled()
|
||||
|
||||
constructor(params: TaskParams) {
|
||||
const {
|
||||
@@ -285,6 +296,17 @@ export class Task {
|
||||
|
||||
this.taskInitializationStartTime = performance.now()
|
||||
this.taskState = new TaskState()
|
||||
this.remoteWorkspaceDetectionPromise = HostProvider.env
|
||||
.getHostVersion({})
|
||||
.then((hostVersion) => {
|
||||
this.isRemoteWorkspaceEnvironment = isRemoteWorkspaceEnvironment(hostVersion)
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.warn(`[Task ${taskId}] Failed to detect remote workspace state: ${error}`)
|
||||
})
|
||||
.finally(() => {
|
||||
this.remoteWorkspaceDetectionSettled = true
|
||||
})
|
||||
this.controller = controller
|
||||
this.mcpHub = mcpHub
|
||||
this.updateTaskHistory = updateTaskHistory
|
||||
@@ -370,7 +392,6 @@ export class Task {
|
||||
mode: this.stateManager.getGlobalSettingsKey("mode"),
|
||||
stateManager: this.stateManager,
|
||||
postStateToWebview: this.postStateToWebview,
|
||||
postTaskMetadataDelta: (metadata) => this.controller.postTaskMetadataDelta(metadata, this.taskId),
|
||||
say: this.say.bind(this),
|
||||
focusChainSettings: focusChainSettings,
|
||||
})
|
||||
@@ -534,6 +555,26 @@ export class Task {
|
||||
|
||||
this.commandExecutor = new CommandExecutor(commandExecutorConfig, commandExecutorCallbacks)
|
||||
|
||||
// Note: the scheduler's getDelayMs reads this.isRemoteWorkspaceEnvironment which is
|
||||
// populated asynchronously by remoteWorkspaceDetectionPromise. The promise is awaited
|
||||
// before streaming begins (in recursivelyMakeClineRequests) so the cadence is always
|
||||
// correct by the time the first flush is scheduled.
|
||||
this.presentationScheduler = new TaskPresentationScheduler({
|
||||
flush: () => this.presentAssistantMessage(),
|
||||
getDelayMs: (priority) => {
|
||||
if (!this.remoteWorkspaceDetectionSettled) {
|
||||
// This should never fire in production because recursivelyMakeClineRequests
|
||||
// awaits remoteWorkspaceDetectionPromise before the first flush is scheduled.
|
||||
// If it does fire, we fall back to the local cadence (safe default).
|
||||
Logger.warn(
|
||||
`[Task ${taskId}] getDelayMs called before remote workspace detection settled — using local cadence as fallback`,
|
||||
)
|
||||
}
|
||||
return getPresentationCadenceMs(this.isRemoteWorkspaceEnvironment, priority)
|
||||
},
|
||||
onFlushError: (error) => Logger.debug(`[Task] Failed scheduled presentation flush: ${error}`),
|
||||
})
|
||||
|
||||
this.toolExecutor = new ToolExecutor(
|
||||
this.taskState,
|
||||
this.messageStateHandler,
|
||||
@@ -572,6 +613,44 @@ export class Task {
|
||||
)
|
||||
}
|
||||
|
||||
private async scheduleAssistantPresentation(
|
||||
trigger: TaskLatencyTrigger,
|
||||
priority: PresentationPriority = "normal",
|
||||
): Promise<void> {
|
||||
if (this.presentationSchedulingDisabled) {
|
||||
// Scheduling is disabled: preserve the old per-chunk synchronisation
|
||||
// semantics by awaiting flushNow() directly, while still routing through
|
||||
// the scheduler so its serialisation/locking guarantees are respected.
|
||||
await this.presentationScheduler.flushNow().catch((error) => {
|
||||
Logger.warn(`[Task] Failed immediate presentation flush: ${error}`)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Immediate semantic boundaries: first visible token, tool transitions, finalization, and cleanup drains.
|
||||
Logger.debug(`[Task ${this.taskId}] schedule assistant presentation (${trigger}, ${priority})`)
|
||||
this.presentationScheduler.requestFlush(priority)
|
||||
}
|
||||
|
||||
private async flushAssistantPresentationOrThrow() {
|
||||
await this.presentationScheduler.flushNow()
|
||||
}
|
||||
|
||||
private getPresentationPriorityForChunk(args: {
|
||||
chunkType: "text" | "reasoning" | "tool_calls"
|
||||
hadVisibleAssistantContent: boolean
|
||||
}): PresentationPriority {
|
||||
if (!args.hadVisibleAssistantContent) {
|
||||
return "immediate"
|
||||
}
|
||||
|
||||
if (args.chunkType === "tool_calls") {
|
||||
return "immediate"
|
||||
}
|
||||
|
||||
return "normal"
|
||||
}
|
||||
|
||||
// Communicate with webview
|
||||
|
||||
// partial has three valid states true (partial message), false (completion of partial message), undefined (individual complete message)
|
||||
@@ -601,7 +680,7 @@ export class Task {
|
||||
if (partial) {
|
||||
if (isUpdatingPreviousPartial) {
|
||||
// existing partial message, so update it
|
||||
await this.messageStateHandler.updateClineMessageEphemeral(lastMessageIndex, {
|
||||
await this.messageStateHandler.updateClineMessage(lastMessageIndex, {
|
||||
text,
|
||||
partial,
|
||||
})
|
||||
@@ -618,14 +697,14 @@ export class Task {
|
||||
// this.askResponseImages = undefined
|
||||
askTs = Date.now()
|
||||
this.taskState.lastMessageTs = askTs
|
||||
await this.messageStateHandler.addToClineMessagesEphemeral({
|
||||
await this.messageStateHandler.addToClineMessages({
|
||||
ts: askTs,
|
||||
type: "ask",
|
||||
ask: type,
|
||||
text,
|
||||
partial,
|
||||
})
|
||||
await this.postStateToWebviewIfDeltaSyncDisabled()
|
||||
await this.postStateToWebview()
|
||||
throw new Error("Current ask promise was ignored 2")
|
||||
}
|
||||
// partial=false means its a complete version of a previously partial message
|
||||
@@ -666,7 +745,7 @@ export class Task {
|
||||
ask: type,
|
||||
text,
|
||||
})
|
||||
await this.postStateToWebviewIfDeltaSyncDisabled()
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
} else {
|
||||
// this is a new non-partial message, so add it like normal
|
||||
@@ -683,7 +762,7 @@ export class Task {
|
||||
ask: type,
|
||||
text,
|
||||
})
|
||||
await this.postStateToWebviewIfDeltaSyncDisabled()
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
|
||||
if (type !== "command_output") {
|
||||
@@ -700,9 +779,17 @@ export class Task {
|
||||
})
|
||||
}
|
||||
|
||||
await pWaitFor(() => this.taskState.askResponse !== undefined || this.taskState.lastMessageTs !== askTs, {
|
||||
interval: 100,
|
||||
})
|
||||
const shouldWakeOnAbort = type !== "resume_task" && type !== "resume_completed_task"
|
||||
await pWaitFor(
|
||||
() =>
|
||||
this.taskState.askResponse !== undefined ||
|
||||
this.taskState.lastMessageTs !== askTs ||
|
||||
(shouldWakeOnAbort && this.taskState.abort),
|
||||
{ interval: 100 },
|
||||
)
|
||||
if (shouldWakeOnAbort && this.taskState.abort) {
|
||||
throw new Error("Cline instance aborted")
|
||||
}
|
||||
if (this.taskState.lastMessageTs !== askTs) {
|
||||
throw new Error("Current ask promise was ignored") // could happen if we send multiple asks in a row i.e. with command_output. It's important that when we know an ask could fail, it is handled gracefully
|
||||
}
|
||||
@@ -782,7 +869,7 @@ export class Task {
|
||||
if (isUpdatingPreviousPartial) {
|
||||
// existing partial message, so update it
|
||||
const lastIndex = this.messageStateHandler.getClineMessages().length - 1
|
||||
await this.messageStateHandler.updateClineMessageEphemeral(lastIndex, {
|
||||
await this.messageStateHandler.updateClineMessage(lastIndex, {
|
||||
text,
|
||||
images,
|
||||
files,
|
||||
@@ -796,7 +883,7 @@ export class Task {
|
||||
// this is a new partial message, so add it with partial state
|
||||
const sayTs = Date.now()
|
||||
this.taskState.lastMessageTs = sayTs
|
||||
await this.messageStateHandler.addToClineMessagesEphemeral({
|
||||
await this.messageStateHandler.addToClineMessages({
|
||||
ts: sayTs,
|
||||
type: "say",
|
||||
say: type,
|
||||
@@ -806,7 +893,7 @@ export class Task {
|
||||
partial,
|
||||
modelInfo,
|
||||
})
|
||||
await this.postStateToWebviewIfDeltaSyncDisabled()
|
||||
await this.postStateToWebview()
|
||||
return sayTs
|
||||
}
|
||||
// partial=false means its a complete version of a previously partial message
|
||||
@@ -839,7 +926,7 @@ export class Task {
|
||||
files,
|
||||
modelInfo,
|
||||
})
|
||||
await this.postStateToWebviewIfDeltaSyncDisabled()
|
||||
await this.postStateToWebview()
|
||||
return sayTs
|
||||
}
|
||||
// this is a new non-partial message, so add it like normal
|
||||
@@ -854,7 +941,7 @@ export class Task {
|
||||
files,
|
||||
modelInfo,
|
||||
})
|
||||
await this.postStateToWebviewIfDeltaSyncDisabled()
|
||||
await this.postStateToWebview()
|
||||
return sayTs
|
||||
}
|
||||
|
||||
@@ -1586,6 +1673,7 @@ export class Task {
|
||||
if (this.FocusChainManager) {
|
||||
this.FocusChainManager.dispose()
|
||||
}
|
||||
await this.presentationScheduler.dispose()
|
||||
} finally {
|
||||
// Release task folder lock
|
||||
if (this.taskLockAcquired) {
|
||||
@@ -1676,12 +1764,6 @@ export class Task {
|
||||
return { model, providerId, customPrompt, mode }
|
||||
}
|
||||
|
||||
private async postStateToWebviewIfDeltaSyncDisabled(): Promise<void> {
|
||||
if (this.taskUiDeltaSyncDisabled) {
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
}
|
||||
|
||||
private async writePromptMetadataArtifacts(params: { systemPrompt: string; providerInfo: ApiProviderInfo }): Promise<void> {
|
||||
const enabledFlag = process.env.CLINE_WRITE_PROMPT_ARTIFACTS?.toLowerCase()
|
||||
const enabled = enabledFlag === "1" || enabledFlag === "true" || enabledFlag === "yes"
|
||||
@@ -2280,6 +2362,10 @@ export class Task {
|
||||
throw new Error("Task instance aborted")
|
||||
}
|
||||
|
||||
// Ensure remote workspace detection completes before streaming begins so
|
||||
// the presentation scheduler uses the correct cadence from the first flush.
|
||||
await this.remoteWorkspaceDetectionPromise
|
||||
|
||||
// Increment API request counter for focus chain list management
|
||||
this.taskState.apiRequestCount++
|
||||
this.taskState.apiRequestsSinceLastTodoUpdate++
|
||||
@@ -2564,7 +2650,7 @@ export class Task {
|
||||
request: userContent.map((block) => formatContentBlockToMarkdown(block)).join("\n\n"),
|
||||
} satisfies ClineApiReqInfo),
|
||||
})
|
||||
await this.postStateToWebviewIfDeltaSyncDisabled()
|
||||
await this.postStateToWebview()
|
||||
|
||||
try {
|
||||
const taskMetrics: {
|
||||
@@ -2614,7 +2700,7 @@ export class Task {
|
||||
}
|
||||
|
||||
await updateApiReqMsgFromMetrics()
|
||||
await this.postStateToWebviewIfDeltaSyncDisabled()
|
||||
await this.postStateToWebview()
|
||||
await telemetryService.captureTokenUsage(
|
||||
this.ulid,
|
||||
usageInputTokens,
|
||||
@@ -2709,6 +2795,7 @@ export class Task {
|
||||
this.taskState.didAutomaticallyRetryFailedApiRequest = false
|
||||
await this.diffViewProvider.reset()
|
||||
this.streamHandler.reset()
|
||||
this.presentationScheduler.reset()
|
||||
this.taskState.toolUseIdMap.clear()
|
||||
|
||||
const { toolUseHandler, reasonsHandler } = this.streamHandler.getHandlers()
|
||||
@@ -2722,6 +2809,7 @@ export class Task {
|
||||
this.taskState.isStreaming = true
|
||||
let didReceiveUsageChunk = false
|
||||
let didFinalizeReasoningForUi = false
|
||||
let didScheduleAnyContent = false // Tracks whether any content chunk has been scheduled for presentation (not necessarily flushed yet)
|
||||
|
||||
const finalizePendingReasoningMessage = async (thinking: string): Promise<boolean> => {
|
||||
const pendingReasoningIndex = findLastIndex(
|
||||
@@ -2773,6 +2861,10 @@ export class Task {
|
||||
if (!chunk) {
|
||||
break
|
||||
}
|
||||
// Track whether any content chunk has been scheduled for presentation (not necessarily flushed yet).
|
||||
// Using assistantMessage alone would miss reasoning-only streams where text hasn't
|
||||
// started yet, causing every reasoning chunk to get "immediate" priority.
|
||||
const hadVisibleAssistantContent = didScheduleAnyContent
|
||||
if (!this.taskState.taskFirstTokenTimeMs) {
|
||||
this.taskState.taskFirstTokenTimeMs = Math.max(0, Date.now() - this.taskState.taskStartTimeMs)
|
||||
}
|
||||
@@ -2799,6 +2891,11 @@ export class Task {
|
||||
await this.say("reasoning", thinkingBlock.thinking, undefined, undefined, true)
|
||||
}
|
||||
}
|
||||
await this.scheduleAssistantPresentation(
|
||||
"reasoning",
|
||||
this.getPresentationPriorityForChunk({ chunkType: "reasoning", hadVisibleAssistantContent }),
|
||||
)
|
||||
didScheduleAnyContent = true
|
||||
|
||||
break
|
||||
}
|
||||
@@ -2821,6 +2918,11 @@ export class Task {
|
||||
}
|
||||
|
||||
await this.processNativeToolCalls(assistantTextOnly, toolUseHandler.getPartialToolUsesAsContent())
|
||||
await this.scheduleAssistantPresentation(
|
||||
"tool",
|
||||
this.getPresentationPriorityForChunk({ chunkType: "tool_calls", hadVisibleAssistantContent }),
|
||||
)
|
||||
didScheduleAnyContent = true
|
||||
break
|
||||
}
|
||||
case "text": {
|
||||
@@ -2848,16 +2950,15 @@ export class Task {
|
||||
if (this.taskState.assistantMessageContent.length > prevLength) {
|
||||
this.taskState.userMessageContentReady = false // new content we need to present, reset to false in case previous content set this to true
|
||||
}
|
||||
await this.scheduleAssistantPresentation(
|
||||
"text",
|
||||
this.getPresentationPriorityForChunk({ chunkType: "text", hadVisibleAssistantContent }),
|
||||
)
|
||||
didScheduleAnyContent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Present content once per chunk. Calling this from multiple case branches can
|
||||
// race partial updates and duplicate text rows in the chat.
|
||||
await this.presentAssistantMessage().catch((error) =>
|
||||
Logger.debug("[Task] Failed to present message: " + error),
|
||||
)
|
||||
|
||||
if (this.taskState.abort) {
|
||||
this.api.abort?.()
|
||||
if (!this.taskState.abandoned) {
|
||||
@@ -2987,7 +3088,7 @@ export class Task {
|
||||
// Update the api_req_started message with final usage and cost details
|
||||
await finalizeApiReqMsg()
|
||||
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
await this.postStateToWebviewIfDeltaSyncDisabled()
|
||||
await this.postStateToWebview()
|
||||
|
||||
// need to call here in case the stream was aborted
|
||||
if (this.taskState.abort) {
|
||||
@@ -3084,10 +3185,7 @@ export class Task {
|
||||
// in case there are native tool calls pending
|
||||
const partialToolBlocks = toolUseHandler.getPartialToolUsesAsContent()?.map((block) => ({ ...block, partial: false }))
|
||||
await this.processNativeToolCalls(assistantTextOnly, partialToolBlocks)
|
||||
|
||||
if (partialBlocks.length > 0) {
|
||||
await this.presentAssistantMessage() // if there is content to update then it will complete and update this.userMessageContentReady to true, which we pwaitfor before making the next request. all this is really doing is presenting the last partial message that we just set to complete
|
||||
}
|
||||
await this.flushAssistantPresentationOrThrow() // finalization is immediate so no coalesced content remains pending
|
||||
|
||||
// now add to apiconversationhistory
|
||||
// need to save assistant responses to file before proceeding to tool use since user can exit at any moment and we wouldn't be able to save the assistant's response
|
||||
@@ -3347,7 +3445,7 @@ export class Task {
|
||||
return [processedUserContent, environmentDetails, clinerulesError]
|
||||
}
|
||||
|
||||
async processNativeToolCalls(assistantTextOnly: string, toolBlocks: ToolUse[]) {
|
||||
protected async processNativeToolCalls(assistantTextOnly: string, toolBlocks: ToolUse[]) {
|
||||
if (!toolBlocks?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
+27
-125
@@ -1,6 +1,7 @@
|
||||
export type PresentationPriority = "immediate" | "normal" | "low"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { PresentationPriority } from "./presentation-types"
|
||||
|
||||
export type TaskLatencyTrigger = "text" | "reasoning" | "tool" | "finalization" | "other"
|
||||
export type TaskLatencyTrigger = "text" | "reasoning" | "tool"
|
||||
|
||||
function readBooleanEnv(envVarName: string): boolean {
|
||||
const rawValue = process.env[envVarName]?.toLowerCase()
|
||||
@@ -15,36 +16,38 @@ function readCadenceOverride(envVarName: string): number | undefined {
|
||||
|
||||
const parsed = Number.parseInt(rawValue, 10)
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
Logger.warn(`[latency] Ignoring invalid cadence override ${envVarName}="${rawValue}" (must be a non-negative integer)`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function getCadenceOverride(args: { isRemoteWorkspace: boolean; localEnvVar: string; remoteEnvVar: string }): number | undefined {
|
||||
return args.isRemoteWorkspace ? readCadenceOverride(args.remoteEnvVar) : readCadenceOverride(args.localEnvVar)
|
||||
}
|
||||
// Cadence overrides are read once at module load. Env vars do not change at
|
||||
// runtime, and getPresentationCadenceMs is called on every flush (hot path).
|
||||
const localCadenceOverride = readCadenceOverride("CLINE_PRESENTATION_CADENCE_MS")
|
||||
const remoteCadenceOverride = readCadenceOverride("CLINE_REMOTE_PRESENTATION_CADENCE_MS")
|
||||
const schedulingDisabled = readBooleanEnv("CLINE_DISABLE_PRESENTATION_SCHEDULER")
|
||||
|
||||
/**
|
||||
* Determines whether the host is connected to a remote workspace.
|
||||
*
|
||||
* The primary signal is `remoteName` which is populated from `vscode.env.remoteName`
|
||||
* (e.g. `"ssh-remote"`, `"dev-container"`, `"codespaces"`). When this field is present
|
||||
* the host is definitively remote.
|
||||
*
|
||||
* For non-VSCode hosts (e.g. JetBrains) that do not populate `remoteName`, this
|
||||
* function conservatively returns `false` and uses the local cadence. This avoids
|
||||
* false positives from version strings that happen to contain the word "remote"
|
||||
* (e.g. `"1.0.0-remote-fix"`). Host bridges for remote-capable environments should
|
||||
* populate `remoteName` explicitly to opt in to the higher cadence.
|
||||
*/
|
||||
export function isRemoteWorkspaceEnvironment(host: { platform?: string; version?: string; remoteName?: string | null }): boolean {
|
||||
if (host.remoteName) {
|
||||
return true
|
||||
}
|
||||
|
||||
const platform = host.platform?.toLowerCase() ?? ""
|
||||
const version = host.version?.toLowerCase() ?? ""
|
||||
return platform.includes("remote") || version.includes("remote")
|
||||
return !!host.remoteName
|
||||
}
|
||||
|
||||
export function isPresentationSchedulingDisabled(): boolean {
|
||||
return readBooleanEnv("CLINE_DISABLE_PRESENTATION_SCHEDULER")
|
||||
}
|
||||
|
||||
export function isEphemeralMessagePersistenceDisabled(): boolean {
|
||||
return readBooleanEnv("CLINE_DISABLE_EPHEMERAL_MESSAGE_PERSISTENCE")
|
||||
}
|
||||
|
||||
export function isTaskUiDeltaSyncDisabled(): boolean {
|
||||
return readBooleanEnv("CLINE_DISABLE_TASK_UI_DELTA_SYNC")
|
||||
return schedulingDisabled
|
||||
}
|
||||
|
||||
export function getPresentationCadenceMs(isRemoteWorkspace: boolean, priority: PresentationPriority): number {
|
||||
@@ -52,113 +55,12 @@ export function getPresentationCadenceMs(isRemoteWorkspace: boolean, priority: P
|
||||
return 0
|
||||
}
|
||||
|
||||
const override = getCadenceOverride({
|
||||
isRemoteWorkspace,
|
||||
localEnvVar: priority === "low" ? "CLINE_PRESENTATION_LOW_CADENCE_MS" : "CLINE_PRESENTATION_CADENCE_MS",
|
||||
remoteEnvVar: priority === "low" ? "CLINE_REMOTE_PRESENTATION_LOW_CADENCE_MS" : "CLINE_REMOTE_PRESENTATION_CADENCE_MS",
|
||||
})
|
||||
const override = isRemoteWorkspace ? remoteCadenceOverride : localCadenceOverride
|
||||
if (override !== undefined) {
|
||||
return override
|
||||
}
|
||||
|
||||
if (priority === "low") {
|
||||
return isRemoteWorkspace ? 125 : 50
|
||||
}
|
||||
|
||||
// Default cadences: remote workspaces use a higher interval to reduce
|
||||
// message-passing overhead over the network.
|
||||
return isRemoteWorkspace ? 90 : 40
|
||||
}
|
||||
|
||||
export function getStateUpdateCadenceMs(isRemoteWorkspace: boolean, priority: PresentationPriority): number {
|
||||
if (priority === "immediate") {
|
||||
return 0
|
||||
}
|
||||
|
||||
const override = getCadenceOverride({
|
||||
isRemoteWorkspace,
|
||||
localEnvVar: priority === "low" ? "CLINE_STATE_UPDATE_LOW_CADENCE_MS" : "CLINE_STATE_UPDATE_CADENCE_MS",
|
||||
remoteEnvVar: priority === "low" ? "CLINE_REMOTE_STATE_UPDATE_LOW_CADENCE_MS" : "CLINE_REMOTE_STATE_UPDATE_CADENCE_MS",
|
||||
})
|
||||
if (override !== undefined) {
|
||||
return override
|
||||
}
|
||||
|
||||
if (priority === "low") {
|
||||
return isRemoteWorkspace ? 150 : 40
|
||||
}
|
||||
|
||||
return isRemoteWorkspace ? 110 : 16
|
||||
}
|
||||
|
||||
export function getUsageUpdateCadenceMs(isRemoteWorkspace: boolean): number {
|
||||
const override = getCadenceOverride({
|
||||
isRemoteWorkspace,
|
||||
localEnvVar: "CLINE_USAGE_UPDATE_CADENCE_MS",
|
||||
remoteEnvVar: "CLINE_REMOTE_USAGE_UPDATE_CADENCE_MS",
|
||||
})
|
||||
if (override !== undefined) {
|
||||
return override
|
||||
}
|
||||
|
||||
return isRemoteWorkspace ? 400 : 250
|
||||
}
|
||||
|
||||
export function getRequestBoundaryCacheTtlMs(isRemoteWorkspace: boolean): number {
|
||||
const override = getCadenceOverride({
|
||||
isRemoteWorkspace,
|
||||
localEnvVar: "CLINE_REQUEST_BOUNDARY_CACHE_TTL_MS",
|
||||
remoteEnvVar: "CLINE_REMOTE_REQUEST_BOUNDARY_CACHE_TTL_MS",
|
||||
})
|
||||
if (override !== undefined) {
|
||||
return override
|
||||
}
|
||||
|
||||
return isRemoteWorkspace ? 1000 : 500
|
||||
}
|
||||
|
||||
export function getEnvironmentDetailsStaticCacheTtlMs(isRemoteWorkspace: boolean): number {
|
||||
const override = getCadenceOverride({
|
||||
isRemoteWorkspace,
|
||||
localEnvVar: "CLINE_ENVIRONMENT_DETAILS_STATIC_CACHE_TTL_MS",
|
||||
remoteEnvVar: "CLINE_REMOTE_ENVIRONMENT_DETAILS_STATIC_CACHE_TTL_MS",
|
||||
})
|
||||
if (override !== undefined) {
|
||||
return override
|
||||
}
|
||||
|
||||
return isRemoteWorkspace ? 60_000 : 30_000
|
||||
}
|
||||
|
||||
export function shouldWaitForTerminalCooldown(args: {
|
||||
busyTerminalIds: number[]
|
||||
isProcessHot: (terminalId: number) => boolean
|
||||
didEditFile: boolean
|
||||
}): boolean {
|
||||
if (args.busyTerminalIds.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (args.didEditFile) {
|
||||
return true
|
||||
}
|
||||
|
||||
return args.busyTerminalIds.some((terminalId) => {
|
||||
try {
|
||||
return args.isProcessHot(terminalId)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function summarizeChunkToWebviewDelays(delaysMs: number[]): { medianMs: number; p95Ms: number } {
|
||||
if (delaysMs.length === 0) {
|
||||
return { medianMs: 0, p95Ms: 0 }
|
||||
}
|
||||
|
||||
const sorted = [...delaysMs].sort((a, b) => a - b)
|
||||
const percentile = (ratio: number) => sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1))]
|
||||
return {
|
||||
medianMs: percentile(0.5),
|
||||
p95Ms: percentile(0.95),
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,7 @@ import { HistoryItem } from "@/shared/HistoryItem"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { getCwd, getDesktopDir } from "@/utils/path"
|
||||
import { sendTaskUiDelta } from "../controller/ui/subscribeToTaskUiDeltas"
|
||||
import { ensureTaskDirectoryExists, saveApiConversationHistory, saveClineMessages } from "../storage/disk"
|
||||
import { isTaskUiDeltaSyncDisabled } from "./latency"
|
||||
import { TaskState } from "./TaskState"
|
||||
|
||||
// Event types for clineMessages changes
|
||||
@@ -50,14 +48,12 @@ interface MessageStateHandlerParams {
|
||||
export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents> {
|
||||
private apiConversationHistory: ClineStorageMessage[] = []
|
||||
private clineMessages: ClineMessage[] = []
|
||||
private hasDirtyEphemeralChanges = false
|
||||
private taskIsFavorited: boolean
|
||||
private checkpointTracker: CheckpointTracker | undefined
|
||||
private updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
|
||||
private taskId: string
|
||||
private ulid: string
|
||||
private taskState: TaskState
|
||||
private readonly taskUiDeltaSyncDisabled = isTaskUiDeltaSyncDisabled()
|
||||
|
||||
// Mutex to prevent concurrent state modifications (RC-4)
|
||||
// Protects against data loss from race conditions when multiple
|
||||
@@ -79,31 +75,6 @@ export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents>
|
||||
*/
|
||||
private emitClineMessagesChanged(change: ClineMessageChange): void {
|
||||
this.emit("clineMessagesChanged", change)
|
||||
if (!this.taskUiDeltaSyncDisabled) {
|
||||
void this.emitTaskUiDeltaForChange(change)
|
||||
}
|
||||
}
|
||||
|
||||
private async emitTaskUiDeltaForChange(change: ClineMessageChange): Promise<void> {
|
||||
const sequence = ++this.taskState.taskUiDeltaSequence
|
||||
if (change.type === "add" && change.message) {
|
||||
await sendTaskUiDelta({ type: "message_added", taskId: this.taskId, sequence, message: change.message })
|
||||
return
|
||||
}
|
||||
|
||||
if (change.type === "update" && change.message) {
|
||||
await sendTaskUiDelta({ type: "message_updated", taskId: this.taskId, sequence, message: change.message })
|
||||
return
|
||||
}
|
||||
|
||||
if (change.type === "delete" && change.previousMessage) {
|
||||
await sendTaskUiDelta({ type: "message_deleted", taskId: this.taskId, sequence, messageTs: change.previousMessage.ts })
|
||||
return
|
||||
}
|
||||
|
||||
if (change.type === "set") {
|
||||
await sendTaskUiDelta({ type: "task_state_resynced", taskId: this.taskId, sequence })
|
||||
}
|
||||
}
|
||||
|
||||
setCheckpointTracker(tracker: CheckpointTracker | undefined) {
|
||||
@@ -134,7 +105,6 @@ export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents>
|
||||
setClineMessages(newMessages: ClineMessage[]) {
|
||||
const previousMessages = this.clineMessages
|
||||
this.clineMessages = newMessages
|
||||
this.hasDirtyEphemeralChanges = true
|
||||
this.emitClineMessagesChanged({
|
||||
type: "set",
|
||||
messages: this.clineMessages,
|
||||
@@ -213,22 +183,6 @@ export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents>
|
||||
})
|
||||
}
|
||||
|
||||
async addToClineMessagesEphemeral(message: ClineMessage) {
|
||||
return await this.withStateLock(async () => {
|
||||
message.conversationHistoryIndex = this.apiConversationHistory.length - 1
|
||||
message.conversationHistoryDeletedRange = this.taskState.conversationHistoryDeletedRange
|
||||
const index = this.clineMessages.length
|
||||
this.clineMessages.push(message)
|
||||
this.hasDirtyEphemeralChanges = true
|
||||
this.emitClineMessagesChanged({
|
||||
type: "add",
|
||||
messages: this.clineMessages,
|
||||
index,
|
||||
message,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async overwriteApiConversationHistory(newHistory: ClineStorageMessage[]): Promise<void> {
|
||||
// Protect with mutex to prevent concurrent modifications from corrupting data (RC-4)
|
||||
return await this.withStateLock(async () => {
|
||||
@@ -269,7 +223,6 @@ export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents>
|
||||
return await this.withStateLock(async () => {
|
||||
const previousMessages = this.clineMessages
|
||||
this.clineMessages = newMessages
|
||||
this.hasDirtyEphemeralChanges = true
|
||||
this.emitClineMessagesChanged({
|
||||
type: "set",
|
||||
messages: this.clineMessages,
|
||||
@@ -308,26 +261,6 @@ export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents>
|
||||
})
|
||||
}
|
||||
|
||||
async updateClineMessageEphemeral(index: number, updates: Partial<ClineMessage>): Promise<void> {
|
||||
return await this.withStateLock(async () => {
|
||||
if (index < 0 || index >= this.clineMessages.length) {
|
||||
throw new Error(`Invalid message index: ${index}`)
|
||||
}
|
||||
|
||||
const previousMessage = { ...this.clineMessages[index] }
|
||||
Object.assign(this.clineMessages[index], updates)
|
||||
this.hasDirtyEphemeralChanges = true
|
||||
|
||||
this.emitClineMessagesChanged({
|
||||
type: "update",
|
||||
messages: this.clineMessages,
|
||||
index,
|
||||
previousMessage,
|
||||
message: this.clineMessages[index],
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific message from the clineMessages array
|
||||
* The entire operation (validate, delete, save) is atomic to prevent races (RC-4)
|
||||
@@ -355,13 +288,4 @@ export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents>
|
||||
await this.saveClineMessagesAndUpdateHistoryInternal()
|
||||
})
|
||||
}
|
||||
|
||||
async flushClineMessagesAndUpdateHistory(): Promise<void> {
|
||||
return await this.withStateLock(async () => {
|
||||
if (!this.hasDirtyEphemeralChanges) {
|
||||
return
|
||||
}
|
||||
await this.saveClineMessagesAndUpdateHistoryInternal()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Priority level for a presentation flush request.
|
||||
*
|
||||
* - `"immediate"` — flush synchronously (delay = 0 ms). Used at semantic
|
||||
* boundaries: first visible token, tool-call transitions, and finalization.
|
||||
* - `"normal"` — flush after the configured cadence delay, coalescing
|
||||
* intermediate chunks to reduce message-passing overhead.
|
||||
*/
|
||||
export type PresentationPriority = "immediate" | "normal"
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { resolve as resolvePath } from "node:path"
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import { resolveWorkspacePath } from "@core/workspace"
|
||||
import { processFilesIntoText } from "@integrations/misc/extract-text"
|
||||
@@ -335,6 +336,13 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
const pathToTrack = change.type === PatchActionType.UPDATE && change.movePath ? change.movePath : changedFilePath
|
||||
config.services.fileContextTracker.markFileAsEditedByCline(pathToTrack)
|
||||
await config.services.fileContextTracker.trackFileContext(pathToTrack, "cline_edited")
|
||||
|
||||
// Invalidate file read cache for all changed files so re-reads get fresh content
|
||||
config.taskState.fileReadCache.delete(resolvePath(config.cwd, pathToTrack).toLowerCase())
|
||||
// Also invalidate old path for move operations
|
||||
if (change.type === PatchActionType.UPDATE && change.movePath) {
|
||||
config.taskState.fileReadCache.delete(resolvePath(config.cwd, changedFilePath).toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
this.config = undefined
|
||||
@@ -348,6 +356,7 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
for (const [path, result] of Object.entries(applyResults)) {
|
||||
if (result.deleted) {
|
||||
config.taskState.didEditFile = true
|
||||
// Note: cache invalidation for deleted files is already handled in the changedFiles loop above
|
||||
responseLines.push(`\n${path}: [deleted]`)
|
||||
} else {
|
||||
// Format response similar to WriteToFileToolHandler
|
||||
|
||||
@@ -312,6 +312,16 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
// Invalidate the entire file read cache after any command execution.
|
||||
// Bash commands can modify files in ways we can't predict (sed, npm install, git checkout, mv, etc.),
|
||||
// so we must clear the cache to prevent stale reads.
|
||||
// Invalidate the entire file read cache after any command execution.
|
||||
// Bash commands can modify files in ways we can't predict (sed, npm install, git checkout, mv, etc.),
|
||||
// so we must clear the cache to prevent stale reads.
|
||||
if (!userRejected) {
|
||||
config.taskState.fileReadCache.clear()
|
||||
}
|
||||
|
||||
if (userRejected) {
|
||||
config.taskState.didRejectTool = true
|
||||
}
|
||||
|
||||
@@ -167,6 +167,61 @@ export class ReadFileToolHandler implements IFullyManagedTool {
|
||||
throw error
|
||||
}
|
||||
|
||||
// === File Read Deduplication ===
|
||||
// Check if we've already read this exact file in this task.
|
||||
// This prevents the model from endlessly reading the same file, which wastes API tokens.
|
||||
// The cache stores only metadata (readCount, mtime, imageBlock) — not file content —
|
||||
// to keep memory usage minimal. On cache hits we re-read from disk to return fresh content.
|
||||
const cacheKey = absolutePath.toLowerCase()
|
||||
const cached = config.taskState.fileReadCache.get(cacheKey)
|
||||
|
||||
if (cached) {
|
||||
// Check if the file has been modified externally (e.g. user edited in their editor)
|
||||
// by comparing the mtime. If it changed, treat this as a fresh read.
|
||||
try {
|
||||
const stat = await import("node:fs/promises").then((fs) => fs.stat(absolutePath))
|
||||
if (stat.mtimeMs !== cached.mtime) {
|
||||
// File was modified externally — evict cache entry and fall through to fresh read
|
||||
config.taskState.fileReadCache.delete(cacheKey)
|
||||
}
|
||||
} catch {
|
||||
// If we can't stat the file, evict the cache and let extractFileContent handle the error
|
||||
config.taskState.fileReadCache.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-check after possible mtime eviction
|
||||
const validCached = config.taskState.fileReadCache.get(cacheKey)
|
||||
|
||||
if (validCached) {
|
||||
validCached.readCount++
|
||||
|
||||
// Re-push image block for multimodal models so image context is not lost on cached reads
|
||||
if (validCached.imageBlock) {
|
||||
config.taskState.userMessageContent.push(validCached.imageBlock)
|
||||
}
|
||||
|
||||
// Re-read from disk (cache doesn't store content to save memory)
|
||||
const supportsImages = config.api.getModel().info.supportsImages ?? false
|
||||
let fileContent: FileContentResult
|
||||
try {
|
||||
fileContent = await extractFileContent(absolutePath, supportsImages)
|
||||
} catch (error) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const normalizedMessage = errorMessage.startsWith("Error reading file:")
|
||||
? errorMessage
|
||||
: `Error reading file: ${errorMessage}`
|
||||
return formatResponse.toolError(normalizedMessage)
|
||||
}
|
||||
|
||||
if (validCached.readCount >= 3) {
|
||||
return `[DUPLICATE READ] You have already read '${displayPath}' ${validCached.readCount} times in this conversation. The content has not changed since your last read. Please use the information you already have and proceed with your task.\n\n${fileContent.text}`
|
||||
}
|
||||
|
||||
return `[File already read] The file '${displayPath}' was already read earlier in this conversation. Returning content:\n${fileContent.text}`
|
||||
}
|
||||
|
||||
// Execute the actual file read operation
|
||||
const supportsImages = config.api.getModel().info.supportsImages ?? false
|
||||
let fileContent: FileContentResult
|
||||
@@ -191,6 +246,20 @@ export class ReadFileToolHandler implements IFullyManagedTool {
|
||||
// Track file read operation
|
||||
await config.services.fileContextTracker.trackFileContext(relPath!, "read_tool")
|
||||
|
||||
// Cache metadata for deduplication (no content stored — saves memory)
|
||||
let mtime = 0
|
||||
try {
|
||||
const stat = await import("node:fs/promises").then((fs) => fs.stat(absolutePath))
|
||||
mtime = stat.mtimeMs
|
||||
} catch {
|
||||
// If stat fails, use 0 — the next cache hit will evict due to mtime mismatch
|
||||
}
|
||||
config.taskState.fileReadCache.set(cacheKey, {
|
||||
readCount: 1,
|
||||
mtime,
|
||||
imageBlock: fileContent.imageBlock,
|
||||
})
|
||||
|
||||
// Handle image blocks separately - they need to be pushed to userMessageContent
|
||||
if (fileContent.imageBlock) {
|
||||
config.taskState.userMessageContent.push(fileContent.imageBlock)
|
||||
|
||||
@@ -361,6 +361,9 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
|
||||
config.taskState.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request
|
||||
|
||||
// Invalidate file read cache for this file so re-reads get fresh content
|
||||
config.taskState.fileReadCache.delete(absolutePath.toLowerCase())
|
||||
|
||||
// Track file edit operation
|
||||
await config.services.fileContextTracker.trackFileContext(relPath, "cline_edited")
|
||||
|
||||
|
||||
@@ -10,5 +10,11 @@ export async function getHostVersion(_: EmptyRequest): Promise<GetHostVersionRes
|
||||
version: vscode.version,
|
||||
clineType: ClineClient.VSCode,
|
||||
clineVersion: ExtensionRegistryInfo.version,
|
||||
// vscode.env.remoteName is a non-empty string when connected to a remote workspace
|
||||
// (e.g. "ssh-remote", "dev-container", "codespaces") and undefined otherwise.
|
||||
// We coerce falsy values (undefined, null, "") to undefined so the proto optional
|
||||
// field is absent for local workspaces. An empty string is treated as local — the
|
||||
// safe direction (false negative rather than false positive).
|
||||
remoteName: vscode.env.remoteName || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { strict as assert } from "assert"
|
||||
import { describe, it } from "mocha"
|
||||
import { compareTaskLatencySummaries, summarizeTaskLatencyEvents } from "../taskLatencySummary"
|
||||
|
||||
describe("taskLatencySummary", () => {
|
||||
it("summarizes averages and ranges across latency events", () => {
|
||||
const summary = summarizeTaskLatencyEvents([
|
||||
{
|
||||
ulid: "task-1",
|
||||
requestIndex: 1,
|
||||
presentationInvocationCount: 2,
|
||||
partialMessageCount: 4,
|
||||
statePostCount: 1,
|
||||
statePostSerializedBytes: 100,
|
||||
persistenceFlushCount: 1,
|
||||
chunkToWebviewMedianMs: 20,
|
||||
chunkToWebviewP95Ms: 35,
|
||||
taskInitializationDurationMs: 600,
|
||||
},
|
||||
{
|
||||
ulid: "task-1",
|
||||
requestIndex: 2,
|
||||
presentationInvocationCount: 4,
|
||||
partialMessageCount: 6,
|
||||
statePostCount: 3,
|
||||
statePostSerializedBytes: 300,
|
||||
persistenceFlushCount: 2,
|
||||
chunkToWebviewMedianMs: 30,
|
||||
chunkToWebviewP95Ms: 45,
|
||||
taskInitializationDurationMs: 900,
|
||||
},
|
||||
])
|
||||
|
||||
assert.equal(summary.eventCount, 2)
|
||||
assert.equal(summary.requestCount, 2)
|
||||
assert.deepStrictEqual(summary.metrics.presentationInvocationCount, { average: 3, min: 2, max: 4 })
|
||||
assert.deepStrictEqual(summary.metrics.statePostSerializedBytes, { average: 200, min: 100, max: 300 })
|
||||
assert.deepStrictEqual(summary.metrics.chunkToWebviewP95Ms, { average: 40, min: 35, max: 45 })
|
||||
assert.deepStrictEqual(summary.metrics.taskInitializationDurationMs, { average: 750, min: 600, max: 900 })
|
||||
})
|
||||
|
||||
it("returns zeroed summaries when events are empty or metrics are absent", () => {
|
||||
const summary = summarizeTaskLatencyEvents([{ ulid: "task-1", requestIndex: 1 }])
|
||||
assert.equal(summary.eventCount, 1)
|
||||
assert.equal(summary.requestCount, 1)
|
||||
assert.deepStrictEqual(summary.metrics.partialMessageCount, { average: 0, min: 0, max: 0 })
|
||||
|
||||
const empty = summarizeTaskLatencyEvents([])
|
||||
assert.equal(empty.eventCount, 0)
|
||||
assert.equal(empty.requestCount, 0)
|
||||
assert.deepStrictEqual(empty.metrics.statePostCount, { average: 0, min: 0, max: 0 })
|
||||
})
|
||||
|
||||
it("compares latency summaries for before/after analysis", () => {
|
||||
const baseline = summarizeTaskLatencyEvents([{ ulid: "task", requestIndex: 1, presentationInvocationCount: 5, statePostCount: 4 }])
|
||||
const candidate = summarizeTaskLatencyEvents([{ ulid: "task", requestIndex: 1, presentationInvocationCount: 3, statePostCount: 2 }])
|
||||
|
||||
const comparison = compareTaskLatencySummaries(baseline, candidate)
|
||||
assert.equal(comparison.baselineEvents, 1)
|
||||
assert.equal(comparison.candidateEvents, 1)
|
||||
assert.deepStrictEqual(comparison.metricDiffs.presentationInvocationCount, {
|
||||
averageDelta: -2,
|
||||
minDelta: -2,
|
||||
maxDelta: -2,
|
||||
})
|
||||
assert.deepStrictEqual(comparison.metricDiffs.statePostCount, {
|
||||
averageDelta: -2,
|
||||
minDelta: -2,
|
||||
maxDelta: -2,
|
||||
})
|
||||
})
|
||||
|
||||
it("normalizes task initialization events into latency summaries", () => {
|
||||
const summary = summarizeTaskLatencyEvents([
|
||||
{ event: "task.initialization", ulid: "task-1", taskId: "task-a", durationMs: 500 },
|
||||
{ event: "task.initialization", ulid: "task-2", taskId: "task-b", durationMs: 700 },
|
||||
])
|
||||
|
||||
assert.equal(summary.eventCount, 2)
|
||||
assert.equal(summary.requestCount, 0)
|
||||
assert.deepStrictEqual(summary.metrics.taskInitializationDurationMs, {
|
||||
average: 600,
|
||||
min: 500,
|
||||
max: 700,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,120 +0,0 @@
|
||||
type TaskLatencyEvent = {
|
||||
presentationInvocationCount?: number
|
||||
partialMessageCount?: number
|
||||
statePostCount?: number
|
||||
statePostSerializedBytes?: number
|
||||
persistenceFlushCount?: number
|
||||
chunkToWebviewMedianMs?: number
|
||||
chunkToWebviewP95Ms?: number
|
||||
taskInitializationDurationMs?: number
|
||||
durationMs?: number
|
||||
requestIndex?: number
|
||||
ulid?: string
|
||||
taskId?: string
|
||||
event?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type NumericMetricKey =
|
||||
| "presentationInvocationCount"
|
||||
| "partialMessageCount"
|
||||
| "statePostCount"
|
||||
| "statePostSerializedBytes"
|
||||
| "persistenceFlushCount"
|
||||
| "chunkToWebviewMedianMs"
|
||||
| "chunkToWebviewP95Ms"
|
||||
| "taskInitializationDurationMs"
|
||||
|
||||
export type TaskLatencySummary = {
|
||||
eventCount: number
|
||||
requestCount: number
|
||||
metrics: Record<NumericMetricKey, { average: number; min: number; max: number }>
|
||||
}
|
||||
|
||||
export type TaskLatencySummaryComparison = {
|
||||
baselineEvents: number
|
||||
candidateEvents: number
|
||||
metricDiffs: Record<NumericMetricKey, { averageDelta: number; minDelta: number; maxDelta: number }>
|
||||
}
|
||||
|
||||
const METRIC_KEYS: NumericMetricKey[] = [
|
||||
"presentationInvocationCount",
|
||||
"partialMessageCount",
|
||||
"statePostCount",
|
||||
"statePostSerializedBytes",
|
||||
"persistenceFlushCount",
|
||||
"chunkToWebviewMedianMs",
|
||||
"chunkToWebviewP95Ms",
|
||||
"taskInitializationDurationMs",
|
||||
]
|
||||
|
||||
function normalizeEvent(event: TaskLatencyEvent): TaskLatencyEvent {
|
||||
if (Number.isFinite(event.taskInitializationDurationMs)) {
|
||||
return event
|
||||
}
|
||||
|
||||
if (event.event === "task.initialization" && Number.isFinite(event.durationMs)) {
|
||||
return {
|
||||
...event,
|
||||
taskInitializationDurationMs: event.durationMs as number,
|
||||
}
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
function summarizeMetric(events: TaskLatencyEvent[], key: NumericMetricKey) {
|
||||
const values = events.map((event) => event[key]).filter((value): value is number => Number.isFinite(value))
|
||||
if (values.length === 0) {
|
||||
return { average: 0, min: 0, max: 0 }
|
||||
}
|
||||
|
||||
const total = values.reduce((sum, value) => sum + value, 0)
|
||||
return {
|
||||
average: total / values.length,
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeTaskLatencyEvents(events: TaskLatencyEvent[]): TaskLatencySummary {
|
||||
const normalizedEvents = events.map(normalizeEvent)
|
||||
const requestKeys = new Set(
|
||||
normalizedEvents
|
||||
.map((event) => {
|
||||
if (event.ulid && Number.isFinite(event.requestIndex)) {
|
||||
return `${event.ulid}:${event.requestIndex}`
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
)
|
||||
|
||||
return {
|
||||
eventCount: normalizedEvents.length,
|
||||
requestCount: requestKeys.size,
|
||||
metrics: Object.fromEntries(METRIC_KEYS.map((key) => [key, summarizeMetric(normalizedEvents, key)])) as TaskLatencySummary["metrics"],
|
||||
}
|
||||
}
|
||||
|
||||
export function compareTaskLatencySummaries(
|
||||
baseline: TaskLatencySummary,
|
||||
candidate: TaskLatencySummary,
|
||||
): TaskLatencySummaryComparison {
|
||||
return {
|
||||
baselineEvents: baseline.eventCount,
|
||||
candidateEvents: candidate.eventCount,
|
||||
metricDiffs: Object.fromEntries(
|
||||
METRIC_KEYS.map((key) => [
|
||||
key,
|
||||
{
|
||||
averageDelta: candidate.metrics[key].average - baseline.metrics[key].average,
|
||||
minDelta: candidate.metrics[key].min - baseline.metrics[key].min,
|
||||
maxDelta: candidate.metrics[key].max - baseline.metrics[key].max,
|
||||
},
|
||||
]),
|
||||
) as TaskLatencySummaryComparison["metricDiffs"],
|
||||
}
|
||||
}
|
||||
|
||||
export type { TaskLatencyEvent }
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { ClineMessage, ExtensionState } from "@shared/ExtensionMessage"
|
||||
|
||||
/**
|
||||
* Narrow task metadata updates that can be streamed during active execution.
|
||||
*
|
||||
* Snapshots remain the canonical hydration/recovery source of truth. Deltas are
|
||||
* only valid for the active task and must be applied strictly in sequence.
|
||||
*/
|
||||
export type TaskUiMetadataDelta = Partial<
|
||||
Pick<ExtensionState, "currentFocusChainChecklist" | "backgroundCommandRunning" | "backgroundCommandTaskId">
|
||||
>
|
||||
|
||||
/**
|
||||
* Task UI delta contract for active task execution.
|
||||
*
|
||||
* Sequencing contract:
|
||||
* - `taskId` scopes the delta to a specific active task.
|
||||
* - `sequence` must increase monotonically by exactly 1.
|
||||
* - Any gap, duplicate, or out-of-order delta must trigger snapshot resync.
|
||||
* - `task_state_resynced` signals the receiver to discard local sequencing state
|
||||
* and rehydrate from the canonical full snapshot path.
|
||||
*/
|
||||
export type TaskUiDelta =
|
||||
| {
|
||||
type: "message_added"
|
||||
taskId: string
|
||||
sequence: number
|
||||
message: ClineMessage
|
||||
}
|
||||
| {
|
||||
type: "message_updated"
|
||||
taskId: string
|
||||
sequence: number
|
||||
message: ClineMessage
|
||||
}
|
||||
| {
|
||||
type: "message_deleted"
|
||||
taskId: string
|
||||
sequence: number
|
||||
messageTs: number
|
||||
}
|
||||
| {
|
||||
type: "task_metadata_updated"
|
||||
taskId: string
|
||||
sequence: number
|
||||
metadata: TaskUiMetadataDelta
|
||||
}
|
||||
| {
|
||||
type: "task_state_resynced"
|
||||
taskId: string
|
||||
sequence: number
|
||||
}
|
||||
|
||||
export function isTaskUiDeltaMessageMutation(
|
||||
delta: TaskUiDelta,
|
||||
): delta is Extract<TaskUiDelta, { type: "message_added" | "message_updated" | "message_deleted" }> {
|
||||
return delta.type === "message_added" || delta.type === "message_updated" || delta.type === "message_deleted"
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import * as sinon from "sinon"
|
||||
import { Controller } from "../core/controller"
|
||||
import * as taskUiDeltaModule from "../core/controller/ui/subscribeToTaskUiDeltas"
|
||||
|
||||
describe("Controller.postTaskMetadataDelta", () => {
|
||||
it("publishes metadata deltas for the current active task", async () => {
|
||||
const sendTaskUiDeltaStub = sinon.stub(taskUiDeltaModule, "sendTaskUiDelta").resolves(undefined)
|
||||
const postStateToWebview = sinon.stub().resolves()
|
||||
|
||||
const fakeController = {
|
||||
task: {
|
||||
taskId: "task-1",
|
||||
taskState: {
|
||||
taskUiDeltaSequence: 0,
|
||||
},
|
||||
},
|
||||
postStateToWebview,
|
||||
}
|
||||
|
||||
await Controller.prototype.postTaskMetadataDelta.call(fakeController as any, {
|
||||
backgroundCommandRunning: true,
|
||||
backgroundCommandTaskId: "task-1",
|
||||
})
|
||||
|
||||
sinon.assert.calledOnce(sendTaskUiDeltaStub)
|
||||
sinon.assert.calledWithExactly(sendTaskUiDeltaStub, {
|
||||
type: "task_metadata_updated",
|
||||
taskId: "task-1",
|
||||
sequence: 1,
|
||||
metadata: {
|
||||
backgroundCommandRunning: true,
|
||||
backgroundCommandTaskId: "task-1",
|
||||
},
|
||||
})
|
||||
sinon.assert.notCalled(postStateToWebview)
|
||||
|
||||
sendTaskUiDeltaStub.restore()
|
||||
})
|
||||
|
||||
it("falls back to posting full state when task identity is missing or mismatched", async () => {
|
||||
const sendTaskUiDeltaStub = sinon.stub(taskUiDeltaModule, "sendTaskUiDelta").resolves(undefined)
|
||||
const postStateToWebview = sinon.stub().resolves()
|
||||
|
||||
const fakeController = {
|
||||
task: {
|
||||
taskId: "task-1",
|
||||
taskState: {
|
||||
taskUiDeltaSequence: 4,
|
||||
},
|
||||
},
|
||||
postStateToWebview,
|
||||
}
|
||||
|
||||
await Controller.prototype.postTaskMetadataDelta.call(
|
||||
fakeController as any,
|
||||
{ currentFocusChainChecklist: "- [x] one" },
|
||||
"task-2",
|
||||
)
|
||||
|
||||
sinon.assert.notCalled(sendTaskUiDeltaStub)
|
||||
sinon.assert.calledOnce(postStateToWebview)
|
||||
|
||||
sendTaskUiDeltaStub.restore()
|
||||
})
|
||||
})
|
||||
@@ -53,7 +53,7 @@ The user wants me to replace the name "john" with "cline" in the test.ts file. I
|
||||
export const name = "john"
|
||||
\`\`\`
|
||||
|
||||
I need to change "john" to "cline". This is a simple targeted edit, so I should use the replace_in_file tool rather than write_to_file since I'm only changing one small part of the file.
|
||||
I need to change "john" to "cline". This is a simple targeted edit, so I should use the replace_in_file tool rather than write_to_file since I\'m only changing one small part of the file.
|
||||
|
||||
I need to:
|
||||
1. Use replace_in_file to change "john" to "cline" in the test.ts file
|
||||
@@ -61,7 +61,7 @@ I need to:
|
||||
3. The REPLACE block should be: \`export const name = "cline"\`
|
||||
</thinking>
|
||||
|
||||
I'll replace "john" with "cline" in the test.ts file.
|
||||
I\'ll replace "john" with "cline" in the test.ts file.
|
||||
|
||||
<replace_in_file>
|
||||
<path>test.ts</path>
|
||||
@@ -74,41 +74,8 @@ export const name = "cline"
|
||||
</diff>
|
||||
</replace_in_file>`
|
||||
|
||||
const latency_validation = `I'll complete a lightweight validation task so the latency harness can measure end-to-end task UI behavior.
|
||||
|
||||
<attempt_completion>
|
||||
<result>
|
||||
Latency validation scenario completed successfully.
|
||||
</result>
|
||||
</attempt_completion>`
|
||||
|
||||
const latency_validation_long = `I'll complete a longer-running validation task so the latency harness can measure repeated task UI updates under sustained streaming load.
|
||||
|
||||
Here is a streamed progress narrative with enough material to force multiple incremental updates while still converging on a single correct final UI state. The harness should observe the active task accumulating partial message activity, task UI deltas, and final completion without producing duplicate stale rows.
|
||||
|
||||
Progress checkpoint 1: initializing the long-running validation flow.
|
||||
Progress checkpoint 2: continuing the long-running validation flow.
|
||||
Progress checkpoint 3: continuing the long-running validation flow.
|
||||
Progress checkpoint 4: continuing the long-running validation flow.
|
||||
Progress checkpoint 5: continuing the long-running validation flow.
|
||||
Progress checkpoint 6: continuing the long-running validation flow.
|
||||
Progress checkpoint 7: continuing the long-running validation flow.
|
||||
Progress checkpoint 8: continuing the long-running validation flow.
|
||||
Progress checkpoint 9: continuing the long-running validation flow.
|
||||
Progress checkpoint 10: continuing the long-running validation flow.
|
||||
Progress checkpoint 11: continuing the long-running validation flow.
|
||||
Progress checkpoint 12: continuing the long-running validation flow.
|
||||
|
||||
<attempt_completion>
|
||||
<result>
|
||||
Long-running latency validation scenario completed successfully.
|
||||
</result>
|
||||
</attempt_completion>`
|
||||
|
||||
export const E2E_MOCK_API_RESPONSES = {
|
||||
DEFAULT: "Hello! I'm a mock Cline API response.",
|
||||
REPLACE_REQUEST: replace_in_file,
|
||||
EDIT_REQUEST: edit_request,
|
||||
LATENCY_VALIDATION: latency_validation,
|
||||
LATENCY_VALIDATION_LONG: latency_validation_long,
|
||||
}
|
||||
|
||||
@@ -377,11 +377,6 @@ export class ClineApiServerMock {
|
||||
const parsed = JSON.parse(body)
|
||||
const { _messages, model = "claude-3-5-sonnet-20241022", stream = true } = parsed
|
||||
let responseText = E2E_MOCK_API_RESPONSES.DEFAULT
|
||||
if (body.includes("latency_validation_long")) {
|
||||
responseText = E2E_MOCK_API_RESPONSES.LATENCY_VALIDATION_LONG
|
||||
} else if (body.includes("latency_validation")) {
|
||||
responseText = E2E_MOCK_API_RESPONSES.LATENCY_VALIDATION
|
||||
}
|
||||
if (body.includes("[replace_in_file for 'test.ts'] Result:")) {
|
||||
responseText = E2E_MOCK_API_RESPONSES.REPLACE_REQUEST
|
||||
}
|
||||
@@ -459,30 +454,31 @@ export class ClineApiServerMock {
|
||||
|
||||
sendChunk()
|
||||
return
|
||||
}
|
||||
const response = {
|
||||
id: generationId,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Hello! I'm a mock Cline API response.",
|
||||
} else {
|
||||
const response = {
|
||||
id: generationId,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Hello! I'm a mock Cline API response.",
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
finish_reason: "stop",
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 140,
|
||||
completion_tokens: responseText.length,
|
||||
total_tokens: 140 + responseText.length,
|
||||
cost: (140 + responseText.length) * 0.00015,
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 140,
|
||||
completion_tokens: responseText.length,
|
||||
total_tokens: 140 + responseText.length,
|
||||
cost: (140 + responseText.length) * 0.00015,
|
||||
},
|
||||
}
|
||||
return sendJson(response)
|
||||
}
|
||||
return sendJson(response)
|
||||
}
|
||||
|
||||
// Generation details endpoint
|
||||
|
||||
@@ -227,7 +227,9 @@ setTimeout(() => {
|
||||
|
||||
describe("Cancellable Hooks", () => {
|
||||
it("should support user cancellation for cancellable hooks", async function () {
|
||||
this.timeout(5000)
|
||||
this.timeout(isWindows ? 10000 : 5000)
|
||||
const hookDelayMs = isWindows ? 1500 : 500
|
||||
const abortDelayMs = isWindows ? 300 : 50
|
||||
|
||||
// Create a hook that takes some time to execute
|
||||
await createHookScript(
|
||||
@@ -236,7 +238,7 @@ setTimeout(() => {
|
||||
cancel: false,
|
||||
},
|
||||
0,
|
||||
2000, // 2 second delay
|
||||
hookDelayMs,
|
||||
)
|
||||
|
||||
let capturedAbortController: AbortController | null = null
|
||||
@@ -259,10 +261,11 @@ setTimeout(() => {
|
||||
setActiveHookExecution: async (execution) => {
|
||||
setHookCalled = true
|
||||
capturedAbortController = execution.abortController
|
||||
// Abort after capturing the controller
|
||||
// Give the spawned hook process enough time to become fully active,
|
||||
// especially on slower Windows/PowerShell CI runners, before aborting.
|
||||
setTimeout(() => {
|
||||
capturedAbortController?.abort()
|
||||
}, 100)
|
||||
}, abortDelayMs)
|
||||
},
|
||||
clearActiveHookExecution: async () => {
|
||||
clearHookCalled = true
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import should from "should"
|
||||
import { registerTaskUiDeltaCallback } from "../core/controller/ui/subscribeToTaskUiDeltas"
|
||||
import { MessageStateHandler } from "../core/task/message-state"
|
||||
import { TaskState } from "../core/task/TaskState"
|
||||
import { ClineMessage } from "../shared/ExtensionMessage"
|
||||
@@ -265,71 +264,4 @@ describe("MessageStateHandler Mutex Protection", () => {
|
||||
finalHistory[0].content.should.equal("new1")
|
||||
finalHistory[1].content.should.equal("new2")
|
||||
})
|
||||
|
||||
it("publishes message_added deltas with monotonically increasing sequence numbers", async () => {
|
||||
const handler = createTestHandler()
|
||||
const received: Array<{ type: string; sequence: number; text?: string }> = []
|
||||
const unsubscribe = registerTaskUiDeltaCallback((delta) => {
|
||||
received.push({
|
||||
type: delta.type,
|
||||
sequence: delta.sequence,
|
||||
text: "message" in delta ? delta.message.text : undefined,
|
||||
})
|
||||
})
|
||||
|
||||
await handler.addToClineMessagesEphemeral(createTestMessage("first"))
|
||||
await handler.addToClineMessagesEphemeral(createTestMessage("second"))
|
||||
|
||||
received.should.deepEqual([
|
||||
{ type: "message_added", sequence: 1, text: "first" },
|
||||
{ type: "message_added", sequence: 2, text: "second" },
|
||||
])
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it("publishes message_updated and message_deleted deltas with correct sequence numbers", async () => {
|
||||
const handler = createTestHandler()
|
||||
const received: Array<{ type: string; sequence: number; text?: string; messageTs?: number }> = []
|
||||
const unsubscribe = registerTaskUiDeltaCallback((delta) => {
|
||||
received.push({
|
||||
type: delta.type,
|
||||
sequence: delta.sequence,
|
||||
text: "message" in delta ? delta.message.text : undefined,
|
||||
messageTs: "messageTs" in delta ? delta.messageTs : undefined,
|
||||
})
|
||||
})
|
||||
|
||||
const message = createTestMessage("original")
|
||||
await handler.addToClineMessagesEphemeral(message)
|
||||
await handler.updateClineMessageEphemeral(0, { text: "updated" })
|
||||
await handler.deleteClineMessage(0).catch(() => {
|
||||
// deleteClineMessage persists to disk; ignore persistence failures in this unit test.
|
||||
})
|
||||
|
||||
received[0]?.type.should.equal("message_added")
|
||||
received[1]?.should.deepEqual({
|
||||
type: "message_updated",
|
||||
sequence: 2,
|
||||
text: "updated",
|
||||
messageTs: undefined,
|
||||
})
|
||||
received[2]?.type.should.equal("message_deleted")
|
||||
received[2]?.sequence.should.equal(3)
|
||||
should.exist(received[2]?.messageTs)
|
||||
received[2]!.messageTs!.should.equal(message.ts)
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it("publishes task_state_resynced when the full message state is replaced", async () => {
|
||||
const handler = createTestHandler()
|
||||
const received: Array<{ type: string; sequence: number }> = []
|
||||
const unsubscribe = registerTaskUiDeltaCallback((delta) => {
|
||||
received.push({ type: delta.type, sequence: delta.sequence })
|
||||
})
|
||||
|
||||
handler.setClineMessages([createTestMessage("replacement")])
|
||||
|
||||
received.should.deepEqual([{ type: "task_state_resynced", sequence: 1 }])
|
||||
unsubscribe()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,6 +53,7 @@ import { CommandOutputContent, CommandOutputRow } from "./CommandOutputRow"
|
||||
import { CompletionOutputRow } from "./CompletionOutputRow"
|
||||
import { DiffEditRow } from "./DiffEditRow"
|
||||
import ErrorRow from "./ErrorRow"
|
||||
import { FeatureTip } from "./FeatureTip"
|
||||
import HookMessage from "./HookMessage"
|
||||
import { MarkdownRow } from "./MarkdownRow"
|
||||
import NewTaskPreview from "./NewTaskPreview"
|
||||
@@ -888,17 +889,22 @@ export const ChatRowContent = memo(
|
||||
case "reasoning": {
|
||||
const isReasoningStreaming = message.partial === true
|
||||
const hasReasoningText = !!message.text?.trim()
|
||||
// Show feature tips throughout the entire thinking/reasoning phase
|
||||
const showFeatureTip = isReasoningStreaming
|
||||
return (
|
||||
<ThinkingRow
|
||||
isExpanded={(isReasoningStreaming && hasReasoningText) || isExpanded}
|
||||
isStreaming={isReasoningStreaming}
|
||||
isVisible={true}
|
||||
onToggle={isReasoningStreaming ? undefined : handleToggle}
|
||||
reasoningContent={message.text}
|
||||
showChevron={!isReasoningStreaming || hasReasoningText}
|
||||
showTitle={true}
|
||||
title={isReasoningStreaming ? "Thinking..." : "Thinking"}
|
||||
/>
|
||||
<div>
|
||||
<ThinkingRow
|
||||
isExpanded={(isReasoningStreaming && hasReasoningText) || isExpanded}
|
||||
isStreaming={isReasoningStreaming}
|
||||
isVisible={true}
|
||||
onToggle={isReasoningStreaming ? undefined : handleToggle}
|
||||
reasoningContent={message.text}
|
||||
showChevron={!isReasoningStreaming || hasReasoningText}
|
||||
showTitle={true}
|
||||
title={isReasoningStreaming ? "Thinking..." : "Thinking"}
|
||||
/>
|
||||
{isReasoningStreaming && <FeatureTip />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case "user_feedback":
|
||||
|
||||
@@ -115,7 +115,7 @@ describe("ErrorRow", () => {
|
||||
expect(screen.getByText("Request ID: req_123456")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders auth error with sign in button when user is not signed in", async () => {
|
||||
it("renders friendly logged-out message and sign in button when user is not signed in", async () => {
|
||||
const mockClineError = {
|
||||
message: "Authentication failed",
|
||||
isErrorType: vi.fn((type) => type === "auth"),
|
||||
@@ -128,7 +128,8 @@ describe("ErrorRow", () => {
|
||||
|
||||
render(<ErrorRow apiRequestFailedMessage="Authentication failed" errorType="error" message={mockMessage} />)
|
||||
|
||||
expect(screen.getByText("Authentication failed")).toBeInTheDocument()
|
||||
expect(screen.queryByText("Authentication failed")).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/Whoops looks like you're logged out/)).toBeInTheDocument()
|
||||
expect(screen.getByText("Sign in to Cline")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -56,6 +56,30 @@ const ErrorRow = memo(({ message, errorType, apiRequestFailedMessage, apiReqStre
|
||||
)
|
||||
}
|
||||
|
||||
if (clineError?.isErrorType(ClineErrorType.Auth) && isClineProvider) {
|
||||
return !clineUser ? (
|
||||
// User is using Cline provider and is not logged in
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-center rounded border border-neutral-500/30 bg-vscode-editor-background p-6 text-center text-vscode-foreground">
|
||||
Whoops looks like you're logged out – click below to sign in
|
||||
</div>
|
||||
<Button className="w-full" disabled={isLoginLoading} onClick={handleSignIn}>
|
||||
Sign in to Cline
|
||||
{isLoginLoading && (
|
||||
<span className="ml-1 animate-spin">
|
||||
<span className="codicon codicon-refresh" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
// Don't show sign in button after the user has logged in, just ask them to retry
|
||||
<div className="mt-4">
|
||||
<span className="text-description">(Click "Retry" below)</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="m-0 whitespace-pre-wrap text-error wrap-anywhere flex flex-col gap-3">
|
||||
{/* Display the well-formatted error extracted from the ClineError instance */}
|
||||
@@ -83,21 +107,8 @@ const ErrorRow = memo(({ message, errorType, apiRequestFailedMessage, apiReqStre
|
||||
{/* Display raw API error if different from parsed error message */}
|
||||
{errorMessage !== rawApiError && <div>{rawApiError}</div>}
|
||||
|
||||
{/* Display Login button for non-logged in users using the Cline provider */}
|
||||
<div>
|
||||
{/* The user is signed in or not using cline provider */}
|
||||
{isClineProvider && !clineUser ? (
|
||||
<Button className="w-full mb-4" disabled={isLoginLoading} onClick={handleSignIn}>
|
||||
Sign in to Cline
|
||||
{isLoginLoading && (
|
||||
<span className="ml-1 animate-spin">
|
||||
<span className="codicon codicon-refresh"></span>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="mb-4 text-description">(Click "Retry" below)</span>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<span className="text-description">(Click "Retry" below)</span>
|
||||
</div>
|
||||
</p>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { LightbulbIcon } from "lucide-react"
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface FeatureTipItem {
|
||||
text: string
|
||||
}
|
||||
|
||||
const FEATURE_TIPS: FeatureTipItem[] = [
|
||||
{
|
||||
text: 'Enable "Double-Check Completion" in settings to have Cline verify its work before finishing a task.',
|
||||
},
|
||||
{
|
||||
text: "Add a .clinerules file to your project root to give Cline project-specific instructions.",
|
||||
},
|
||||
{
|
||||
text: "Switch to Plan Mode to discuss and plan an approach before Cline takes action.",
|
||||
},
|
||||
{
|
||||
text: "Use @ in the chat input to add files, folders, or URLs as context for your task.",
|
||||
},
|
||||
{
|
||||
text: "Set up MCP Servers to give Cline access to external tools and APIs.",
|
||||
},
|
||||
{
|
||||
text: "Cline creates checkpoints after changes — you can always restore to a previous state.",
|
||||
},
|
||||
{
|
||||
text: "Use /compact to condense long conversations and free up context window space.",
|
||||
},
|
||||
{
|
||||
text: "Enable auto-approve for read-only tools like file reads to speed up exploration.",
|
||||
},
|
||||
{
|
||||
text: "Use the quote button to select text from Cline's response and reference it in your reply.",
|
||||
},
|
||||
{
|
||||
text: "You can drag and drop images into the chat to share screenshots with Cline.",
|
||||
},
|
||||
{
|
||||
text: "Cline can browse websites — ask it to test your local dev server in the browser.",
|
||||
},
|
||||
{
|
||||
text: "Use /reportbug to quickly file a GitHub issue with diagnostic context included.",
|
||||
},
|
||||
]
|
||||
|
||||
const SHOW_DELAY_MS = 2000
|
||||
const CYCLE_INTERVAL_MS = 8000
|
||||
const FADE_DURATION_MS = 300
|
||||
|
||||
/**
|
||||
* Shows rotating feature tips below the "Thinking..." indicator.
|
||||
* Appears after a brief delay and cycles through tips while Cline is thinking.
|
||||
*/
|
||||
export const FeatureTip = memo(() => {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [hasFadedIn, setHasFadedIn] = useState(false)
|
||||
const [isFading, setIsFading] = useState(false)
|
||||
const [tipIndex, setTipIndex] = useState(() => Math.floor(Math.random() * FEATURE_TIPS.length))
|
||||
const cycleTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const showTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const fadeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const currentTip = FEATURE_TIPS[tipIndex]
|
||||
|
||||
const advanceTip = useCallback(() => {
|
||||
setIsFading(true)
|
||||
fadeTimerRef.current = setTimeout(() => {
|
||||
setTipIndex((prev) => (prev + 1) % FEATURE_TIPS.length)
|
||||
setIsFading(false)
|
||||
}, FADE_DURATION_MS)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
showTimerRef.current = setTimeout(() => {
|
||||
setIsVisible(true)
|
||||
// Trigger fade-in on next frame so transition applies
|
||||
requestAnimationFrame(() => setHasFadedIn(true))
|
||||
cycleTimerRef.current = setInterval(advanceTip, CYCLE_INTERVAL_MS)
|
||||
}, SHOW_DELAY_MS)
|
||||
|
||||
return () => {
|
||||
if (showTimerRef.current) {
|
||||
clearTimeout(showTimerRef.current)
|
||||
}
|
||||
if (cycleTimerRef.current) {
|
||||
clearInterval(cycleTimerRef.current)
|
||||
}
|
||||
if (fadeTimerRef.current) {
|
||||
clearTimeout(fadeTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [advanceTip])
|
||||
|
||||
if (!isVisible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-1.5 mt-2 ml-1 transition-opacity duration-300",
|
||||
!hasFadedIn || isFading ? "opacity-0" : "opacity-100",
|
||||
)}>
|
||||
<LightbulbIcon className="size-3 text-description shrink-0 mt-[1px]" />
|
||||
<span className="text-xs text-description leading-relaxed">
|
||||
<span className="font-medium">Tip:</span> {currentTip.text}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
FeatureTip.displayName = "FeatureTip"
|
||||
@@ -451,19 +451,21 @@ const ClineRulesToggleModal: React.FC = () => {
|
||||
{isVisible && (
|
||||
<PopupModalContainer $arrowPosition={arrowPosition} $menuPosition={menuPosition}>
|
||||
{/* Fixed header section - tabs and description */}
|
||||
<div className="flex-shrink-0 px-2 pt-0">
|
||||
<div className="flex-shrink-0 px-3 pt-2">
|
||||
{/* Tabs container */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "10px",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "1px",
|
||||
borderBottom: "1px solid var(--vscode-panel-border)",
|
||||
flexWrap: "wrap",
|
||||
}}>
|
||||
<TabButton isActive={currentView === "rules"} onClick={() => setCurrentView("rules")}>
|
||||
Rules
|
||||
@@ -484,7 +486,7 @@ const ClineRulesToggleModal: React.FC = () => {
|
||||
|
||||
{/* Remote config banner */}
|
||||
{(currentView === "rules" && hasRemoteRules) || (currentView === "workflows" && hasRemoteWorkflows) ? (
|
||||
<div className="flex items-center gap-2 px-5 py-3 mb-4 bg-vscode-textBlockQuote-background border-l-[3px] border-vscode-textLink-foreground">
|
||||
<div className="flex items-center gap-2 px-3 py-3 mb-4 bg-vscode-textBlockQuote-background border-l-[3px] border-vscode-textLink-foreground">
|
||||
<i className="codicon codicon-lock text-sm" />
|
||||
<span className="text-base">
|
||||
{currentView === "rules"
|
||||
@@ -534,7 +536,7 @@ const ClineRulesToggleModal: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Scrollable content area */}
|
||||
<div className="flex-1 overflow-y-auto px-2 pb-3" style={{ minHeight: 0 }}>
|
||||
<div className="flex-1 overflow-y-auto px-3 pb-3" style={{ minHeight: 0 }}>
|
||||
{currentView === "rules" ? (
|
||||
<>
|
||||
{/* Remote Rules Section */}
|
||||
@@ -694,12 +696,12 @@ const ClineRulesToggleModal: React.FC = () => {
|
||||
{/* Hooks Tab */}
|
||||
{/* Windows warning banner */}
|
||||
{isWindows && (
|
||||
<div className="flex items-center gap-2 px-5 py-3 mb-4 bg-vscode-inputValidation-warningBackground border-l-[3px] border-vscode-inputValidation-warningBorder">
|
||||
<div className="flex items-center gap-2 px-3 py-3 mb-4 bg-vscode-inputValidation-warningBackground border-l-[3px] border-vscode-inputValidation-warningBorder">
|
||||
<i className="codicon codicon-warning text-sm" />
|
||||
<span className="text-base">
|
||||
Hook toggling is not yet supported on Windows in this foundation PR. Hooks can be created,
|
||||
edited, and deleted, and execute whenever the hook file exists. Coming next: JSON-backed
|
||||
hook enabled/disabled state across platforms.
|
||||
Hook toggling is not yet supported on Windows in this foundation PR. Hooks can be
|
||||
created, edited, and deleted, and execute whenever the hook file exists. Coming next:
|
||||
JSON-backed hook enabled/disabled state across platforms.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -831,11 +833,12 @@ const StyledTabButton = styled.button<{ isActive: boolean }>`
|
||||
border: none;
|
||||
border-bottom: 2px solid ${(props) => (props.isActive ? "var(--vscode-foreground)" : "transparent")};
|
||||
color: ${(props) => (props.isActive ? "var(--vscode-foreground)" : "var(--vscode-descriptionForeground)")};
|
||||
padding: 8px 16px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
margin-bottom: -1px;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: var(--vscode-foreground);
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { ExtensionStateContextProvider, useExtensionState } from "./ExtensionStateContext"
|
||||
|
||||
type StreamCallbacks<T> = {
|
||||
onResponse?: (value: T) => void
|
||||
onError?: (error: unknown) => void
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
const subscriptions = {
|
||||
state: undefined as StreamCallbacks<{ stateJson?: string }> | undefined,
|
||||
partial: undefined as StreamCallbacks<any> | undefined,
|
||||
delta: undefined as StreamCallbacks<{ deltaJson?: string }> | undefined,
|
||||
}
|
||||
|
||||
vi.mock("../services/grpc-client", () => ({
|
||||
StateServiceClient: {
|
||||
subscribeToState: (_request: unknown, callbacks: StreamCallbacks<{ stateJson?: string }>) => {
|
||||
subscriptions.state = callbacks
|
||||
return () => {
|
||||
subscriptions.state = undefined
|
||||
}
|
||||
},
|
||||
getLatestState: vi.fn().mockResolvedValue({
|
||||
stateJson: JSON.stringify({
|
||||
version: "resynced",
|
||||
clineMessages: [{ ts: 99, type: "say", say: "text", text: "resynced" }],
|
||||
currentTaskItem: { id: "task-1" },
|
||||
}),
|
||||
}),
|
||||
getAvailableTerminalProfiles: vi.fn().mockResolvedValue({ profiles: [] }),
|
||||
},
|
||||
UiServiceClient: {
|
||||
subscribeToMcpButtonClicked: vi.fn().mockReturnValue(() => {}),
|
||||
subscribeToHistoryButtonClicked: vi.fn().mockReturnValue(() => {}),
|
||||
subscribeToChatButtonClicked: vi.fn().mockReturnValue(() => {}),
|
||||
subscribeToAccountButtonClicked: vi.fn().mockReturnValue(() => {}),
|
||||
subscribeToSettingsButtonClicked: vi.fn().mockReturnValue(() => {}),
|
||||
subscribeToWorktreesButtonClicked: vi.fn().mockReturnValue(() => {}),
|
||||
subscribeToRelinquishControl: vi.fn().mockReturnValue(() => {}),
|
||||
subscribeToPartialMessage: (_request: unknown, callbacks: StreamCallbacks<any>) => {
|
||||
subscriptions.partial = callbacks
|
||||
return () => {
|
||||
subscriptions.partial = undefined
|
||||
}
|
||||
},
|
||||
subscribeToTaskUiDeltas: (_request: unknown, callbacks: StreamCallbacks<{ deltaJson?: string }>) => {
|
||||
subscriptions.delta = callbacks
|
||||
return () => {
|
||||
subscriptions.delta = undefined
|
||||
}
|
||||
},
|
||||
initializeWebview: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
McpServiceClient: {
|
||||
subscribeToMcpServers: vi.fn().mockReturnValue(() => {}),
|
||||
subscribeToMcpMarketplaceCatalog: vi.fn().mockReturnValue(() => {}),
|
||||
},
|
||||
ModelsServiceClient: {
|
||||
subscribeToOpenRouterModels: vi.fn().mockReturnValue(() => {}),
|
||||
subscribeToLiteLlmModels: vi.fn().mockReturnValue(() => {}),
|
||||
refreshOpenRouterModelsRpc: vi.fn().mockResolvedValue({ models: [] }),
|
||||
refreshVercelAiGatewayModelsRpc: vi.fn().mockResolvedValue({ models: [] }),
|
||||
refreshClineModelsRpc: vi.fn().mockResolvedValue({ models: [] }),
|
||||
refreshBasetenModelsRpc: vi.fn().mockResolvedValue({ models: [] }),
|
||||
refreshLiteLlmModelsRpc: vi.fn().mockResolvedValue({ models: [] }),
|
||||
refreshHicapModels: vi.fn().mockResolvedValue({ models: [] }),
|
||||
},
|
||||
FileServiceClient: {},
|
||||
}))
|
||||
|
||||
function ContextProbe() {
|
||||
const state = useExtensionState() as any
|
||||
return (
|
||||
<>
|
||||
<div data-testid="version">{state.version}</div>
|
||||
<div data-testid="message-count">{state.clineMessages.length}</div>
|
||||
<div data-testid="latest-message">{state.clineMessages.at(-1)?.text ?? ""}</div>
|
||||
<div data-testid="background-command">{String(state.backgroundCommandRunning)}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe("ExtensionStateContextProvider", () => {
|
||||
it("hydrates from full state and applies streaming task UI deltas", async () => {
|
||||
render(
|
||||
<ExtensionStateContextProvider>
|
||||
<ContextProbe />
|
||||
</ExtensionStateContextProvider>,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
subscriptions.state?.onResponse?.({
|
||||
stateJson: JSON.stringify({
|
||||
version: "initial",
|
||||
clineMessages: [],
|
||||
currentTaskItem: { id: "task-1" },
|
||||
backgroundCommandRunning: false,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("version").textContent).toBe("initial")
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
subscriptions.delta?.onResponse?.({
|
||||
deltaJson: JSON.stringify({
|
||||
type: "message_added",
|
||||
taskId: "task-1",
|
||||
sequence: 1,
|
||||
message: { ts: 1, type: "say", say: "text", text: "hello" },
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("message-count").textContent).toBe("1")
|
||||
expect(screen.getByTestId("latest-message").textContent).toBe("hello")
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
subscriptions.delta?.onResponse?.({
|
||||
deltaJson: JSON.stringify({
|
||||
type: "message_updated",
|
||||
taskId: "task-1",
|
||||
sequence: 2,
|
||||
message: { ts: 1, type: "say", say: "text", text: "hello world" },
|
||||
}),
|
||||
})
|
||||
subscriptions.delta?.onResponse?.({
|
||||
deltaJson: JSON.stringify({
|
||||
type: "task_metadata_updated",
|
||||
taskId: "task-1",
|
||||
sequence: 3,
|
||||
metadata: { backgroundCommandRunning: true, backgroundCommandTaskId: "task-1" },
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("latest-message").textContent).toBe("hello world")
|
||||
expect(screen.getByTestId("background-command").textContent).toBe("true")
|
||||
})
|
||||
})
|
||||
|
||||
it("requests a full-state resync when a delta sequence gap is detected", async () => {
|
||||
render(
|
||||
<ExtensionStateContextProvider>
|
||||
<ContextProbe />
|
||||
</ExtensionStateContextProvider>,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
subscriptions.state?.onResponse?.({
|
||||
stateJson: JSON.stringify({
|
||||
version: "initial",
|
||||
clineMessages: [],
|
||||
currentTaskItem: { id: "task-1" },
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
subscriptions.delta?.onResponse?.({
|
||||
deltaJson: JSON.stringify({
|
||||
type: "message_added",
|
||||
taskId: "task-1",
|
||||
sequence: 2,
|
||||
message: { ts: 2, type: "say", say: "text", text: "should trigger resync" },
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("version").textContent).toBe("resynced")
|
||||
expect(screen.getByTestId("latest-message").textContent).toBe("resynced")
|
||||
})
|
||||
})
|
||||
|
||||
it("resets delta sequencing when a full snapshot switches to a different task", async () => {
|
||||
render(
|
||||
<ExtensionStateContextProvider>
|
||||
<ContextProbe />
|
||||
</ExtensionStateContextProvider>,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
subscriptions.state?.onResponse?.({
|
||||
stateJson: JSON.stringify({
|
||||
version: "task-1-state",
|
||||
clineMessages: [],
|
||||
currentTaskItem: { id: "task-1" },
|
||||
}),
|
||||
})
|
||||
subscriptions.delta?.onResponse?.({
|
||||
deltaJson: JSON.stringify({
|
||||
type: "message_added",
|
||||
taskId: "task-1",
|
||||
sequence: 1,
|
||||
message: { ts: 1, type: "say", say: "text", text: "task one" },
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("latest-message").textContent).toBe("task one")
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
subscriptions.state?.onResponse?.({
|
||||
stateJson: JSON.stringify({
|
||||
version: "task-2-state",
|
||||
clineMessages: [],
|
||||
currentTaskItem: { id: "task-2" },
|
||||
}),
|
||||
})
|
||||
subscriptions.delta?.onResponse?.({
|
||||
deltaJson: JSON.stringify({
|
||||
type: "message_added",
|
||||
taskId: "task-2",
|
||||
sequence: 1,
|
||||
message: { ts: 2, type: "say", say: "text", text: "task two" },
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("version").textContent).toBe("task-2-state")
|
||||
expect(screen.getByTestId("latest-message").textContent).toBe("task two")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
|
||||
import { findLastIndex } from "@shared/array"
|
||||
import { DEFAULT_BROWSER_SETTINGS } from "@shared/BrowserSettings"
|
||||
import { DEFAULT_PLATFORM, type ExtensionState } from "@shared/ExtensionMessage"
|
||||
import { DEFAULT_FOCUS_CHAIN_SETTINGS } from "@shared/FocusChainSettings"
|
||||
@@ -25,14 +26,7 @@ import {
|
||||
} from "../../../src/shared/api"
|
||||
import { Environment } from "../../../src/shared/config-types"
|
||||
import type { McpMarketplaceCatalog, McpServer, McpViewTab } from "../../../src/shared/mcp"
|
||||
import type { TaskUiDelta } from "../../../src/shared/TaskUiDelta"
|
||||
import { McpServiceClient, ModelsServiceClient, StateServiceClient, UiServiceClient } from "../services/grpc-client"
|
||||
import { mergeExtensionStateSnapshot } from "./mergeExtensionState"
|
||||
import { mergePartialMessage } from "./mergePartialMessage"
|
||||
import { ensureDebugTaskUiCounters, incrementDebugTaskUiCounter } from "./taskUiDebugCounters"
|
||||
import { applyTaskUiDeltaToState } from "./taskUiDeltaState"
|
||||
|
||||
const IS_DEV = process.env.IS_DEV === '"true"'
|
||||
|
||||
export interface ExtensionStateContextType extends ExtensionState {
|
||||
didHydrateState: boolean
|
||||
@@ -325,7 +319,6 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
const [huggingFaceModels, setHuggingFaceModels] = useState<Record<string, ModelInfo>>({})
|
||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
|
||||
const [mcpMarketplaceCatalog, setMcpMarketplaceCatalog] = useState<McpMarketplaceCatalog>({ items: [] })
|
||||
const latestTaskUiDeltaSequenceRef = useRef<number>(0)
|
||||
|
||||
// References to store subscription cancellation functions
|
||||
const stateSubscriptionRef = useRef<(() => void) | null>(null)
|
||||
@@ -337,7 +330,6 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
const settingsButtonClickedSubscriptionRef = useRef<(() => void) | null>(null)
|
||||
const worktreesButtonClickedSubscriptionRef = useRef<(() => void) | null>(null)
|
||||
const partialMessageUnsubscribeRef = useRef<(() => void) | null>(null)
|
||||
const taskUiDeltaUnsubscribeRef = useRef<(() => void) | null>(null)
|
||||
const mcpMarketplaceUnsubscribeRef = useRef<(() => void) | null>(null)
|
||||
const openRouterModelsUnsubscribeRef = useRef<(() => void) | null>(null)
|
||||
const liteLlmModelsUnsubscribeRef = useRef<(() => void) | null>(null)
|
||||
@@ -356,24 +348,6 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
}, [])
|
||||
const mcpServersSubscriptionRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const resyncCurrentTaskState = useCallback(async () => {
|
||||
try {
|
||||
const latestState = await StateServiceClient.getLatestState(EmptyRequest.create({}))
|
||||
if (!latestState.stateJson) {
|
||||
return
|
||||
}
|
||||
|
||||
const stateData = JSON.parse(latestState.stateJson) as ExtensionState
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
...stateData,
|
||||
}))
|
||||
latestTaskUiDeltaSequenceRef.current = 0
|
||||
} catch (error) {
|
||||
console.error("Failed to resync extension state after task delta gap:", error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Subscribe to state updates and UI events using the gRPC streaming API
|
||||
useEffect(() => {
|
||||
// Set up state subscription
|
||||
@@ -381,14 +355,25 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
onResponse: (response) => {
|
||||
if (response.stateJson) {
|
||||
try {
|
||||
incrementDebugTaskUiCounter(
|
||||
IS_DEV,
|
||||
typeof window === "undefined" ? undefined : window,
|
||||
"fullStateApplications",
|
||||
)
|
||||
const stateData = JSON.parse(response.stateJson) as ExtensionState
|
||||
setState((prevState) => {
|
||||
const newState = mergeExtensionStateSnapshot(prevState, stateData)
|
||||
// Versioning logic for autoApprovalSettings
|
||||
const incomingVersion = stateData.autoApprovalSettings?.version ?? 1
|
||||
const currentVersion = prevState.autoApprovalSettings?.version ?? 1
|
||||
const shouldUpdateAutoApproval = incomingVersion > currentVersion
|
||||
// HACK: Preserve clineMessages if currentTaskItem is the same
|
||||
if (stateData.currentTaskItem?.id === prevState.currentTaskItem?.id) {
|
||||
stateData.clineMessages = stateData.clineMessages?.length
|
||||
? stateData.clineMessages
|
||||
: prevState.clineMessages
|
||||
}
|
||||
|
||||
const newState = {
|
||||
...stateData,
|
||||
autoApprovalSettings: shouldUpdateAutoApproval
|
||||
? stateData.autoApprovalSettings
|
||||
: prevState.autoApprovalSettings,
|
||||
}
|
||||
|
||||
// Update welcome screen state based on API configuration if welcome view not in progress
|
||||
if (!newState.welcomeViewCompleted && !showWelcome) {
|
||||
@@ -400,7 +385,6 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
}
|
||||
|
||||
setDidHydrateState(true)
|
||||
latestTaskUiDeltaSequenceRef.current = 0
|
||||
|
||||
return newState
|
||||
})
|
||||
@@ -528,12 +512,16 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
}
|
||||
|
||||
const partialMessage = convertProtoToClineMessage(protoMessage)
|
||||
incrementDebugTaskUiCounter(
|
||||
IS_DEV,
|
||||
typeof window === "undefined" ? undefined : window,
|
||||
"partialMessageApplications",
|
||||
)
|
||||
setState((prevState) => mergePartialMessage(prevState, partialMessage))
|
||||
setState((prevState) => {
|
||||
// worth noting it will never be possible for a more up-to-date message to be sent here or in normal messages post since the presentAssistantContent function uses lock
|
||||
const lastIndex = findLastIndex(prevState.clineMessages, (msg) => msg.ts === partialMessage.ts)
|
||||
if (lastIndex !== -1) {
|
||||
const newClineMessages = [...prevState.clineMessages]
|
||||
newClineMessages[lastIndex] = partialMessage
|
||||
return { ...prevState, clineMessages: newClineMessages }
|
||||
}
|
||||
return prevState
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to process partial message:", error, protoMessage)
|
||||
}
|
||||
@@ -546,47 +534,6 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
},
|
||||
})
|
||||
|
||||
taskUiDeltaUnsubscribeRef.current = UiServiceClient.subscribeToTaskUiDeltas(EmptyRequest.create({}), {
|
||||
onResponse: (response: { deltaJson?: string }) => {
|
||||
if (!response.deltaJson) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const delta = JSON.parse(response.deltaJson) as TaskUiDelta
|
||||
setState((prevState) => {
|
||||
const result = applyTaskUiDeltaToState(prevState, delta, latestTaskUiDeltaSequenceRef.current)
|
||||
const counters = ensureDebugTaskUiCounters(IS_DEV, typeof window === "undefined" ? undefined : window)
|
||||
latestTaskUiDeltaSequenceRef.current = result.nextSequence
|
||||
if (result.kind === "resync") {
|
||||
if (counters) {
|
||||
counters.taskUiDeltaResyncRequests += 1
|
||||
}
|
||||
void resyncCurrentTaskState()
|
||||
return prevState
|
||||
}
|
||||
if (result.kind === "ignored") {
|
||||
return prevState
|
||||
}
|
||||
if (counters) {
|
||||
counters.taskUiDeltaApplications += 1
|
||||
}
|
||||
|
||||
return result.state
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to process task UI delta:", error)
|
||||
}
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const typedError = error as Error
|
||||
console.error("Error in taskUiDelta subscription:", typedError)
|
||||
},
|
||||
onComplete: () => {
|
||||
console.log("taskUiDelta subscription completed")
|
||||
},
|
||||
})
|
||||
|
||||
// Subscribe to MCP marketplace catalog updates
|
||||
mcpMarketplaceUnsubscribeRef.current = McpServiceClient.subscribeToMcpMarketplaceCatalog(EmptyRequest.create({}), {
|
||||
onResponse: (catalog) => {
|
||||
@@ -713,10 +660,6 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
partialMessageUnsubscribeRef.current()
|
||||
partialMessageUnsubscribeRef.current = null
|
||||
}
|
||||
if (taskUiDeltaUnsubscribeRef.current) {
|
||||
taskUiDeltaUnsubscribeRef.current()
|
||||
taskUiDeltaUnsubscribeRef.current = null
|
||||
}
|
||||
if (mcpMarketplaceUnsubscribeRef.current) {
|
||||
mcpMarketplaceUnsubscribeRef.current()
|
||||
mcpMarketplaceUnsubscribeRef.current = null
|
||||
@@ -742,17 +685,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
mcpServersSubscriptionRef.current = null
|
||||
}
|
||||
}
|
||||
}, [
|
||||
closeMcpView,
|
||||
navigateToAccount,
|
||||
navigateToChat,
|
||||
navigateToHistory,
|
||||
navigateToMcp,
|
||||
navigateToSettings,
|
||||
navigateToWorktrees,
|
||||
resyncCurrentTaskState,
|
||||
showWelcome,
|
||||
])
|
||||
}, [])
|
||||
|
||||
const refreshOpenRouterModels = useCallback(() => {
|
||||
ModelsServiceClient.refreshOpenRouterModelsRpc(EmptyRequest.create({}))
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { ExtensionState } from "@shared/ExtensionMessage"
|
||||
import deepEqual from "fast-deep-equal"
|
||||
|
||||
export function mergeExtensionStateSnapshot(prevState: ExtensionState, incomingState: ExtensionState): ExtensionState {
|
||||
const incomingVersion = incomingState.autoApprovalSettings?.version ?? 1
|
||||
const currentVersion = prevState.autoApprovalSettings?.version ?? 1
|
||||
const shouldUpdateAutoApproval = incomingVersion > currentVersion
|
||||
|
||||
const nextClineMessages =
|
||||
incomingState.currentTaskItem?.id === prevState.currentTaskItem?.id
|
||||
? incomingState.clineMessages?.length
|
||||
? incomingState.clineMessages
|
||||
: prevState.clineMessages
|
||||
: incomingState.clineMessages
|
||||
|
||||
const newState = {
|
||||
...incomingState,
|
||||
clineMessages: nextClineMessages,
|
||||
autoApprovalSettings: shouldUpdateAutoApproval ? incomingState.autoApprovalSettings : prevState.autoApprovalSettings,
|
||||
}
|
||||
|
||||
return deepEqual(newState, prevState) ? prevState : newState
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { findLastIndex } from "@shared/array"
|
||||
import type { ClineMessage, ExtensionState } from "@shared/ExtensionMessage"
|
||||
import deepEqual from "fast-deep-equal"
|
||||
|
||||
export function mergePartialMessage(prevState: ExtensionState, partialMessage: ClineMessage): ExtensionState {
|
||||
const lastIndex = findLastIndex(prevState.clineMessages, (msg) => msg.ts === partialMessage.ts)
|
||||
if (lastIndex === -1) {
|
||||
return prevState
|
||||
}
|
||||
|
||||
if (deepEqual(prevState.clineMessages[lastIndex], partialMessage)) {
|
||||
return prevState
|
||||
}
|
||||
|
||||
const newClineMessages = [...prevState.clineMessages]
|
||||
newClineMessages[lastIndex] = partialMessage
|
||||
return { ...prevState, clineMessages: newClineMessages }
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { type DebugTaskUiCounters, ensureDebugTaskUiCounters, incrementDebugTaskUiCounter } from "./taskUiDebugCounters"
|
||||
|
||||
describe("taskUiDebugCounters", () => {
|
||||
it("returns undefined when debug mode is disabled or window is absent", () => {
|
||||
expect(ensureDebugTaskUiCounters(false, window)).toBeUndefined()
|
||||
expect(ensureDebugTaskUiCounters(true, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("initializes counters once and increments individual keys", () => {
|
||||
const targetWindow = window as Window & { __CLINE_DEBUG_TASK_UI_COUNTERS__?: DebugTaskUiCounters }
|
||||
delete targetWindow.__CLINE_DEBUG_TASK_UI_COUNTERS__
|
||||
|
||||
const counters = ensureDebugTaskUiCounters(true, targetWindow)
|
||||
expect(counters).toEqual({
|
||||
fullStateApplications: 0,
|
||||
partialMessageApplications: 0,
|
||||
taskUiDeltaApplications: 0,
|
||||
taskUiDeltaResyncRequests: 0,
|
||||
})
|
||||
|
||||
incrementDebugTaskUiCounter(true, targetWindow, "taskUiDeltaApplications")
|
||||
incrementDebugTaskUiCounter(true, targetWindow, "taskUiDeltaApplications")
|
||||
incrementDebugTaskUiCounter(true, targetWindow, "taskUiDeltaResyncRequests")
|
||||
|
||||
expect(targetWindow.__CLINE_DEBUG_TASK_UI_COUNTERS__).toEqual({
|
||||
fullStateApplications: 0,
|
||||
partialMessageApplications: 0,
|
||||
taskUiDeltaApplications: 2,
|
||||
taskUiDeltaResyncRequests: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
export type DebugTaskUiCounters = {
|
||||
fullStateApplications: number
|
||||
partialMessageApplications: number
|
||||
taskUiDeltaApplications: number
|
||||
taskUiDeltaResyncRequests: number
|
||||
}
|
||||
|
||||
export type DebugTaskUiCounterKey = keyof DebugTaskUiCounters
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__CLINE_DEBUG_TASK_UI_COUNTERS__?: DebugTaskUiCounters
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureDebugTaskUiCounters(isDev: boolean, targetWindow: Window | undefined): DebugTaskUiCounters | undefined {
|
||||
if (!isDev || !targetWindow) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
targetWindow.__CLINE_DEBUG_TASK_UI_COUNTERS__ ??= {
|
||||
fullStateApplications: 0,
|
||||
partialMessageApplications: 0,
|
||||
taskUiDeltaApplications: 0,
|
||||
taskUiDeltaResyncRequests: 0,
|
||||
}
|
||||
|
||||
return targetWindow.__CLINE_DEBUG_TASK_UI_COUNTERS__
|
||||
}
|
||||
|
||||
export function incrementDebugTaskUiCounter(
|
||||
isDev: boolean,
|
||||
targetWindow: Window | undefined,
|
||||
key: DebugTaskUiCounterKey,
|
||||
): DebugTaskUiCounters | undefined {
|
||||
const counters = ensureDebugTaskUiCounters(isDev, targetWindow)
|
||||
if (!counters) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
counters[key] += 1
|
||||
return counters
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import type { ExtensionState } from "../../../src/shared/ExtensionMessage"
|
||||
import type { TaskUiDelta } from "../../../src/shared/TaskUiDelta"
|
||||
import { applyTaskUiDeltaToState } from "./taskUiDeltaState"
|
||||
|
||||
const createState = (): ExtensionState =>
|
||||
({
|
||||
version: "test",
|
||||
clineMessages: [],
|
||||
taskHistory: [],
|
||||
shouldShowAnnouncement: false,
|
||||
autoApprovalSettings: { enabled: false, actions: {}, version: 1 },
|
||||
browserSettings: { viewport: "desktop", screencast: true },
|
||||
focusChainSettings: { enabled: false, reminderIntervalRequests: 5 },
|
||||
preferredLanguage: "English",
|
||||
mode: "act",
|
||||
platform: "macOS",
|
||||
environment: "production",
|
||||
telemetrySetting: "unset",
|
||||
distinctId: "distinct-id",
|
||||
planActSeparateModelsSetting: true,
|
||||
enableCheckpointsSetting: true,
|
||||
mcpDisplayMode: "sidebar",
|
||||
globalClineRulesToggles: {},
|
||||
localClineRulesToggles: {},
|
||||
localCursorRulesToggles: {},
|
||||
localWindsurfRulesToggles: {},
|
||||
localAgentsRulesToggles: {},
|
||||
localWorkflowToggles: {},
|
||||
globalWorkflowToggles: {},
|
||||
shellIntegrationTimeout: 4_000,
|
||||
terminalReuseEnabled: true,
|
||||
vscodeTerminalExecutionMode: "vscodeTerminal",
|
||||
terminalOutputLineLimit: 500,
|
||||
maxConsecutiveMistakes: 3,
|
||||
defaultTerminalProfile: "default",
|
||||
isNewUser: false,
|
||||
welcomeViewCompleted: true,
|
||||
strictPlanModeEnabled: false,
|
||||
yoloModeToggled: false,
|
||||
useAutoCondense: false,
|
||||
subagentsEnabled: false,
|
||||
clineWebToolsEnabled: { user: true, featureFlag: false },
|
||||
worktreesEnabled: { user: true, featureFlag: false },
|
||||
favoritedModelIds: [],
|
||||
lastDismissedInfoBannerVersion: 0,
|
||||
lastDismissedModelBannerVersion: 0,
|
||||
lastDismissedCliBannerVersion: 0,
|
||||
remoteConfigSettings: {},
|
||||
onboardingModels: undefined,
|
||||
backgroundCommandRunning: false,
|
||||
backgroundCommandTaskId: undefined,
|
||||
backgroundEditEnabled: false,
|
||||
doubleCheckCompletionEnabled: false,
|
||||
globalSkillsToggles: {},
|
||||
localSkillsToggles: {},
|
||||
mcpResponsesCollapsed: false,
|
||||
customPrompt: undefined,
|
||||
workspaceRoots: [],
|
||||
primaryRootIndex: 0,
|
||||
isMultiRootWorkspace: false,
|
||||
multiRootSetting: { user: false, featureFlag: false },
|
||||
hooksEnabled: false,
|
||||
nativeToolCallSetting: false,
|
||||
enableParallelToolCalling: false,
|
||||
currentTaskItem: {
|
||||
id: "task-1",
|
||||
ts: 1,
|
||||
task: "demo",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
cacheWrites: 0,
|
||||
cacheReads: 0,
|
||||
totalCost: 0,
|
||||
size: 0,
|
||||
cwdOnTaskInitialization: "/workspace",
|
||||
isFavorited: false,
|
||||
},
|
||||
}) as unknown as ExtensionState
|
||||
|
||||
const createDelta = (overrides: Partial<TaskUiDelta>): TaskUiDelta =>
|
||||
({
|
||||
type: "task_state_resynced",
|
||||
taskId: "task-1",
|
||||
sequence: 1,
|
||||
...overrides,
|
||||
}) as TaskUiDelta
|
||||
|
||||
describe("applyTaskUiDeltaToState", () => {
|
||||
it("applies added and updated message deltas", () => {
|
||||
const state = createState()
|
||||
const added = applyTaskUiDeltaToState(
|
||||
state,
|
||||
createDelta({
|
||||
type: "message_added",
|
||||
message: { ts: 10, type: "say", say: "text", text: "hello" },
|
||||
}),
|
||||
0,
|
||||
)
|
||||
|
||||
expect(added.kind).toBe("applied")
|
||||
if (added.kind !== "applied") {
|
||||
throw new Error("expected applied result")
|
||||
}
|
||||
expect(added.state.clineMessages).toHaveLength(1)
|
||||
|
||||
const updated = applyTaskUiDeltaToState(
|
||||
added.state,
|
||||
createDelta({
|
||||
sequence: 2,
|
||||
type: "message_updated",
|
||||
message: { ts: 10, type: "say", say: "text", text: "updated" },
|
||||
}),
|
||||
added.nextSequence,
|
||||
)
|
||||
|
||||
expect(updated.kind).toBe("applied")
|
||||
if (updated.kind !== "applied") {
|
||||
throw new Error("expected applied result")
|
||||
}
|
||||
expect(updated.state.clineMessages[0].text).toBe("updated")
|
||||
})
|
||||
|
||||
it("requests a resync when a sequence gap is detected", () => {
|
||||
const state = createState()
|
||||
const result = applyTaskUiDeltaToState(
|
||||
state,
|
||||
createDelta({
|
||||
sequence: 3,
|
||||
type: "message_added",
|
||||
message: { ts: 10, type: "say", say: "text", text: "hello" },
|
||||
}),
|
||||
1,
|
||||
)
|
||||
|
||||
expect(result).toEqual({ kind: "resync", nextSequence: 1 })
|
||||
})
|
||||
|
||||
it("requests a full snapshot resync when the backend emits a task_state_resynced delta", () => {
|
||||
const state = createState()
|
||||
state.clineMessages = [{ ts: 10, type: "say", say: "text", text: "stale local state" } as any]
|
||||
|
||||
const result = applyTaskUiDeltaToState(
|
||||
state,
|
||||
createDelta({
|
||||
sequence: 1,
|
||||
type: "task_state_resynced",
|
||||
}),
|
||||
0,
|
||||
)
|
||||
|
||||
expect(result).toEqual({ kind: "resync", nextSequence: 0 })
|
||||
})
|
||||
|
||||
it("ignores deltas for other tasks", () => {
|
||||
const state = createState()
|
||||
const result = applyTaskUiDeltaToState(
|
||||
state,
|
||||
createDelta({
|
||||
taskId: "task-2",
|
||||
type: "message_added",
|
||||
message: { ts: 10, type: "say", say: "text", text: "hello" },
|
||||
}),
|
||||
0,
|
||||
)
|
||||
|
||||
expect(result).toEqual({ kind: "ignored", nextSequence: 1 })
|
||||
})
|
||||
|
||||
it("applies task metadata deltas without replacing the message list", () => {
|
||||
const state = createState()
|
||||
state.clineMessages = [{ ts: 10, type: "say", say: "text", text: "hello" }]
|
||||
|
||||
const result = applyTaskUiDeltaToState(
|
||||
state,
|
||||
createDelta({
|
||||
type: "task_metadata_updated",
|
||||
metadata: {
|
||||
currentFocusChainChecklist: "- [x] done",
|
||||
backgroundCommandRunning: true,
|
||||
backgroundCommandTaskId: "task-1",
|
||||
},
|
||||
}),
|
||||
0,
|
||||
)
|
||||
|
||||
expect(result.kind).toBe("applied")
|
||||
if (result.kind !== "applied") {
|
||||
throw new Error("expected applied result")
|
||||
}
|
||||
|
||||
expect(result.state.currentFocusChainChecklist).toBe("- [x] done")
|
||||
expect(result.state.backgroundCommandRunning).toBe(true)
|
||||
expect(result.state.backgroundCommandTaskId).toBe("task-1")
|
||||
expect(result.state.clineMessages).toEqual(state.clineMessages)
|
||||
})
|
||||
|
||||
it("preserves state references when a metadata delta does not change values", () => {
|
||||
const state = createState()
|
||||
const result = applyTaskUiDeltaToState(
|
||||
state,
|
||||
createDelta({
|
||||
type: "task_metadata_updated",
|
||||
metadata: {
|
||||
backgroundCommandRunning: false,
|
||||
backgroundCommandTaskId: undefined,
|
||||
},
|
||||
}),
|
||||
0,
|
||||
)
|
||||
|
||||
expect(result.kind).toBe("applied")
|
||||
if (result.kind !== "applied") {
|
||||
throw new Error("expected applied result")
|
||||
}
|
||||
expect(result.state).toBe(state)
|
||||
})
|
||||
|
||||
it("preserves message array reference when an update delta is identical to existing content", () => {
|
||||
const state = createState()
|
||||
const existingMessage = { ts: 10, type: "say", say: "text", text: "hello" } as const
|
||||
state.clineMessages = [existingMessage as any]
|
||||
|
||||
const result = applyTaskUiDeltaToState(
|
||||
state,
|
||||
createDelta({
|
||||
type: "message_updated",
|
||||
message: { ...existingMessage },
|
||||
}),
|
||||
0,
|
||||
)
|
||||
|
||||
expect(result.kind).toBe("applied")
|
||||
if (result.kind !== "applied") {
|
||||
throw new Error("expected applied result")
|
||||
}
|
||||
expect(result.state).toBe(state)
|
||||
expect(result.state.clineMessages).toBe(state.clineMessages)
|
||||
})
|
||||
|
||||
it("preserves state references when a delete delta targets a missing message", () => {
|
||||
const state = createState()
|
||||
state.clineMessages = [{ ts: 10, type: "say", say: "text", text: "hello" } as any]
|
||||
|
||||
const result = applyTaskUiDeltaToState(
|
||||
state,
|
||||
createDelta({
|
||||
type: "message_deleted",
|
||||
messageTs: 999,
|
||||
}),
|
||||
0,
|
||||
)
|
||||
|
||||
expect(result.kind).toBe("applied")
|
||||
if (result.kind !== "applied") {
|
||||
throw new Error("expected applied result")
|
||||
}
|
||||
expect(result.state).toBe(state)
|
||||
expect(result.state.clineMessages).toBe(state.clineMessages)
|
||||
})
|
||||
|
||||
it("converges to the same final task state as an equivalent full snapshot", () => {
|
||||
const initialState = createState()
|
||||
|
||||
const deltas: TaskUiDelta[] = [
|
||||
createDelta({
|
||||
sequence: 1,
|
||||
type: "message_added",
|
||||
message: { ts: 10, type: "say", say: "text", text: "hello" },
|
||||
}),
|
||||
createDelta({
|
||||
sequence: 2,
|
||||
type: "message_added",
|
||||
message: { ts: 20, type: "say", say: "reasoning", text: "thinking", partial: true },
|
||||
}),
|
||||
createDelta({
|
||||
sequence: 3,
|
||||
type: "message_updated",
|
||||
message: { ts: 20, type: "say", say: "reasoning", text: "thinking complete", partial: false },
|
||||
}),
|
||||
createDelta({
|
||||
sequence: 4,
|
||||
type: "task_metadata_updated",
|
||||
metadata: {
|
||||
backgroundCommandRunning: true,
|
||||
backgroundCommandTaskId: "task-1",
|
||||
currentFocusChainChecklist: "- [x] streamed",
|
||||
},
|
||||
}),
|
||||
createDelta({
|
||||
sequence: 5,
|
||||
type: "message_deleted",
|
||||
messageTs: 10,
|
||||
}),
|
||||
]
|
||||
|
||||
let state = initialState
|
||||
let sequence = 0
|
||||
for (const delta of deltas) {
|
||||
const result = applyTaskUiDeltaToState(state, delta, sequence)
|
||||
expect(result.kind).toBe("applied")
|
||||
if (result.kind !== "applied") {
|
||||
throw new Error("expected applied result")
|
||||
}
|
||||
state = result.state
|
||||
sequence = result.nextSequence
|
||||
}
|
||||
|
||||
const expectedSnapshot: ExtensionState = {
|
||||
...createState(),
|
||||
clineMessages: [{ ts: 20, type: "say", say: "reasoning", text: "thinking complete", partial: false } as any],
|
||||
backgroundCommandRunning: true,
|
||||
backgroundCommandTaskId: "task-1",
|
||||
currentFocusChainChecklist: "- [x] streamed",
|
||||
}
|
||||
|
||||
expect(state.clineMessages).toEqual(expectedSnapshot.clineMessages)
|
||||
expect(state.backgroundCommandRunning).toBe(expectedSnapshot.backgroundCommandRunning)
|
||||
expect(state.backgroundCommandTaskId).toBe(expectedSnapshot.backgroundCommandTaskId)
|
||||
expect(state.currentFocusChainChecklist).toBe(expectedSnapshot.currentFocusChainChecklist)
|
||||
})
|
||||
|
||||
it("applies ordered delta events sequentially while advancing the cursor", () => {
|
||||
let state = createState()
|
||||
let sequence = 0
|
||||
|
||||
const orderedDeltas: TaskUiDelta[] = [
|
||||
createDelta({
|
||||
sequence: 1,
|
||||
type: "message_added",
|
||||
message: { ts: 100, type: "say", say: "text", text: "first" },
|
||||
}),
|
||||
createDelta({
|
||||
sequence: 2,
|
||||
type: "message_updated",
|
||||
message: { ts: 100, type: "say", say: "text", text: "first updated" },
|
||||
}),
|
||||
createDelta({
|
||||
sequence: 3,
|
||||
type: "task_metadata_updated",
|
||||
metadata: { backgroundCommandRunning: true },
|
||||
}),
|
||||
]
|
||||
|
||||
for (const delta of orderedDeltas) {
|
||||
const result = applyTaskUiDeltaToState(state, delta, sequence)
|
||||
expect(result.kind).toBe("applied")
|
||||
if (result.kind !== "applied") {
|
||||
throw new Error("expected applied result")
|
||||
}
|
||||
state = result.state
|
||||
sequence = result.nextSequence
|
||||
}
|
||||
|
||||
expect(sequence).toBe(3)
|
||||
expect(state.clineMessages).toEqual([{ ts: 100, type: "say", say: "text", text: "first updated" }])
|
||||
expect(state.backgroundCommandRunning).toBe(true)
|
||||
})
|
||||
|
||||
it("updates the active message row correctly under repeated deltas", () => {
|
||||
let state = createState()
|
||||
let sequence = 0
|
||||
|
||||
const deltas: TaskUiDelta[] = [
|
||||
createDelta({
|
||||
sequence: 1,
|
||||
type: "message_added",
|
||||
message: { ts: 500, type: "say", say: "text", text: "draft", partial: true },
|
||||
}),
|
||||
createDelta({
|
||||
sequence: 2,
|
||||
type: "message_updated",
|
||||
message: { ts: 500, type: "say", say: "text", text: "draft + more", partial: true },
|
||||
}),
|
||||
createDelta({
|
||||
sequence: 3,
|
||||
type: "message_updated",
|
||||
message: { ts: 500, type: "say", say: "text", text: "final", partial: false },
|
||||
}),
|
||||
]
|
||||
|
||||
for (const delta of deltas) {
|
||||
const result = applyTaskUiDeltaToState(state, delta, sequence)
|
||||
expect(result.kind).toBe("applied")
|
||||
if (result.kind !== "applied") {
|
||||
throw new Error("expected applied result")
|
||||
}
|
||||
state = result.state
|
||||
sequence = result.nextSequence
|
||||
}
|
||||
|
||||
expect(state.clineMessages).toHaveLength(1)
|
||||
expect(state.clineMessages[0]).toEqual({ ts: 500, type: "say", say: "text", text: "final", partial: false })
|
||||
})
|
||||
})
|
||||
@@ -1,97 +0,0 @@
|
||||
import { findLastIndex } from "@shared/array"
|
||||
import type { ExtensionState } from "@shared/ExtensionMessage"
|
||||
import type { TaskUiDelta } from "@shared/TaskUiDelta"
|
||||
import deepEqual from "fast-deep-equal"
|
||||
|
||||
export type TaskUiDeltaApplicationResult =
|
||||
| { kind: "ignored"; nextSequence: number }
|
||||
| { kind: "resync"; nextSequence: number }
|
||||
| { kind: "applied"; nextSequence: number; state: ExtensionState }
|
||||
|
||||
export function applyTaskUiDeltaToState(
|
||||
state: ExtensionState,
|
||||
delta: TaskUiDelta,
|
||||
latestSequence: number,
|
||||
): TaskUiDeltaApplicationResult {
|
||||
const expectedSequence = latestSequence + 1
|
||||
if (delta.sequence !== expectedSequence) {
|
||||
return { kind: "resync", nextSequence: latestSequence }
|
||||
}
|
||||
|
||||
if (delta.taskId !== state.currentTaskItem?.id) {
|
||||
return { kind: "ignored", nextSequence: delta.sequence }
|
||||
}
|
||||
|
||||
if (delta.type === "task_state_resynced") {
|
||||
return { kind: "resync", nextSequence: 0 }
|
||||
}
|
||||
|
||||
if (delta.type === "task_metadata_updated") {
|
||||
const metadataChanged = Object.entries(delta.metadata).some(([key, value]) => {
|
||||
return !deepEqual(state[key as keyof ExtensionState], value)
|
||||
})
|
||||
if (!metadataChanged) {
|
||||
return { kind: "applied", nextSequence: delta.sequence, state }
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "applied",
|
||||
nextSequence: delta.sequence,
|
||||
state: {
|
||||
...state,
|
||||
...delta.metadata,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (delta.type === "message_deleted") {
|
||||
const hasMessageToDelete = state.clineMessages.some((message) => message.ts === delta.messageTs)
|
||||
if (!hasMessageToDelete) {
|
||||
return {
|
||||
kind: "applied",
|
||||
nextSequence: delta.sequence,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "applied",
|
||||
nextSequence: delta.sequence,
|
||||
state: {
|
||||
...state,
|
||||
clineMessages: state.clineMessages.filter((message) => message.ts !== delta.messageTs),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const existingIndex = findLastIndex(state.clineMessages, (message) => message.ts === delta.message.ts)
|
||||
if (existingIndex === -1) {
|
||||
return {
|
||||
kind: "applied",
|
||||
nextSequence: delta.sequence,
|
||||
state: {
|
||||
...state,
|
||||
clineMessages: [...state.clineMessages, delta.message],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (deepEqual(state.clineMessages[existingIndex], delta.message)) {
|
||||
return {
|
||||
kind: "applied",
|
||||
nextSequence: delta.sequence,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
const clineMessages = [...state.clineMessages]
|
||||
clineMessages[existingIndex] = delta.message
|
||||
return {
|
||||
kind: "applied",
|
||||
nextSequence: delta.sequence,
|
||||
state: {
|
||||
...state,
|
||||
clineMessages,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user