Skip to content

Commit 657f253

Browse files
committed
Fix Kiro ACP image attachment errors
1 parent 3ba7233 commit 657f253

7 files changed

Lines changed: 346 additions & 20 deletions

File tree

.reviews/kiro-provider-appearance-review.md

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@
66
| -------------- | ---------------------------------------- |
77
| **Repository** | `declancowen/t3code` |
88
| **Remote** | `origin` |
9-
| **Branch** | `main` |
9+
| **Branch** | `merge-upstream-main-check` |
1010
| **Stack** | TypeScript, Effect, React/Vite, Electron |
1111

1212
## Scope
1313

1414
- `apps/server/src/provider/acp/StandardAcpAdapter.ts` — ACP prompt lifecycle and active-prompt steering.
1515
- `apps/server/src/provider/Layers/KiroAdapter.ts` — Kiro `_message/send` payload mapping.
1616
- `packages/effect-acp/src/protocol.ts` — ACP JSON-RPC transport compatibility for provider-originated requests.
17+
- `packages/effect-acp/src/client.test.ts`, `packages/effect-acp/src/protocol.test.ts` — ACP transport error-channel regression coverage.
1718
- `apps/server/src/provider/acp/AcpAdapterSupport.ts`, `apps/server/src/provider/acp/AcpRuntimeModel.ts` — ACP permission outcome mapping and tool-call classification.
1819
- `apps/server/src/provider/acp/StandardAcpAdapter.test.ts` — ACP steering regression coverage.
1920
- `apps/web/src/components/ChatView.tsx` — running-turn image send guard removal.
@@ -26,6 +27,7 @@
2627
- Kiro cancel behavior because Kiro currently rejects `session/cancel`.
2728
- ACP permission requests with provider-owned UUID request IDs and provider-owned option IDs.
2829
- Active-prompt steering payload compatibility for text and image attachments.
30+
- ACP JSON-RPC response error normalization and provider error data surfacing.
2931
- Running-turn UI send behavior across provider adapters.
3032
- Sidebar/translucency surface consistency across route wrappers.
3133
- macOS app icon visual bounds, corner radius, and generated package assets.
@@ -35,12 +37,66 @@
3537
| Field | Value |
3638
| --------------------- | -------------------- |
3739
| **Review started** | 2026-05-20 |
38-
| **Last reviewed** | 2026-05-21 07:08 BST |
39-
| **Total turns** | 8 |
40+
| **Last reviewed** | 2026-05-23 21:24 BST |
41+
| **Total turns** | 9 |
4042
| **Open findings** | 0 |
4143
| **Resolved findings** | 6 |
4244
| **Accepted findings** | 0 |
4345

46+
## Turn 9 — 2026-05-23 21:24 BST
47+
48+
| Field | Value |
49+
| --------------- | ------------ |
50+
| **Commit** | working tree |
51+
| **IDE / Agent** | Codex |
52+
53+
**Summary:** Reviewed the Kiro/ACP image attachment fix that preserves native ACP image prompts while surfacing provider JSON-RPC errors as request errors instead of Effect decode defects.
54+
**Outcome:** No findings.
55+
**Risk score:** High — this changes shared ACP transport response handling, the shared ACP adapter attachment path, and Kiro provider-specific attachment validation.
56+
**Change archetypes:** protocol compatibility, provider capability guard, attachment validation, error-channel preservation, regression coverage.
57+
**Intended change:** Keep image upload support for Kiro, reject unsupported image paths clearly, and preserve provider error `data` so invalid images no longer show the low-level `Internal error at decode` stack.
58+
**Intent vs actual:** The diff matches the intent. JSON-RPC response errors that the Effect RPC layer decoded as `Die` defects are normalized back to protocol failures before reaching core RPC callers or generic extension callers. `mapAcpToAdapterError` can now read the preserved `data`. Shared ACP content-block construction checks the agent's advertised image prompt capability and lets providers supply their own image MIME allowlist. Kiro wires the allowlist to PNG/JPEG/GIF/WebP without moving provider policy into the transport layer.
59+
**Confidence:** High for transport and adapter behavior because both core RPC and extension request error paths now have direct tests. Medium for Kiro's private active-prompt `_message/send` image payload contract because that path is covered by the shared adapter unit test, while the live provider probe covered normal `session/prompt` image handling and invalid image error surfacing.
60+
**Coverage note:** Added focused tests for ACP JSON-RPC error surfacing, extension error surfacing, image-capability rejection, and provider MIME allowlist rejection.
61+
**Finding triage:** No open findings. The main candidate failures were checked directly: protocol errors still route through typed `AcpRequestError`, non-image-capable agents cannot receive image blocks, and Kiro rejects unsupported MIME types before file read/provider dispatch.
62+
**Static/analyzer evidence:** No analyzer policy changes. Fallow is not available in this repo context. Lint passed with the existing 9 warnings unrelated to this diff.
63+
**Architecture impact:** The capability invariant lives in the shared ACP adapter, where prompt content is materialized for all standard ACP providers. Kiro's narrower MIME policy remains in `KiroAdapter`, preserving provider ownership. Transport normalization stays in `effect-acp` and is not Kiro-specific.
64+
**Bug classes / invariants checked:** provider JSON-RPC errors preserve code/message/data; core RPC and generic extension errors use the request error channel; image content is only sent to agents advertising image prompt support; provider MIME allowlists reject unsupported image types before dispatch; Kiro image types align with the existing Claude/common vision set.
65+
**Branch totality:** Reviewed the scoped local diff for the ACP/Kiro image fix. The working tree still contains unrelated `.gitignore`, app-logo, public, and release artifact changes that are intentionally outside this review and commit scope.
66+
**Sibling closure:** Checked `AcpAdapterSupport` error formatting, ACP error classes, attachment path resolution, Claude/Codex/Cursor image attachment patterns, shared ACP prompt construction, Kiro active-prompt send mapping, and web send attachment handoff.
67+
**Remediation impact surface:** Affects Kiro and any future provider using `makeStandardAcpAdapter` with image attachments. Cursor remains on its separate adapter path. Existing text-only ACP prompt behavior is unchanged.
68+
**Residual risk / unknowns:** Kiro's private `_message/send` method is not an ACP standard contract; the shared adapter payload shape is tested, but provider behavior could still change outside this repo.
69+
70+
### Validation
71+
72+
- `bun run test src/client.test.ts src/protocol.test.ts` from `packages/effect-acp` — passed, 17 tests.
73+
- `bun run test src/provider/acp/StandardAcpAdapter.test.ts` from `apps/server` — passed, 10 tests.
74+
- `bun fmt` — passed.
75+
- `bun lint` — passed with 9 existing warnings.
76+
- `bun typecheck` — passed, 13 packages.
77+
- `git diff --check -- apps/server/src/provider/Layers/KiroAdapter.ts apps/server/src/provider/acp/StandardAcpAdapter.test.ts apps/server/src/provider/acp/StandardAcpAdapter.ts packages/effect-acp/src/client.test.ts packages/effect-acp/src/protocol.test.ts packages/effect-acp/src/protocol.ts` — passed.
78+
79+
### Branch-totality proof
80+
81+
- **Non-delta files/systems re-read:** diff-review gates, all-clear antipatterns, architecture implementation checklist, `AcpAdapterSupport`, `effect-acp` error classes, attachment store/mime helpers, Claude/Codex/Cursor attachment handling, web send attachment handoff.
82+
- **Prior open findings rechecked:** No open findings remained from Turn 8.
83+
- **Prior resolved/adjacent areas revalidated:** Active-prompt attachment steering remains routed through the existing Kiro `_message/send` hook; Kiro stop/cancel behavior is unchanged by this turn.
84+
- **Hotspots or sibling paths revisited:** Shared ACP protocol response routing, generic extension pending requests, normal `session/prompt`, active-prompt content block construction, provider-specific MIME policy, attachment path resolution.
85+
- **Dependency/adjacent surfaces revalidated:** `mapAcpToAdapterError` already formats preserved provider `data`, so the transport fix now feeds the UI-facing detail path.
86+
- **Why this is enough:** The riskiest invariant is error-channel classification, and both the core RPC path and extension path are directly covered. The attachment support checks are enforced before dispatch and have unit coverage for both capability and MIME rejection.
87+
88+
### Challenger pass
89+
90+
Done — assumed the fix could either swallow true provider defects or block valid image-capable providers. The normalization only rewrites failure entries shaped like JSON-RPC protocol errors, and the image guard follows ACP `promptCapabilities.image` with Kiro-specific MIME policy isolated to the Kiro adapter.
91+
92+
### Resolved / Carried / New findings
93+
94+
- None.
95+
96+
### Recommendations
97+
98+
1. **Fix first:** none.
99+
44100
## Turn 8 — 2026-05-21 07:08 BST
45101

46102
| Field | Value |

apps/server/src/provider/Layers/KiroAdapter.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts";
66

77
const PROVIDER = ProviderDriverKind.make("kiro");
88
const KIRO_ACTIVE_PROMPT_MESSAGE_METHOD = "_message/send";
9+
const SUPPORTED_KIRO_IMAGE_MIME_TYPES = new Set([
10+
"image/gif",
11+
"image/jpeg",
12+
"image/png",
13+
"image/webp",
14+
]);
915

1016
export interface KiroAdapterLiveOptions {
1117
readonly environment?: NodeJS.ProcessEnv;
@@ -23,6 +29,7 @@ export function makeKiroAdapter(kiroSettings: KiroSettings, options?: KiroAdapte
2329
...(options?.nativeEventLogger ? { nativeEventLogger: options.nativeEventLogger } : {}),
2430
...(options?.instanceId ? { instanceId: options.instanceId } : {}),
2531
activePromptMessageMethod: KIRO_ACTIVE_PROMPT_MESSAGE_METHOD,
32+
supportedImageMimeTypes: SUPPORTED_KIRO_IMAGE_MIME_TYPES,
2633
stopSessionOnInterruptCancelUnsupported: true,
2734
sendMessageWhilePromptActive: ({ runtime, sessionId, content, contentBlocks }) =>
2835
runtime.request(KIRO_ACTIVE_PROMPT_MESSAGE_METHOD, {

apps/server/src/provider/acp/StandardAcpAdapter.test.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type * as EffectAcpSchema from "effect-acp/schema";
1414
import { type ChatAttachment, ProviderDriverKind, ThreadId } from "@t3tools/contracts";
1515

1616
import { ServerConfig } from "../../config.ts";
17+
import { ProviderAdapterRequestError, ProviderAdapterValidationError } from "../Errors.ts";
1718
import type { AcpSessionRuntimeShape } from "./AcpSessionRuntime.ts";
1819
import { makeStandardAcpAdapter } from "./StandardAcpAdapter.ts";
1920

@@ -26,6 +27,7 @@ function makeFakeAcpRuntime(input: {
2627
readonly cancel?: Effect.Effect<void, AcpRequestError>;
2728
readonly prompt?: () => Effect.Effect<EffectAcpSchema.PromptResponse, unknown>;
2829
readonly request?: (method: string, payload: unknown) => Effect.Effect<unknown, unknown>;
30+
readonly supportsImagePrompts?: boolean;
2931
}): AcpSessionRuntimeShape {
3032
const ignoreHandler = () => Effect.void;
3133
return {
@@ -49,7 +51,12 @@ function makeFakeAcpRuntime(input: {
4951
sessionId: "fake-session",
5052
initializeResult: {
5153
protocolVersion: 1,
52-
agentCapabilities: { loadSession: true },
54+
agentCapabilities: {
55+
loadSession: true,
56+
...(input.supportsImagePrompts === false
57+
? {}
58+
: { promptCapabilities: { image: true } }),
59+
},
5360
} as EffectAcpSchema.InitializeResponse,
5461
sessionSetupResult: {
5562
sessionId: "fake-session",
@@ -123,6 +130,92 @@ it.effect("keeps interrupted ACP turns active until session/prompt resolves", ()
123130
}).pipe(Effect.scoped, Effect.provide(standardAcpAdapterTestLayer)),
124131
);
125132

133+
it.effect("rejects image attachments when the ACP session does not advertise image prompts", () =>
134+
Effect.gen(function* () {
135+
const provider = ProviderDriverKind.make("cursor");
136+
const threadId = ThreadId.make("standard-acp-image-capability-required");
137+
const cancelCalled = yield* Deferred.make<void>();
138+
const runtime = makeFakeAcpRuntime({
139+
cancelCalled,
140+
supportsImagePrompts: false,
141+
});
142+
const attachment: ChatAttachment = {
143+
type: "image",
144+
id: "image-capability-required",
145+
name: "image-capability-required.png",
146+
mimeType: "image/png",
147+
sizeBytes: 1,
148+
};
149+
150+
const adapter = yield* makeStandardAcpAdapter({
151+
provider,
152+
runtimeLabel: "Fake ACP",
153+
makeRuntime: () => Effect.succeed(runtime),
154+
});
155+
156+
yield* adapter.startSession({
157+
threadId,
158+
provider,
159+
cwd: process.cwd(),
160+
runtimeMode: "full-access",
161+
});
162+
163+
const error = yield* adapter
164+
.sendTurn({
165+
threadId,
166+
input: "inspect",
167+
attachments: [attachment],
168+
})
169+
.pipe(Effect.flip, Effect.timeout("1 second"));
170+
171+
assert.instanceOf(error, ProviderAdapterValidationError);
172+
assert.equal(error.issue, "Fake ACP session does not support image attachments.");
173+
yield* adapter.stopSession(threadId);
174+
}).pipe(Effect.scoped, Effect.provide(standardAcpAdapterTestLayer)),
175+
);
176+
177+
it.effect("rejects image attachments outside a provider image MIME allowlist", () =>
178+
Effect.gen(function* () {
179+
const provider = ProviderDriverKind.make("kiro");
180+
const threadId = ThreadId.make("standard-acp-image-mime-allowlist");
181+
const cancelCalled = yield* Deferred.make<void>();
182+
const runtime = makeFakeAcpRuntime({ cancelCalled });
183+
const attachment: ChatAttachment = {
184+
type: "image",
185+
id: "image-mime-allowlist",
186+
name: "image-mime-allowlist.svg",
187+
mimeType: "image/svg+xml",
188+
sizeBytes: 1,
189+
};
190+
191+
const adapter = yield* makeStandardAcpAdapter({
192+
provider,
193+
runtimeLabel: "Kiro",
194+
supportedImageMimeTypes: new Set(["image/png"]),
195+
makeRuntime: () => Effect.succeed(runtime),
196+
});
197+
198+
yield* adapter.startSession({
199+
threadId,
200+
provider,
201+
cwd: process.cwd(),
202+
runtimeMode: "full-access",
203+
});
204+
205+
const error = yield* adapter
206+
.sendTurn({
207+
threadId,
208+
input: "inspect",
209+
attachments: [attachment],
210+
})
211+
.pipe(Effect.flip, Effect.timeout("1 second"));
212+
213+
assert.instanceOf(error, ProviderAdapterRequestError);
214+
assert.equal(error.detail, "Unsupported Kiro image attachment type 'image/svg+xml'.");
215+
yield* adapter.stopSession(threadId);
216+
}).pipe(Effect.scoped, Effect.provide(standardAcpAdapterTestLayer)),
217+
);
218+
126219
it.effect("forwards session/cancel when no local active prompt is registered", () =>
127220
Effect.gen(function* () {
128221
const provider = ProviderDriverKind.make("cursor");

apps/server/src/provider/acp/StandardAcpAdapter.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export interface StandardAcpAdapterOptions {
7575
readonly nativeEventLogger?: EventNdjsonLogger;
7676
readonly instanceId?: ProviderInstanceId;
7777
readonly activePromptMessageMethod?: string;
78+
readonly supportedImageMimeTypes?: ReadonlySet<string>;
7879
readonly stopSessionOnInterruptCancelUnsupported?: boolean;
7980
readonly sendMessageWhilePromptActive?: (input: {
8081
readonly runtime: AcpSessionRuntimeShape;
@@ -107,6 +108,7 @@ interface StandardAcpSessionContext {
107108
readonly scope: Scope.Closeable;
108109
readonly acp: AcpSessionRuntimeShape;
109110
readonly acpSessionId: string;
111+
readonly supportsImagePrompts: boolean;
110112
notificationFiber: Fiber.Fiber<void, never> | undefined;
111113
readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;
112114
readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>;
@@ -138,6 +140,10 @@ function parseStandardAcpResume(raw: unknown): { sessionId: string } | undefined
138140
return { sessionId: raw.sessionId.trim() };
139141
}
140142

143+
function supportsImagePromptContent(initializeResult: EffectAcpSchema.InitializeResponse): boolean {
144+
return initializeResult.agentCapabilities?.promptCapabilities?.image === true;
145+
}
146+
141147
function normalizeModeSearchText(mode: AcpSessionMode): string {
142148
return [mode.id, mode.name, mode.description]
143149
.filter((value): value is string => typeof value === "string" && value.length > 0)
@@ -400,14 +406,33 @@ export function makeStandardAcpAdapter(
400406
const buildPromptContentBlocks = (
401407
input: Parameters<ProviderAdapterShape<ProviderAdapterError>["sendTurn"]>[0],
402408
method: string,
409+
supportsImagePrompts: boolean,
403410
) =>
404411
Effect.gen(function* () {
405412
const promptParts: Array<EffectAcpSchema.ContentBlock> = [];
406413
if (input.input?.trim()) {
407414
promptParts.push({ type: "text", text: input.input.trim() });
408415
}
409416
if (input.attachments && input.attachments.length > 0) {
417+
if (!supportsImagePrompts) {
418+
return yield* new ProviderAdapterValidationError({
419+
provider,
420+
operation: "sendTurn",
421+
issue: `${options.runtimeLabel} session does not support image attachments.`,
422+
});
423+
}
410424
for (const attachment of input.attachments) {
425+
const supportedImageMimeTypes = options.supportedImageMimeTypes;
426+
if (
427+
supportedImageMimeTypes &&
428+
!supportedImageMimeTypes.has(attachment.mimeType.toLowerCase())
429+
) {
430+
return yield* new ProviderAdapterRequestError({
431+
provider,
432+
method,
433+
detail: `Unsupported ${options.runtimeLabel} image attachment type '${attachment.mimeType}'.`,
434+
});
435+
}
411436
const attachmentPath = resolveAttachmentPath({
412437
attachmentsDir: serverConfig.attachmentsDir,
413438
attachment,
@@ -639,6 +664,7 @@ export function makeStandardAcpAdapter(
639664
scope: sessionScope,
640665
acp,
641666
acpSessionId: started.sessionId,
667+
supportsImagePrompts: supportsImagePromptContent(started.initializeResult),
642668
notificationFiber: undefined,
643669
pendingApprovals,
644670
pendingUserInputs,
@@ -752,7 +778,11 @@ export function makeStandardAcpAdapter(
752778
ctx.activePrompt?.turnId ?? ctx.activeTurnId ?? ctx.session.activeTurnId;
753779
if (activePromptTurnId && options.sendMessageWhilePromptActive) {
754780
const method = options.activePromptMessageMethod ?? "session/message";
755-
const contentBlocks = yield* buildPromptContentBlocks(input, method);
781+
const contentBlocks = yield* buildPromptContentBlocks(
782+
input,
783+
method,
784+
ctx.supportsImagePrompts,
785+
);
756786
const content = contentBlocks
757787
.filter(
758788
(block): block is Extract<EffectAcpSchema.ContentBlock, { type: "text" }> =>
@@ -813,7 +843,11 @@ export function makeStandardAcpAdapter(
813843
mapAcpToAdapterError(provider, input.threadId, method, cause),
814844
});
815845

816-
const promptParts = yield* buildPromptContentBlocks(input, "session/prompt");
846+
const promptParts = yield* buildPromptContentBlocks(
847+
input,
848+
"session/prompt",
849+
ctx.supportsImagePrompts,
850+
);
817851

818852
const previousActivePrompt = ctx.activePrompt;
819853
const previousActiveTurnId = ctx.activeTurnId;

0 commit comments

Comments
 (0)