Skip to content
Closed
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
5 changes: 5 additions & 0 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1301,6 +1301,11 @@ export function buildPendingUserInputAnswers(
for (const question of questions) {
const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]);
if (!answer) {
// Optional questions can be left unanswered; only required questions
// block submission. Unanswered optional questions are simply omitted.
if (question.optional) {
continue;
}
return null;
}
answers[question.id] = answer;
Expand Down
23 changes: 18 additions & 5 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLET
const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1";
const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1";
const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1";
const emitEmptyCompletion = process.env.T3_ACP_EMIT_EMPTY_COMPLETION === "1";
const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL === "1";
const omitXAiPromptCompleteStopReason =
process.env.T3_ACP_OMIT_XAI_PROMPT_COMPLETE_STOP_REASON === "1";
Expand Down Expand Up @@ -55,7 +56,7 @@ let currentContext = "272k";
let currentFast = false;
let promptCount = 0;
let overlappingFirstPromptId: string | undefined;
const cancelledSessions = new Set<string>();
const cancelledSessions = new Map<string, number>();

function promptIdFromRequestMeta(
request: Pick<AcpSchema.PromptRequest, "_meta">,
Expand Down Expand Up @@ -436,7 +437,7 @@ const program = Effect.gen(function* () {
yield* agent.handleCancel(({ sessionId }) =>
Effect.gen(function* () {
const cancelledSessionId = String(sessionId ?? "mock-session-1");
cancelledSessions.add(cancelledSessionId);
cancelledSessions.set(cancelledSessionId, promptCount);
if (emitLateUpdateAfterCancel) {
yield* Effect.sleep("50 millis");
yield* Effect.sync(() => {
Expand All @@ -456,6 +457,7 @@ const program = Effect.gen(function* () {
Effect.gen(function* () {
const requestedSessionId = String(request.sessionId ?? sessionId);
promptCount += 1;
const currentPromptCount = promptCount;

if (Number.isFinite(promptDelayMs) && promptDelayMs > 0) {
yield* Effect.sleep(`${promptDelayMs} millis`);
Expand All @@ -465,6 +467,13 @@ const program = Effect.gen(function* () {
return yield* AcpError.AcpRequestError.internalError("Mock prompt failure");
}

if (emitEmptyCompletion) {
// Complete the turn immediately with no assistant content and no tool
// activity — mirrors a provider that silently gave up (e.g. Grok ending
// the turn after a usage-limit inference failure).
return { stopReason: "end_turn" };
}

if (emitStaleXAiPromptCompleteBeforeSecondHang && promptCount === 1) {
return {
stopReason: "end_turn",
Expand Down Expand Up @@ -684,9 +693,13 @@ const program = Effect.gen(function* () {
],
});

const cancelled =
cancelledSessions.delete(requestedSessionId) ||
permission.outcome.outcome === "cancelled";
const cancelledPromptCount = cancelledSessions.get(requestedSessionId);
const cancelledBySessionState = cancelledPromptCount === currentPromptCount;
if (cancelledPromptCount !== undefined && cancelledPromptCount <= currentPromptCount) {
cancelledSessions.delete(requestedSessionId);
}
const cancelledByPermission = permission.outcome.outcome === "cancelled";
const cancelled = cancelledBySessionState || cancelledByPermission;

yield* agent.client.sessionUpdate({
sessionId: requestedSessionId,
Expand Down
160 changes: 160 additions & 0 deletions apps/server/src/provider/Drivers/KiroDriver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { KiroSettings, ProviderDriverKind } from "@t3tools/contracts";
import * as Crypto from "effect/Crypto";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { HttpClient } from "effect/unstable/http";
import { ChildProcessSpawner } from "effect/unstable/process";

import { ServerConfig } from "../../config.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { makeKiroTextGeneration } from "../../textGeneration/KiroTextGeneration.ts";
import { ProviderDriverError } from "../Errors.ts";
import { makeKiroAdapter } from "../Layers/KiroAdapter.ts";
import {
buildInitialKiroProviderSnapshot,
checkKiroProviderStatus,
enrichKiroSnapshot,
} from "../Layers/KiroProvider.ts";
import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
import {
defaultProviderContinuationIdentity,
type ProviderDriver,
type ProviderInstance,
} from "../ProviderDriver.ts";
import type { ServerProviderDraft } from "../providerSnapshot.ts";
import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
import {
makeManualOnlyProviderMaintenanceCapabilities,
makeStaticProviderMaintenanceResolver,
resolveProviderMaintenanceCapabilitiesEffect,
} from "../providerMaintenance.ts";
import {
haveProviderSnapshotSettingsChanged,
makeProviderSnapshotSettingsSource,
type ProviderSnapshotSettings,
} from "../providerUpdateSettings.ts";

const decodeKiroSettings = Schema.decodeSync(KiroSettings);
const DRIVER_KIND = ProviderDriverKind.make("kiro");
const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
const UPDATE = makeStaticProviderMaintenanceResolver(
makeManualOnlyProviderMaintenanceCapabilities({
provider: DRIVER_KIND,
packageName: null,
}),
);

export type KiroDriverEnv =
| ChildProcessSpawner.ChildProcessSpawner
| Crypto.Crypto
| FileSystem.FileSystem
| HttpClient.HttpClient
| Path.Path
| ProviderEventLoggers
| ServerConfig
| ServerSettingsService;

const withInstanceIdentity =
(input: {
readonly instanceId: ProviderInstance["instanceId"];
readonly displayName: string | undefined;
readonly accentColor: string | undefined;
readonly continuationGroupKey: string;
}) =>
(snapshot: ServerProviderDraft) => ({
...snapshot,
instanceId: input.instanceId,
driver: DRIVER_KIND,
...(input.displayName ? { displayName: input.displayName } : {}),
...(input.accentColor ? { accentColor: input.accentColor } : {}),
continuation: { groupKey: input.continuationGroupKey },
});

export const KiroDriver: ProviderDriver<KiroSettings, KiroDriverEnv> = {
driverKind: DRIVER_KIND,
metadata: {
displayName: "Kiro",
supportsMultipleInstances: true,
},
configSchema: KiroSettings,
defaultConfig: (): KiroSettings => decodeKiroSettings({}),
create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const httpClient = yield* HttpClient.HttpClient;
const serverSettings = yield* ServerSettingsService;
const eventLoggers = yield* ProviderEventLoggers;
const processEnv = mergeProviderInstanceEnvironment(environment);
const continuationIdentity = defaultProviderContinuationIdentity({
driverKind: DRIVER_KIND,
instanceId,
});
const stampIdentity = withInstanceIdentity({
instanceId,
displayName,
accentColor,
continuationGroupKey: continuationIdentity.continuationKey,
});
const effectiveConfig = { ...config, enabled } satisfies KiroSettings;
const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
binaryPath: effectiveConfig.binaryPath,
env: processEnv,
});

const adapter = yield* makeKiroAdapter(effectiveConfig, {
environment: processEnv,
...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
instanceId,
});
const textGeneration = yield* makeKiroTextGeneration(effectiveConfig, processEnv);
const checkProvider = checkKiroProviderStatus(effectiveConfig, processEnv).pipe(
Effect.map(stampIdentity),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);
const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<KiroSettings>>({
maintenanceCapabilities,
getSettings: snapshotSettings.getSettings,
streamSettings: snapshotSettings.streamSettings,
haveSettingsChanged: haveProviderSnapshotSettingsChanged,
initialSnapshot: (settings) =>
buildInitialKiroProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)),
checkProvider,
enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>
enrichKiroSnapshot({
snapshot: currentSnapshot,
maintenanceCapabilities,
enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
publishSnapshot,
httpClient,
}),
refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
}).pipe(
Effect.mapError(
(cause) =>
new ProviderDriverError({
driver: DRIVER_KIND,
instanceId,
detail: `Failed to build Kiro snapshot: ${cause.message ?? String(cause)}`,
cause,
}),
),
);

return {
instanceId,
driverKind: DRIVER_KIND,
continuationIdentity,
displayName,
accentColor,
enabled,
snapshot,
adapter,
textGeneration,
} satisfies ProviderInstance;
}),
};
56 changes: 54 additions & 2 deletions apps/server/src/provider/Layers/CursorAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
assert.isDefined(delta);
if (delta?.type === "content.delta") {
assert.equal(delta.payload.delta, "hello from mock");
assert.match(String(delta.itemId), /^assistant:mock-session-1:segment:0$/);
assert.match(String(delta.itemId), /^assistant:mock-session-1:[a-z0-9-]+:segment:0$/);
}

const assistantCompleted = runtimeEvents.find(
Expand All @@ -250,6 +250,55 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
}),
);

it.effect(
"assigns distinct assistant message ids across runtime instances that share an ACP session id",
() =>
Effect.gen(function* () {
// Regression: a resumed session (session/load) keeps the same ACP
// session id but runs in a brand-new runtime whose assistant segment
// counter restarts at zero. Before the per-runtime nonce, both runtimes
// produced "assistant:mock-session-1:segment:0", so the resumed turn's
// reply upserted onto the earlier turn's message row — inheriting its
// stale created_at and sorting before the new prompt (reply invisible).
// The mock agent always negotiates "mock-session-1", so two sequential
// sessions reproduce that shared-session-id precondition.
const adapter = yield* CursorAdapter;
const settings = yield* ServerSettingsService;
const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper());
yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });

const captureAssistantItemId = (threadId: ThreadId) =>
Effect.gen(function* () {
const eventsFiber = yield* adapter.streamEvents.pipe(
Stream.filter((event) => event.threadId === threadId),
Stream.takeUntil((event) => event.type === "turn.completed"),
Stream.runCollect,
Effect.forkChild,
);
yield* adapter.startSession({
threadId,
provider: ProviderDriverKind.make("cursor"),
cwd: process.cwd(),
runtimeMode: "full-access",
modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" },
});
yield* adapter.sendTurn({ threadId, input: "hello mock", attachments: [] });
const events = Array.from(yield* Fiber.join(eventsFiber));
yield* adapter.stopSession(threadId);
const delta = events.find((event) => event.type === "content.delta");
assert.isDefined(delta);
return delta?.type === "content.delta" ? String(delta.itemId) : "";
});

const firstItemId = yield* captureAssistantItemId(ThreadId.make("cursor-resume-safety-1"));
const secondItemId = yield* captureAssistantItemId(ThreadId.make("cursor-resume-safety-2"));

assert.match(firstItemId, /^assistant:mock-session-1:[a-z0-9-]+:segment:0$/);
assert.match(secondItemId, /^assistant:mock-session-1:[a-z0-9-]+:segment:0$/);
assert.notEqual(firstItemId, secondItemId);
}),
);

it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () =>
Effect.gen(function* () {
const adapter = yield* CursorAdapter;
Expand Down Expand Up @@ -687,7 +736,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
if (contentDelta?.type === "content.delta") {
assert.equal(String(contentDelta.turnId), String(turn.turnId));
assert.equal(contentDelta.payload.delta, "hello from mock");
assert.equal(String(contentDelta.itemId), "assistant:mock-session-1:segment:0");
assert.match(
String(contentDelta.itemId),
/^assistant:mock-session-1:[a-z0-9-]+:segment:0$/,
);
}
});

Expand Down
Loading
Loading