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
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import * as SqlClient from "effect/sql/SqlClient";
import * as SqlitePersistence from "../persistence/Sqlite.ts";
import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
import * as DelegatedTaskQuestionReactor from "./DelegatedTaskQuestionReactor.ts";
import * as EffectOutbox from "./EffectOutbox.ts";
import * as EventSink from "./EventSink.ts";
import * as Orchestrator from "./Orchestrator.ts";
import * as ProjectionStore from "./ProjectionStore.ts";
Expand All @@ -48,6 +49,7 @@ const layerDatabase = SqlitePersistence.layerMemory;
const layerTest = Layer.mergeAll(
layerDatabase,
ProjectionStore.layer.pipe(Layer.provide(layerDatabase)),
EffectOutbox.layer.pipe(Layer.provide(layerDatabase)),
ProviderReplayHarness.layerWithRegistry(
{ name: "control-reads" },
ProviderAdapterRegistry.layerFromAdapters([adapter]),
Expand Down Expand Up @@ -124,6 +126,7 @@ it.effect(
const orchestrator = yield* Orchestrator.OrchestratorV2;
const projections = yield* ProjectionStore.ProjectionStoreV2;
const sql = yield* SqlClient.SqlClient;
const outbox = yield* EffectOutbox.EffectOutboxV2;
const threadId = ThreadId.make("thread:control-dispatch");
const now = yield* DateTime.now;
yield* orchestrator.dispatch({
Expand Down Expand Up @@ -270,14 +273,45 @@ it.effect(
questions: [],
},
});
const answers = { text: "Blue", list: ["Red"], codex: { answers: ["Green"] } };
const normalizedAnswers = { ...answers, codex: ["Green"] };
if (mode === "live") {
const pending = yield* projections.getRuntimeResponseContext(threadId, requestId);
const sequence = yield* orchestrator.getThreadEventSequence(threadId);
for (const [index, value] of [
null,
42,
{ choice: "Blue" },
["Blue", 42],
{ answers: [42] },
].entries()) {
const commandId = CommandId.make(`invalid-answer:${index}`);
const result = yield* orchestrator
.dispatch({
type: "runtime-request.respond",
commandId,
threadId,
requestId,
answers: { ...answers, invalid: value },
})
.pipe(Effect.result);
assert.isTrue(Result.isFailure(result));
assert.deepEqual(
yield* projections.getRuntimeResponseContext(threadId, requestId),
pending,
);
assert.equal(yield* orchestrator.getThreadEventSequence(threadId), sequence);
assert.deepEqual(yield* outbox.listByCommandId(commandId), []);
}
}
yield* orchestrator.dispatch(
mode === "live"
? {
type: "runtime-request.respond",
commandId: CommandId.make(`respond:${mode}`),
threadId,
requestId,
decision: "accept",
answers,
}
: {
type: "thread.user-input.dismiss",
Expand All @@ -293,6 +327,25 @@ it.effect(
const response = yield* projections.getRuntimeResponseContext(threadId, requestId);
assert.equal(response.node?.status, mode === "live" ? "completed" : "cancelled");
assert.equal(response.item?.status, mode === "live" ? "completed" : "cancelled");
if (mode === "live") {
assert.deepEqual(response.request?.answers, normalizedAnswers);
assert.equal(response.item?.type, "user_input_request");
if (response.item?.type === "user_input_request") {
assert.deepEqual(response.item.questionAnswer?.answers, normalizedAnswers);
}
const effects = yield* outbox.listByCommandId(CommandId.make(`respond:${mode}`));
assert.deepEqual(
effects.map((effect) => effect.request),
[
{
type: "runtime-request.respond",
providerSessionId: sessionId,
requestId,
answers: normalizedAnswers,
},
],
);
}
}
yield* orchestrator.dispatch({
type: "thread.metadata.update",
Expand Down
32 changes: 27 additions & 5 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,18 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId {
}
}

// Validate before resolving the request: adapters may otherwise coerce unusable answers.
const decodeUserInputAnswers = Schema.decodeUnknownEffect(
Schema.Record(
Schema.String,
Schema.Union([
Schema.String,
Schema.Array(Schema.String),
Schema.Struct({ answers: Schema.Array(Schema.String) }),
]),
),
);

function pendingThreadTitleGenerationEffect(
commandId: CommandId,
threadId: ThreadId,
Expand Down Expand Up @@ -7514,14 +7526,24 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
cause: `Provider session ${providerSessionId} was not found.`,
});
}
let answers = command.answers;
if (runtimeRequest.kind === "user_input" && answers !== undefined) {
const decoded = yield* decodeUserInputAnswers(answers).pipe(mapDispatchError(command));
answers = Object.fromEntries(
Object.entries(decoded).map(([questionId, answer]) => [
questionId,
typeof answer === "object" && "answers" in answer ? answer.answers : answer,
]),
);
}

const now = yield* DateTime.now;
const resolvedRequest = {
...runtimeRequest,
status: "resolved" as const,
resolvedAt: now,
...(command.decision === undefined ? {} : { decision: command.decision }),
...(command.answers === undefined ? {} : { answers: command.answers }),
...(answers === undefined ? {} : { answers }),
};
const emitEvent = emit(events, command);
const requestNode = context.node;
Expand Down Expand Up @@ -7580,11 +7602,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
occurredAt: now,
payload: {
...approvalTurnItem,
...(approvalTurnItem.type === "user_input_request" && command.answers !== undefined
...(approvalTurnItem.type === "user_input_request" && answers !== undefined
? {
questionAnswer: {
requestId: command.requestId,
answers: command.answers ?? {},
answers,
attachmentsByQuestionId: command.attachmentsByQuestionId ?? {},
questionTextById: Object.fromEntries(
approvalTurnItem.questions.map((question) => [
Expand Down Expand Up @@ -7612,7 +7634,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
}
const replies: string[] = [];
for (const question of approvalTurnItem.questions) {
const answer = command.answers?.[question.id];
const answer = answers?.[question.id];
if (typeof answer !== "string" || answer.trim().length === 0) {
if (question.required === false) continue;
return yield* new OrchestratorDispatchError({
Expand Down Expand Up @@ -7691,7 +7713,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
providerSessionId,
requestId: command.requestId,
...(command.decision === undefined ? {} : { decision: command.decision }),
...(command.answers === undefined ? {} : { answers: command.answers }),
...(answers === undefined ? {} : { answers }),
},
} satisfies PendingOrchestrationEffectV2,
]);
Expand Down
Loading