Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ version with its date and start a fresh empty `[Unreleased]` above it.
- The current-note chip above the composer is gone. Use the "Excluded tags"
setting to keep a note out of context.

### Fixed

- A turn that launched background subagents could refuse to finish: it was
repeatedly told to wait on a tool it had never been given, so the reply
looped until interrupted. Background results now reach the model on their
own and the turn ends normally.

- A finished background subagent no longer stays stuck on "Running", and is
no longer reported as abandoned when switching conversations.

## [1.0.16] - 2026-09-29

### Changed
Expand Down
2 changes: 0 additions & 2 deletions src/core/runtime/chat-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import type {
ExitPlanModeCallback,
PreparedChatTurn,
SessionUpdateResult,
SubagentRuntimeState,
} from './types';

export interface ChatRuntime {
Expand Down Expand Up @@ -50,7 +49,6 @@ export interface ChatRuntime {
setAskUserQuestionCallback(callback: AskUserQuestionCallback | null): void;
setExitPlanModeCallback(callback: ExitPlanModeCallback | null): void;
setPermissionModeSyncCallback(callback: ((sdkMode: string) => void) | null): void;
setSubagentStateSource(getState: () => SubagentRuntimeState): void;
setAutoTurnCallback(callback: AutoTurnCallback | null): void;
consumeTurnMetadata(): ChatTurnMetadata;

Expand Down
4 changes: 0 additions & 4 deletions src/core/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,6 @@ export interface ChatRewindResult {

export type ChatRewindMode = 'conversation' | 'code-and-conversation';

export interface SubagentRuntimeState {
hasRunning: boolean;
}

export interface ChatTurnMetadata {
userMessageId?: string;
assistantMessageId?: string;
Expand Down
10 changes: 9 additions & 1 deletion src/core/types/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,15 @@ export type StreamChunk =
| { type: 'done' }
| { type: 'usage'; usage: UsageInfo; sessionId?: string | null }
| { type: 'context_compacted' }
| { type: 'async_subagent_result'; agentId: string; status: 'completed' | 'error'; result?: string }
| {
type: 'async_subagent_result';
/** CLI task registry id — a different id space from the agentId in the Agent tool result. */
taskId: string;
/** The launching Agent tool_use id; the only reliable correlation key, but optional in the protocol. */
toolUseId?: string;
status: 'completed' | 'error';
result?: string;
}
| { type: 'subagent_tool_use'; subagentId: string; id: string; name: string; input: Record<string, unknown> }
| { type: 'subagent_tool_result'; subagentId: string; id: string; content: string; isError?: boolean; toolUseResult?: ToolUseResult };

Expand Down
3 changes: 2 additions & 1 deletion src/features/chat/controllers/subagent-stream-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,10 @@ export class SubagentStreamCoordinator {
chunk: Extract<StreamChunk, { type: 'async_subagent_result' }>,
): Promise<void> {
const handled = this.deps.subagentManager.handleAsyncSubagentResult(
chunk.agentId,
chunk.taskId,
chunk.status,
chunk.result,
chunk.toolUseId,
);
await this.hydrateAsyncSubagentToolCalls(handled);
if (handled) this.deps.showThinkingIndicator();
Expand Down
29 changes: 14 additions & 15 deletions src/features/chat/services/subagent-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,22 +437,30 @@ export class SubagentManager {
}

public handleAsyncSubagentResult(
agentId: string,
taskId: string,
status: 'completed' | 'error',
result?: string
result?: string,
toolUseId?: string
): SubagentInfo | undefined {
const subagent = this.activeAsyncSubagents.get(agentId);
if (!subagent || subagent.asyncStatus !== 'running') {
// The CLI's task_id comes from its own task registry and shares no id space
// with the agentId parsed out of the Agent tool result. The notification's
// tool_use_id is the launching Agent tool_use id, which is ours.
const subagent =
(toolUseId ? this.getByTaskId(toolUseId) : undefined) ??
this.activeAsyncSubagents.get(taskId);
if (!subagent || subagent.asyncStatus === 'completed' || subagent.asyncStatus === 'error') {
return undefined;
}

subagent.agentId = subagent.agentId || agentId;
const agentId = subagent.agentId;
subagent.asyncStatus = status;
subagent.status = status;
subagent.result = result?.trim() || (status === 'error' ? 'Background task failed.' : 'Background task completed.');
subagent.completedAt = Date.now();

this.activeAsyncSubagents.delete(agentId);
if (agentId) this.activeAsyncSubagents.delete(agentId);
this.pendingAsyncSubagents.delete(subagent.id);
this.taskIdToAgentId.delete(subagent.id);
for (const [toolId, mappedAgentId] of this.outputToolIdToAgentId.entries()) {
if (mappedAgentId === agentId) {
this.outputToolIdToAgentId.delete(toolId);
Expand Down Expand Up @@ -493,15 +501,6 @@ export class SubagentManager {
this.onStateChange(subagent);
}

// ============================================
// Hook State
// ============================================

public hasRunningSubagents(): boolean {
// pendingAsyncSubagents: awaiting agent_id; activeAsyncSubagents: only holds running entries
return this.pendingAsyncSubagents.size > 0 || this.activeAsyncSubagents.size > 0;
}

// ============================================
// Lifecycle
// ============================================
Expand Down
3 changes: 0 additions & 3 deletions src/features/chat/tabs/tab-runtime-callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,6 @@ export function setupServiceCallbacks(tab: TabData, plugin: QoderianPlugin): voi
}
return decision;
});
tab.service.setSubagentStateSource(() => ({
hasRunning: tab.services.subagentManager.hasRunningSubagents(),
}));
tab.service.setAutoTurnCallback((result: AutoTurnResult) => renderAutoTriggeredTurn(tab, result));
tab.service.setPermissionModeSyncCallback((sdkMode) => {
const mode = sdkMode === 'bypassPermissions' || sdkMode === 'yolo'
Expand Down
28 changes: 0 additions & 28 deletions src/qoder/runtime/qoder-chat-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ import {
buildPromptWithHistoryContext,
isSessionExpiredError,
} from './session-context';
import { createStopSubagentHook, type SubagentHookState } from './subagent-hooks';
import {
type ClosePersistentQueryOptions,
type PersistentQueryConfig,
Expand Down Expand Up @@ -171,9 +170,6 @@ export class QoderChatRuntime implements ChatRuntime {
// SDK command cache — populated on system/init, cleared on persistent query close
private cachedSdkCommands: SlashCommand[] = [];

// Subagent hook state source (set from the feature layer).
private subagentStateSource: (() => SubagentHookState) | null = null;

private readonly turnTracker = new QoderTurnTracker();

constructor(plugin: QoderHostContext, services: QoderRuntimeServices) {
Expand Down Expand Up @@ -613,7 +609,6 @@ export class QoderChatRuntime implements ChatRuntime {
externalContextPaths?: string[]
): Options {
const baseContext = this.buildQueryOptionsContext(vaultPath, cliPath);
const hooks = this.buildHooks();

const ctx: PersistentQueryContext = {
...baseContext,
Expand All @@ -622,7 +617,6 @@ export class QoderChatRuntime implements ChatRuntime {
? { sessionId: resumeSessionId, sessionAt: resumeAtMessageId, fork: this.pendingForkSession || undefined }
: undefined,
canUseTool: this.createApprovalCallback(),
hooks,
externalContextPaths,
};

Expand All @@ -633,22 +627,6 @@ export class QoderChatRuntime implements ChatRuntime {
return options;
}

/**
* Builds the hooks for SDK options.
* Hooks need access to `this` for dynamic settings, so they're built here.
*/
private buildHooks() {
const hooks: Options['hooks'] = {};

// Resolve feature state at hook execution time so a long-lived Query sees
// the latest subagent status.
hooks.Stop = [createStopSubagentHook(
() => this.subagentStateSource?.() ?? { hasRunning: false }
)];

return hooks;
}

/**
* Starts the background consumer loop that routes chunks to handlers.
*/
Expand Down Expand Up @@ -1100,7 +1078,6 @@ export class QoderChatRuntime implements ChatRuntime {
const queryPrompt = buildQoderPromptWithImages(prompt, images);
const baseContext = this.buildQueryOptionsContext(cwd, cliPath);
const externalContextPaths = queryOptions?.externalContextPaths || [];
const hooks = this.buildHooks();
const hasEditorContext = prompt.includes('<editor_selection');

let allowedTools: string[] | undefined;
Expand All @@ -1115,7 +1092,6 @@ export class QoderChatRuntime implements ChatRuntime {
sessionId: this.sessionManager.getSessionId() ?? undefined,
modelOverride: queryOptions?.model,
canUseTool: this.createApprovalCallback(),
hooks,
mcpMentions: queryOptions?.mcpMentions,
enabledMcpServers: queryOptions?.enabledMcpServers,
allowedTools,
Expand Down Expand Up @@ -1388,10 +1364,6 @@ export class QoderChatRuntime implements ChatRuntime {
this.permissionModeSyncCallback = callback;
}

setSubagentStateSource(getState: () => SubagentHookState): void {
this.subagentStateSource = getState;
}

setAutoTurnCallback(callback: AutoTurnCallback | null): void {
this.responseRouter.setAutoTurnCallback(callback);
}
Expand Down
4 changes: 0 additions & 4 deletions src/qoder/runtime/qoder-query-options-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ export interface PersistentQueryContext extends QueryOptionsContext {
fork?: boolean;
};
canUseTool?: CanUseTool;
hooks: Options['hooks'];
externalContextPaths?: string[];
}

Expand All @@ -55,7 +54,6 @@ export interface ColdStartQueryContext extends QueryOptionsContext {
sessionId?: string;
modelOverride?: string;
canUseTool?: CanUseTool;
hooks: Options['hooks'];
mcpMentions?: Set<string>;
enabledMcpServers?: Set<string>;
allowedTools?: string[];
Expand Down Expand Up @@ -151,7 +149,6 @@ export class QueryOptionsBuilder {
ctx.canUseTool,
);
QueryOptionsBuilder.applyThinking(options, ctx.settings, runtimeModel);
options.hooks = ctx.hooks;

// Pull mode: the provider re-reads live settings so model/override
// changes apply on the next LLM call without restarting the query.
Expand Down Expand Up @@ -205,7 +202,6 @@ export class QueryOptionsBuilder {
ctx.settings.permissionMode,
ctx.canUseTool,
);
options.hooks = ctx.hooks;
QueryOptionsBuilder.applyThinking(options, ctx.settings, selectedModel);
options.resolveModel = createQoderModelPolicyProvider(
() => selectedModel,
Expand Down
7 changes: 7 additions & 0 deletions src/qoder/runtime/qoder-response-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ export class QoderResponseRouter {
if (index >= 0) this.handlers.splice(index, 1);
}

/**
* Known limitation: buffered auto-turn chunks are dropped here. A background
* completion that arrives between turns is now the main thing landing in that
* buffer, so a persistent-query restart racing it loses the result. Keeping
* the buffer across a restart needs replay idempotency first — crash recovery
* re-enqueues the interrupted message, which would render twice.
*/
reset(preserveHandlers = false): void {
this.autoTurnBuffer = [];
this.autoTurnSawStreamText = false;
Expand Down
31 changes: 0 additions & 31 deletions src/qoder/runtime/subagent-hooks.ts

This file was deleted.

4 changes: 3 additions & 1 deletion src/qoder/stream/transform-qoder-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,11 @@ function transformTaskNotification(message: SDKMessage): StreamChunk | null {
}

const status = normalizeTaskNotificationStatus(record.status);
const toolUseId = record.tool_use_id;
return {
type: 'async_subagent_result',
agentId: taskId,
taskId,
...(typeof toolUseId === 'string' && toolUseId.length > 0 ? { toolUseId } : {}),
status,
result: normalizeTaskNotificationResult(status, record.summary),
};
Expand Down
Loading
Loading