Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e2b383c
fix(ui): pin monaco 0.55, drop dead css import
dvaJi Aug 3, 2026
511e255
fix(chat): acp loading, model groups, send/stop
dvaJi Aug 3, 2026
a6a3f17
chore: checkpoint ui + base-ui migration skill
dvaJi Aug 3, 2026
b54c480
wip(ui): base-ui migration leaf wrappers
dvaJi Aug 3, 2026
959d24f
test(acp): fix stale runTurn persistence mock
dvaJi Aug 3, 2026
0534196
test(acp): real timers for permission timeout test
dvaJi Aug 3, 2026
5c74632
fix(acp): route plan updates to plan widget
dvaJi Aug 3, 2026
3f17d91
perf(ui): stabilize markdown + toolbar props
dvaJi Aug 3, 2026
b47aec4
style(ui): format separator
dvaJi Aug 3, 2026
0148874
perf(ui): stop rate-limit interval churn, stabilize row callbacks
dvaJi Aug 3, 2026
ad35630
perf(ui): memo MessageListRow + MessageBlockContent
dvaJi Aug 4, 2026
1d83591
feat(ui): setting to hide Continued indicator
dvaJi Aug 4, 2026
958726e
fix(ui): adjust AgentAvatar size for consistency
dvaJi Aug 4, 2026
ec341b0
fix: address PR review (compiler refs, interval, steer)
dvaJi Aug 4, 2026
2a11077
fix(ui): migrate alert-dialog, sweep asChild
dvaJi Aug 4, 2026
99c5ece
fix(ui): move components.json, add shadcn alias
dvaJi Aug 4, 2026
11188bf
fix(ui): sweep tooltip asChild to render prop
dvaJi Aug 4, 2026
87206dd
fix(ui): single root-level TooltipProvider
dvaJi Aug 4, 2026
eb82f60
fix(ui): sweep all base-ui asChild and prop signatures
dvaJi Aug 4, 2026
763b623
fix(ui): address PR review (appbar, grouping, drafts)
dvaJi Aug 5, 2026
f94187e
fix(ui): restore #shadcn alias convention
dvaJi Aug 5, 2026
04031d2
chore: remove migrate-radix-to-base skill
dvaJi Aug 5, 2026
09c1998
fix(ui): resolve react doctor warnings
dvaJi Aug 5, 2026
7f31103
fix(ui): hide empty continued block container
dvaJi Aug 5, 2026
59e274f
perf(ui): stabilize onCopyImage, memo MessageBlockThink
dvaJi Aug 5, 2026
a87e498
fix(ui): better think duration format (ms/s/m/h)
dvaJi Aug 5, 2026
61e3e89
refactor: replace MessageBlockActivityGroup with MessageTurnFold for …
dvaJi Aug 5, 2026
e1156fd
fix(ui): improve dropdown menu and slider component behavior, enhance…
dvaJi Aug 6, 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
88 changes: 83 additions & 5 deletions apps/daemon/src/host/acp-provider-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ import type { AcpConfigState, AcpAgentDiagnostics, AcpDebugRequest, AcpDebugRunR

const ACP_PROVIDER_ID = "acp";

const normalizePlanStatus = (status: unknown): "pending" | "in_progress" | "completed" => {
if (status === "completed" || status === "done") return "completed";
if (status === "in_progress") return "in_progress";
return "pending";
};

type PendingAcpPermission = {
sessionId: string;
toolCallId: string;
Expand All @@ -47,7 +53,16 @@ type PendingAcpPermission = {
*/
export class AcpProviderExecutionPort implements ProviderExecutionPort {
private runtimePromise: Promise<AcpRuntime> | null = null;
private activeTurns = new Map<string, { controller: AbortController; eventId: string; runId: string }>();
private activeTurns = new Map<
string,
{
controller: AbortController;
eventId: string;
runId: string;
donePromise: Promise<void>;
doneResolve: () => void;
}
>();
private pendingPermissions = new Map<string, PendingAcpPermission>();
private readonly contentMapper = new AcpContentMapper();

Expand Down Expand Up @@ -138,10 +153,16 @@ export class AcpProviderExecutionPort implements ProviderExecutionPort {
const runtime = await this.getRuntime();
const record = await this.getSessionRecord(sessionId);
const controller = new AbortController();
let doneResolve!: () => void;
const donePromise = new Promise<void>((resolve) => {
doneResolve = resolve;
});
this.activeTurns.set(sessionId, {
controller,
eventId: assistantMessageId,
runId: requestId,
donePromise,
doneResolve,
});

void this.runTurn(
Expand All @@ -154,7 +175,11 @@ export class AcpProviderExecutionPort implements ProviderExecutionPort {
assistantMessageId,
record?.workdir,
).finally(() => {
this.activeTurns.delete(sessionId);
const current = this.activeTurns.get(sessionId);
if (current && current.runId === requestId) {
this.activeTurns.delete(sessionId);
}
doneResolve();
});

return { requestId, messageId: assistantMessageId };
Expand Down Expand Up @@ -374,6 +399,7 @@ export class AcpProviderExecutionPort implements ProviderExecutionPort {
workdir?: string,
): Promise<void> {
const blocks: Array<Record<string, unknown>> = [];
let planRevision = 0;
try {
for await (const notification of runtime.runPromptTurn({
conversationId: sessionId,
Expand All @@ -385,8 +411,26 @@ export class AcpProviderExecutionPort implements ProviderExecutionPort {
if (controller.signal.aborted) break;

const mapped = this.contentMapper.map(notification);

if (mapped.planEntries && mapped.planEntries.length > 0) {
planRevision += 1;
const plan = mapped.planEntries
.map((entry) => ({ step: (entry.content ?? "").trim(), status: normalizePlanStatus(entry.status) }))
.filter((item) => item.step.length > 0);
if (plan.length > 0) {
this.eventPublisher.publish("chat.plan.updated", {
sessionId,
messageId: assistantMessageId,
plan,
revision: planRevision,
updatedAt: new Date().toISOString(),
});
}
}

const now = Date.now();
for (const block of mapped.blocks) {
if (block.type === "plan") continue;
const last = blocks.at(-1);
if (block.type === "content" && last?.type === "content") {
last.content = `${last.content ?? ""}${block.content ?? ""}`;
Expand Down Expand Up @@ -498,9 +542,43 @@ export class AcpProviderExecutionPort implements ProviderExecutionPort {
});
}

async steerActiveTurn(_sessionId: string, _content: string | SendMessageInput): Promise<void> {
await this.cancelGeneration(_sessionId);
await this.sendMessage(_sessionId, _content);
async steerActiveTurn(sessionId: string, content: string | SendMessageInput): Promise<void> {
await this.interruptActiveTurn(sessionId);
await this.sendMessage(sessionId, content);
}

/**
* Non-destructively interrupts the in-flight ACP prompt for a session so a new
* turn can follow (used by steer). Unlike {@link cancelGeneration}, this does
* NOT tear down the session or unbind the agent process: it asks the agent to
* cancel the active `session/prompt` request, aborts local streaming, and
* waits for the turn to settle before returning.
*/
private async interruptActiveTurn(sessionId: string): Promise<void> {
const active = this.activeTurns.get(sessionId);
if (!active) return;

active.controller.abort();

for (const [toolCallId, pending] of this.pendingPermissions) {
if (pending.sessionId !== sessionId) continue;
this.pendingPermissions.delete(toolCallId);
pending.resolve({ outcome: { outcome: "cancelled" } });
}

try {
const runtime = await this.getRuntime();
const session = runtime.sessionManager.getSession(sessionId);
if (session) {
await session.connection.agent.notify(acpMethods.agent.session.cancel, {
sessionId: session.sessionId,
} as schema.CancelNotification);
}
} catch (error) {
console.warn("[ACP] Failed to send session/cancel for steer:", error);
}

await Promise.race([active.donePromise, new Promise<void>((resolve) => setTimeout(resolve, 4000))]).catch(() => {});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +569 to +581

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="apps/daemon/src/host/acp-provider-execution.ts"
if [ -f "$FILE" ]; then
  echo "FILE_EXISTS yes"
  wc -l "$FILE"
  sed -n '520,590p' "$FILE"
else
  echo "FILE_EXISTS no"
  fd -a 'acp-provider-execution.ts' .
fi

echo "--- nearby symbols ---"
rg -n "steerActiveTurn|async [^(]*steerActiveTurn|active\.donePromise|agent\.session\.cancel|notify\\(" apps/daemon/src/host/acp-provider-execution.ts

Repository: dvaJi/argos

Length of output: 3007


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="apps/daemon/src/host/acp-provider-execution.ts"
echo "--- definitions ---"
rg -n "activeTurns|activeTurn|donePromise|controller|class .*Execution|interface .*Execution|type .*Execution|cancelGeneration|sendMessage\\(" "$FILE"

echo "--- surrounding implementation ---"
sed -n '1,190p' "$FILE"
sed -n '590,785p' "$FILE"

Repository: dvaJi/argos

Length of output: 16263


Apply the timeout before waiting on runtime access and the cancel request.

interruptActiveTurn() awaits getRuntime() and session.connection.agent.notify() before the four-second race, so a stalled ACP connection can make steerActiveTurn() wait indefinitely and block sending the replacement content. Race interruption, runtime access, and the cancel request against active.donePromise with one four-second deadline.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/daemon/src/host/acp-provider-execution.ts` around lines 563 - 575, The
interruptActiveTurn flow currently starts its four-second timeout only after
getRuntime and session.connection.agent.notify complete. Restructure
interruptActiveTurn so runtime lookup, cancellation notification, and
active.donePromise all race against one shared four-second deadline, allowing
steerActiveTurn to proceed even when ACP access or cancellation stalls.

}

async getAcpSessionCommands(conversationId: string): Promise<
Expand Down
113 changes: 95 additions & 18 deletions apps/daemon/test/acpProviderExecution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,19 @@ describe("AcpProviderExecutionPort", () => {
});

it("publishes and persists decoded ACP assistant text chunks", async () => {
const addMessage = vi.fn(async () => "persisted-assistant-1");
const finalizeAssistantMessage = vi.fn(async () => undefined);
const setMessageError = vi.fn(async () => undefined);
const publish = vi.fn();
const port = new AcpProviderExecutionPort({} as never, { addMessage } as never, { publish } as never, {
dataDir: "/tmp",
appVersion: "1.0.0",
db: { prepare: vi.fn() },
});
const port = new AcpProviderExecutionPort(
{} as never,
{ finalizeAssistantMessage, setMessageError } as never,
{ publish } as never,
{
dataDir: "/tmp",
appVersion: "1.0.0",
db: { prepare: vi.fn() },
},
);
const runtime = {
async *runPromptTurn() {
yield {
Expand Down Expand Up @@ -96,15 +102,91 @@ describe("AcpProviderExecutionPort", () => {
expect.objectContaining({
requestId: "request-1",
sessionId: "conversation-1",
messageId: "persisted-assistant-1",
messageId: "assistant-1",
blocks: expect.arrayContaining([
expect.objectContaining({ content: "OpenCode", status: "success" }),
expect.objectContaining({ type: "reasoning_content", content: "The user" }),
expect.objectContaining({ type: "tool_call", tool_call: expect.objectContaining({ id: "tool-1" }) }),
]),
}),
);
expect(addMessage).toHaveBeenCalledWith("conversation-1", "assistant", expect.stringContaining("OpenCode"));
expect(finalizeAssistantMessage).toHaveBeenCalledWith(
"assistant-1",
expect.arrayContaining([expect.objectContaining({ content: "OpenCode", status: "success" })]),
expect.any(String),
);
expect(setMessageError).not.toHaveBeenCalled();
});

it("routes ACP plan updates to the plan widget and skips inline plan blocks", async () => {
const finalizeAssistantMessage = vi.fn(async () => undefined);
const setMessageError = vi.fn(async () => undefined);
const publish = vi.fn();
const port = new AcpProviderExecutionPort(
{} as never,
{ finalizeAssistantMessage, setMessageError } as never,
{ publish } as never,
{
dataDir: "/tmp",
appVersion: "1.0.0",
db: { prepare: vi.fn() },
},
);
const runtime = {
async *runPromptTurn() {
yield {
sessionId: "acp-session",
update: {
sessionUpdate: "plan",
entries: [
{ content: "Analyze", status: "completed" },
{ content: "Implement", status: "in_progress" },
{ content: "Test", status: "pending" },
],
},
};
yield {
sessionId: "acp-session",
update: {
sessionUpdate: "plan",
entries: [
{ content: "Analyze", status: "completed" },
{ content: "Implement", status: "completed" },
{ content: "Test", status: "in_progress" },
],
},
};
},
};

await (port as any).runTurn(
runtime,
"conversation-1",
{ id: "opencode", name: "OpenCode" },
[{ type: "text", text: "hello" }],
new AbortController(),
"request-1",
"assistant-1",
);

const planCalls = publish.mock.calls.filter((call) => call[0] === "chat.plan.updated");
expect(planCalls).toHaveLength(2);
expect(planCalls[0][1]).toMatchObject({
sessionId: "conversation-1",
messageId: "assistant-1",
revision: 1,
plan: [
{ step: "Analyze", status: "completed" },
{ step: "Implement", status: "in_progress" },
{ step: "Test", status: "pending" },
],
});
expect(planCalls[1][1]).toMatchObject({ revision: 2 });

for (const call of publish.mock.calls) {
if (call[0] !== "chat.stream.updated") continue;
expect((call[1] as { blocks: Array<{ type: string }> }).blocks.some((b) => b.type === "plan")).toBe(false);
}
});

it("allows ACP tool permissions once in full access mode", async () => {
Expand Down Expand Up @@ -213,16 +295,11 @@ describe("AcpProviderExecutionPort", () => {
});

it("times out a hanging permission resolver with a cancelled outcome", async () => {
vi.useFakeTimers();
try {
const onTimeout = vi.fn();
const promise = resolvePermissionWithTimeout(() => new Promise(() => {}), 1000, onTimeout);
await vi.advanceTimersByTimeAsync(1000);
await expect(promise).resolves.toEqual({ outcome: { outcome: "cancelled" } });
expect(onTimeout).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
const onTimeout = vi.fn();
await expect(resolvePermissionWithTimeout(() => new Promise(() => {}), 50, onTimeout)).resolves.toEqual({
outcome: { outcome: "cancelled" },
});
expect(onTimeout).toHaveBeenCalledOnce();
});

it("returns the resolver result when it settles before the timeout", async () => {
Expand Down
3 changes: 2 additions & 1 deletion apps/daemon/test/daemonSessionRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,7 @@ describe("daemon ACP session routes", () => {

vi.spyOn(provider as any, "getRuntime").mockResolvedValue(runtime);
vi.spyOn(provider as any, "cancelGeneration").mockResolvedValue(undefined);
vi.spyOn(provider as any, "interruptActiveTurn").mockResolvedValue(undefined);
vi.spyOn(provider as any, "sendMessage").mockResolvedValue({ requestId: null, messageId: null });

await expect(provider.setAcpSessionConfigOption("session-1", "__acp_legacy_model__", "model-b")).resolves.toEqual({
Expand All @@ -905,7 +906,7 @@ describe("daemon ACP session routes", () => {
});

await expect(provider.steerActiveTurn("session-1", "steer this")).resolves.toBeUndefined();
expect(provider.cancelGeneration).toHaveBeenCalledWith("session-1");
expect(provider.interruptActiveTurn).toHaveBeenCalledWith("session-1");
expect(provider.sendMessage).toHaveBeenCalledWith("session-1", "steer this");
expect(runtime.processManager.updateBoundProcessConfigState).toHaveBeenCalledWith("session-1", {
source: "legacy",
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/main/routes/settings/settingsAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const readSettingsSnapshot = (configPresenter: IConfigPresenter): Setting
traceDebugEnabled: configPresenter.getSetting<boolean>("traceDebugEnabled") ?? false,
copyWithCotEnabled: configPresenter.getCopyWithCotEnabled(),
loggingEnabled: configPresenter.getLoggingEnabled(),
showContinueIndicator: configPresenter.getSetting<boolean>("showContinueIndicator") ?? false,
});

export const pickSettingsSnapshot = (
Expand Down Expand Up @@ -93,6 +94,9 @@ export const applySettingChange = (configPresenter: IConfigPresenter, change: Se
case "loggingEnabled":
configPresenter.setLoggingEnabled(change.value);
return;
case "showContinueIndicator":
configPresenter.setSetting("showContinueIndicator", change.value);
return;
}
};

Expand Down
Loading
Loading