Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
2d5849d
feat(transcript): implement dedicated transcript protocol for Zoo Cod…
Gh0st352 Aug 23, 2026
211cd17
Added Chat Output to readme
Gh0st352 Aug 23, 2026
7b098e0
feat: enhance transcript handling and synchronization in webview
Gh0st352 Aug 23, 2026
a33ee13
fix(pre-commit): comment out pnpm lint command
Gh0st352 Aug 23, 2026
86d6d2a
fix(pre-push): comment out check-types command in pre-push hook
Gh0st352 Aug 23, 2026
3739e0a
Delete apply_zoo_code_incremental_transcript_fix.py
Gh0st352 Aug 23, 2026
977eda3
Delete ZOO_CODE_GRAY_SCREEN_FIX_README.md
Gh0st352 Aug 23, 2026
55bb1e4
Uncomment check-types command in pre-push hook
Gh0st352 Aug 23, 2026
ea6e45e
Uncomment lint command in pre-commit hook
Gh0st352 Aug 23, 2026
af3b2a2
fix: address memory leak and improve transcript handling in ClineProv…
Gh0st352 Aug 24, 2026
f6438f0
fix: address transcript synchronization review findings
Gh0st352 Aug 24, 2026
2d90fa0
test: initialize transcript sequence state in provider stubs
Gh0st352 Aug 24, 2026
166c10d
test: exercise edited message submission
Gh0st352 Aug 24, 2026
e0a5507
test: verify transcript republish completion
Gh0st352 Aug 24, 2026
6efcf32
fix(webview): clear focused task without reload
Gh0st352 Aug 24, 2026
c225a9a
fix: address transcript streaming review feedback
Gh0st352 Aug 27, 2026
8515378
test: align state ordering regression with transcript transport
Gh0st352 Sep 1, 2026
35919ea
test: cover transcript transport mutation gaps
Gh0st352 Sep 4, 2026
177e005
test: cover transcript transport mutation edges
Gh0st352 Sep 4, 2026
ce7e537
test: address transcript review feedback
Gh0st352 Sep 4, 2026
dd7fa30
fix: expire incomplete transcript snapshots
Gh0st352 Sep 4, 2026
deb6363
test: cover transcript snapshot timeout mutations
Gh0st352 Sep 4, 2026
9ef5553
fix: address transcript transport review feedback
Gh0st352 Sep 5, 2026
7dab26d
fix(task): await transcript snapshots after overwrite persistence
Gh0st352 Sep 6, 2026
2184ed5
fix(task): synchronize transcript snapshots on overwrite and resume
Gh0st352 Sep 6, 2026
8b3065b
test(task): assert readiness before pending action replay
Gh0st352 Sep 6, 2026
7972427
fix(tests): await theme transitions before visual assertions
Gh0st352 Sep 7, 2026
dfc7e1e
test(webview): assert injected animation by target and identity
Gh0st352 Sep 7, 2026
0cd3bf3
fix(webview): capture transcript snapshots before queueing
Gh0st352 Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ export interface ExtensionMessage {
| "theme"
| "workspaceUpdated"
| "invoke"
| "messageUpdated"
| "clineMessageAppended"
| "clineMessageUpdated"
| "clineMessagesSnapshotStart"
| "clineMessagesSnapshotChunk"
| "clineMessagesSnapshotEnd"
| "messageUpdated" // Legacy: a patched webview requests a full resync instead of applying this.
| "mcpServers"
| "enhancedPrompt"
| "commitSearchResults"
Expand Down Expand Up @@ -138,7 +143,13 @@ export interface ExtensionMessage {
isActive: boolean
path?: string
}>
taskId?: string
clineMessage?: ClineMessage
clineMessages?: ClineMessage[]
clineMessagesSeq?: number
snapshotId?: string
snapshotStartIndex?: number
snapshotTotal?: number
routerModels?: RouterModels
openAiModels?: string[]
ollamaModels?: ModelRecord
Expand Down Expand Up @@ -334,7 +345,11 @@ export type ExtensionState = Pick<
lockApiConfigAcrossModes?: boolean
version: string
clineMessages: ClineMessage[]
currentTaskId?: string
/**
* Focused task identity. Omitted means this partial state update does not
* change task focus; null authoritatively means no task is focused.
*/
currentTaskId?: string | null
currentTaskItem?: HistoryItem
currentTaskTodos?: TodoItem[] // Initial todos for the current task
apiConfiguration: ProviderSettings
Expand Down Expand Up @@ -426,10 +441,9 @@ export type ExtensionState = Pick<
arch?: string

/**
* Monotonically increasing sequence number for clineMessages state pushes.
* When present, the frontend should only apply clineMessages from a state push
* if its seq is greater than the last applied seq. This prevents stale state
* (captured during async getStateToPostToWebview) from overwriting newer messages.
* Last sequence applied by the dedicated task-scoped transcript transport.
* Generic `state` messages intentionally omit this field and `clineMessages`;
* snapshots and append/update messages carry both transcript data and sequence.
*/
clineMessagesSeq?: number
}
Expand Down Expand Up @@ -646,8 +660,11 @@ export interface WebviewMessage {
| "openRuleFile"
| "openRulesDirectory"
| "themeFixtureProbeResponse"
| "requestClineMessagesResync"
text?: string
taskId?: string
expectedSeq?: number
receivedSeq?: number
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
disabled?: boolean
Expand Down
4 changes: 4 additions & 0 deletions src/__tests__/helpers/provider-stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { type Task } from "../../core/task/Task"
type ProviderStubFields = {
delegationTransitionLocks?: Map<string, Promise<void>>
cancelledDelegationChildIds?: Set<string>
clineMessagesSeqByTaskId?: Map<string, number>
log?: ReturnType<typeof vi.fn>
syncFocusedTaskToWebview?: ReturnType<typeof vi.fn>
taskHistoryStore?: { get: (id: string) => unknown }
taskRegistry?: TaskRegistry
clineStack?: Task[]
Expand Down Expand Up @@ -36,7 +38,9 @@ export function makeProviderStub<T extends object>(stub: T): ClineProvider {
const proto = ClineProvider.prototype as unknown as PrivateProviderMethods
s.delegationTransitionLocks ??= new Map()
s.cancelledDelegationChildIds ??= new Set()
s.clineMessagesSeqByTaskId ??= new Map()
s.log ??= vi.fn()
s.syncFocusedTaskToWebview ??= vi.fn().mockResolvedValue(undefined)
s.taskHistoryStore ??= { get: () => undefined }

// Convert legacy clineStack array into a TaskRegistry
Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/single-open-invariant.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ describe("Single-open-task invariant", () => {
taskScheduler: { schedule: schedulespy },
taskEventListeners: new WeakMap(),
performPreparationTasks: vi.fn().mockResolvedValue(undefined),
syncFocusedTaskToWebview: vi.fn().mockResolvedValue(undefined),
context: { extension: { packageJSON: {} }, globalStorageUri: { fsPath: "/tmp" } },
contextProxy: {
extensionUri: {},
Expand Down Expand Up @@ -341,6 +342,7 @@ describe("Single-open-task invariant", () => {
taskScheduler: { schedule: schedulespy },
taskEventListeners: new WeakMap(),
performPreparationTasks: vi.fn().mockResolvedValue(undefined),
syncFocusedTaskToWebview: vi.fn().mockResolvedValue(undefined),
context: { extension: { packageJSON: {} }, globalStorageUri: { fsPath: "/tmp" } },
contextProxy: {
extensionUri: {},
Expand Down
77 changes: 50 additions & 27 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ function queuedResponseForAsk(type: ClineAsk, text?: string): QueuedAskResolutio

const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors
const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors
const PARTIAL_MESSAGE_UPDATE_DEBOUNCE_MS = 500

export interface TaskOptions extends CreateTaskOptions {
provider: ClineProvider
Expand Down Expand Up @@ -492,6 +493,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Token Usage Throttling - Debounced emit function
private readonly TOKEN_USAGE_EMIT_INTERVAL_MS = 2000 // 2 seconds
private debouncedEmitTokenUsage: ReturnType<typeof debounce>
private debouncedPostPartialMessageUpdate: ReturnType<typeof debounce>

// Historical cloud sync tracking retained only to avoid task resume churn.
private cloudSyncedMessageTimestamps: Set<number> = new Set()
Expand Down Expand Up @@ -656,6 +658,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.TOKEN_USAGE_EMIT_INTERVAL_MS,
{ leading: true, trailing: true, maxWait: this.TOKEN_USAGE_EMIT_INTERVAL_MS },
)
this.debouncedPostPartialMessageUpdate = debounce((message: ClineMessage) => {
const provider = this.providerRef.deref()
if (!provider) {
return
}

void provider.postClineMessageUpdated(this.taskId, message).catch((error) => {
console.error("[Task#updateClineMessage] incremental post failed:", error)
})
}, PARTIAL_MESSAGE_UPDATE_DEBOUNCE_MS)

onCreated?.(this)

Expand Down Expand Up @@ -1262,20 +1274,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
message.messageId ??= crypto.randomUUID()
this.clineMessages.push(message)
const provider = this.providerRef.deref()
// Unanswered asks must reach the webview before Message listeners can respond against its state.
const requiresImmediateState =
message.partial === true || (message.type === "ask" && message.isAnswered !== true)
try {
await provider?.postStateToWebviewThrottled()
await provider?.postClineMessageAppended(this.taskId, message)
} catch (error) {
console.error("[Task#addToClineMessages] postStateToWebviewThrottled failed:", error)
}
if (requiresImmediateState) {
try {
await provider?.flushPostStateToWebviewThrottled()
} catch (error) {
console.error("[Task#addToClineMessages] flushPostStateToWebviewThrottled failed:", error)
}
console.error("[Task#addToClineMessages] incremental post failed:", error)
}
this.emit(RooCodeEventName.Message, { action: "created", message })
await this.saveClineMessages()
Expand All @@ -1297,10 +1299,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
* Also resets cloud sync tracking to avoid re-syncing previously synced messages.
*/
public async overwriteClineMessages(newMessages: ClineMessage[], persist = true) {
this.debouncedPostPartialMessageUpdate.cancel()
this.hydrateClineMessages(newMessages)
if (persist) {
await this.saveClineMessages(false)
}
await this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { bumpSeq: true })
Comment thread
Gh0st352 marked this conversation as resolved.
}

private hydrateClineMessages(messages: ClineMessage[]) {
Expand All @@ -1326,8 +1330,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
* Non-partial messages are synced to cloud telemetry if not already synced.
*/
private async updateClineMessage(message: ClineMessage) {
const provider = this.providerRef.deref()
await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message })
if (message.partial === true) {
this.debouncedPostPartialMessageUpdate(message)
} else {
this.debouncedPostPartialMessageUpdate.cancel()
await this.providerRef.deref()?.postClineMessageUpdated(this.taskId, message)
}
this.emit(RooCodeEventName.Message, { action: "updated", message })
Comment thread
Gh0st352 marked this conversation as resolved.

// Check if we should sync to cloud and haven't already synced this message
Expand Down Expand Up @@ -1422,7 +1430,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

let askTs: number

// Resolve auto-approval before adding the message so the state snapshot
// Resolve auto-approval before adding the message so the incremental append
// sent to the webview already carries isAnswered:true when the ask will
// be immediately resolved. This eliminates the race between the state
// update (which shows approval buttons) and the former separate
Expand Down Expand Up @@ -1456,10 +1464,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
lastMessage.partial = partial
lastMessage.progressStatus = progressStatus
lastMessage.isProtected = isProtected
// TODO: Be more efficient about saving and posting only new
// data or one whole message at a time so ignore partial for
// saves, and only post parts of partial message instead of
// whole array in new listener.
// Persist partial messages only when they become complete; the
// dedicated transport can still update one in-memory message at a time.
// Fire-and-forget: the webview post is internally guarded, but
// the `RooCodeEventName.Message` emit can synchronously throw
// if any consumer-attached listener does, which would surface
Expand Down Expand Up @@ -1712,6 +1718,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
if (lastFollowUpIndex !== -1) {
// Mark this follow-up as answered
this.clineMessages[lastFollowUpIndex].isAnswered = true
void this.updateClineMessage(this.clineMessages[lastFollowUpIndex]).catch((error) => {
console.error("[Task#handleWebviewAskResponse] follow-up delta failed:", error)
})
// Save the updated messages
this.saveClineMessages().catch((error) => {
console.error("Failed to save answered follow-up state:", error)
Expand Down Expand Up @@ -2187,7 +2196,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// The todo list is already set in the constructor if initialTodos were provided
// No need to add any messages - the todoList property is already set

await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory()
await this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { bumpSeq: true })

await this.say("text", task, images)

Expand Down Expand Up @@ -2332,16 +2341,23 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
await this.clearPendingActionAfterDurableResult(this.pendingAction.actionId)
}

if (this.pendingAction) {
this.isInitialized = true
await this.resumePendingTaskAction(this.pendingAction)
if (this.abort || this.abandoned) {
return
}

// Publish the transcript after both histories hydrate, before any resume prompt or pending-action replay.
await this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { bumpSeq: true })

if (this.abort || this.abandoned) {
return
}

if (this.pendingAction) {
this.isInitialized = true
await this.resumePendingTaskAction(this.pendingAction)
return
}
Comment thread
Gh0st352 marked this conversation as resolved.

const lastClineMessage = this.clineMessages
.slice()
.reverse()
Expand All @@ -2356,7 +2372,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

this.isInitialized = true

const { response, text, images } = await this.ask(askType) // Calls `postStateToWebview`.
const { response, text, images } = await this.ask(askType)

let responseText: string | undefined
let responseImages: string[] | undefined
Expand Down Expand Up @@ -2687,6 +2703,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
private async disposeOnce(): Promise<void> {
console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`)
this.cancelAssistantMessagePersistence()
this.debouncedPostPartialMessageUpdate.cancel()

// Stop the idle telemetry check and report any unflushed activity as a
// shutdown installment, so a task torn down mid-work (panel closed, task
Expand Down Expand Up @@ -3071,7 +3088,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
} satisfies ClineApiReqInfo)

await this.saveClineMessages()
await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory()
const apiRequestMessage = this.clineMessages[lastApiReqIndex]
if (apiRequestMessage) {
await this.updateClineMessage(apiRequestMessage)
}

try {
let cacheWriteTokens = 0
Expand Down Expand Up @@ -3142,12 +3162,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
if (lastMessage && lastMessage.partial) {
// lastMessage.ts = Date.now() DO NOT update ts since it is used as a key for virtuoso list
lastMessage.partial = false
// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
await this.updateClineMessage(lastMessage)
}

// Update `api_req_started` to have cancelled and cost, so that
// we can display the cost of the partial stream and the cancellation reason
updateApiReqMsg(cancelReason, streamingFailedMessage)
const apiRequestMessage = this.clineMessages[lastApiReqIndex]
if (apiRequestMessage) {
await this.updateClineMessage(apiRequestMessage)
}
await this.saveClineMessages()

// Signals to provider that it can retrieve the saved messages
Expand Down Expand Up @@ -3789,7 +3813,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}

await this.saveClineMessages()
await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory()

// No legacy text-stream tool parser state to reset.

Expand Down
Loading
Loading