blob: ba1d798ecdb5b2653eed89af23234cec97e9cc54 [file]
import { randomUUID } from 'node:crypto';
import type { BackendKind, SessionEvent, SessionHeader, StoredMessage } from '@maka/core';
import type {
AgentBackend,
BackendSendInput,
HostedSandboxBoundarySettlement,
HostedUserQuestionAnswer,
HostedUserQuestionSettlement,
} from '@maka/core/backend-types';
import type {
SandboxBoundaryResponse,
SandboxBoundarySettlement,
} from '@maka/core/sandbox-boundary';
import type { UserQuestionResponse } from '@maka/core/user-question';
import {
RuntimeInteractionInvariantError,
type RuntimeUserQuestionClosureReason,
} from './interaction-authority.js';
import type { SessionStore } from './session-manager.js';
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const FAKE_ASK_USER_QUESTION_PROMPT = '__e2e_ask_user_question__';
export const FAKE_ASK_SANDBOX_BOUNDARY_PROMPT = '__e2e_ask_sandbox_boundary__';
export const FAKE_WAIT_FOR_STEERING_PROMPT = '__e2e_wait_for_steering__';
export const FAKE_MERMAID_PROMPT = '__e2e_mermaid__';
export const FAKE_MERMAID_HOSTILE_PROMPT = '__e2e_mermaid_hostile__';
type PendingQuestion = {
turnId: string;
requestId: string;
hosted: boolean;
resolve(response: UserQuestionResponse | null): void;
};
type PendingSandboxBoundary = {
turnId: string;
requestId: string;
hosted: boolean;
resolve(settlement: SandboxBoundarySettlement | null): void;
};
export class FakeBackend implements AgentBackend {
readonly kind: BackendKind = 'fake';
readonly sessionId: string;
private stopped = false;
private pendingQuestion: PendingQuestion | undefined;
private pendingSandboxBoundary: PendingSandboxBoundary | undefined;
constructor(
private readonly ctx: {
sessionId: string;
header: SessionHeader;
store: SessionStore;
appendMessage?: (message: StoredMessage) => Promise<void>;
},
) {
this.sessionId = ctx.sessionId;
}
async *send(input: BackendSendInput): AsyncIterable<SessionEvent> {
this.stopped = false;
if (input.text === FAKE_ASK_USER_QUESTION_PROMPT) {
yield* this.sendQuestionScenario(input);
return;
}
if (input.text === FAKE_ASK_SANDBOX_BOUNDARY_PROMPT) {
yield* this.sendSandboxBoundaryScenario(input);
return;
}
const turnId = input.turnId;
const messageId = randomUUID();
const attNames = (input.attachments ?? []).map((a) => a.name);
const attLine = attNames.length > 0 ? `\nAttachments received: ${attNames.join(', ')}` : '';
let text =
input.text === FAKE_MERMAID_PROMPT
? [
'Fake backend Mermaid fixture:',
'',
'```mermaid',
'flowchart TB',
'subgraph Input["1. Input layer"]',
' A[User prompt] --> B{Fence settled?}',
' B -->|No| C[Keep source visible]',
' B -->|Yes| D[Sanitize Markdown]',
'end',
'subgraph Render["2. Render layer"]',
' D --> E[Lazy-load Mermaid]',
' E --> F[Render strict SVG]',
' F --> G{Valid diagram?}',
' G -->|No| H[Show source fallback]',
'end',
'subgraph Inspect["3. Inspect layer"]',
' G -->|Yes| I[Fit to viewport]',
' I --> J{User action}',
' J -->|Zoom| K[Scale around pointer]',
' J -->|Pan| L[Move canvas]',
' J -->|Expand| M[Open fullscreen]',
' K --> N[Inspect details]',
' L --> N',
' M --> N',
'end',
'C --> O[Continue streaming]',
'H --> O',
'N --> P[Resume task]',
'```',
].join('\n')
: input.text === FAKE_MERMAID_HOSTILE_PROMPT
? [
'Fake backend hostile Mermaid fixture:',
'',
'```mermaid',
'%%{init: {"securityLevel":"loose","flowchart":{"htmlLabels":true}}}%%',
'flowchart LR',
' A["<img src=x onerror=alert(1)>"] --> B[Safe output]',
' click A "javascript:alert(1)"',
'```',
].join('\n')
: `Fake backend received: ${input.text}${attLine}\n\nThis proves the session stream, SQLite storage, and renderer loop are connected.`;
// Every delta must concatenate to text_complete; `.` would silently drop
// line terminators and make structured Markdown reflow only at completion.
const chunks = text.match(/[\s\S]{1,9}/g) ?? [text];
// Mid-turn steering: drain the caller's pending steering at each step
// boundary (here, between streamed chunks), echoing every message as a
// `steering_message` so the ledger/transcript render the interjection, and
// remembering them so the fake reply acknowledges them like a real model.
const steered: string[] = [];
// Lease accounting (backend-types contract): settlement is per LEASE,
// never per batch. A lease is acked only after its OWN echoed event has
// been received by the consumer — the fake has no durable ledger, so
// consumption is its delivery boundary, and resuming past an event's
// yield proves receipt. A consumer that detaches or throws lands in the
// finally, which nacks exactly the leases whose events never crossed
// their yield; batch settlement would nack an already-delivered lease
// into a redelivery.
const outstanding: string[] = [];
const settleOutstanding = (leaseId: string): void => {
const index = outstanding.indexOf(leaseId);
if (index === -1) return;
outstanding.splice(index, 1);
input.ackSteering?.([leaseId]);
};
const drainSteering = (): Array<{ leaseId: string; event: SessionEvent }> => {
const leases = input.pullSteering?.() ?? [];
if (leases.length === 0) return [];
outstanding.push(...leases.map((lease) => lease.id));
return leases.map((lease) => {
steered.push(lease.content.text);
return {
leaseId: lease.id,
event: {
type: 'steering_message',
id: randomUUID(),
turnId,
ts: Date.now(),
messageId: lease.messageId,
content: lease.content,
} satisfies SessionEvent,
};
});
};
try {
if (input.text === FAKE_WAIT_FOR_STEERING_PROMPT) {
let pending = drainSteering();
while (pending.length === 0 && !this.stopped) {
await sleep(5);
pending = drainSteering();
}
for (const { leaseId, event } of pending) {
yield event;
settleOutstanding(leaseId);
}
}
for (const chunk of chunks) {
if (this.stopped) {
yield { type: 'abort', id: randomUUID(), turnId, ts: Date.now(), reason: 'user_stop' };
yield {
type: 'complete',
id: randomUUID(),
turnId,
ts: Date.now(),
stopReason: 'user_stop',
};
return;
}
if (input.text !== FAKE_WAIT_FOR_STEERING_PROMPT) await sleep(45);
for (const { leaseId, event } of drainSteering()) {
yield event;
settleOutstanding(leaseId);
}
yield {
type: 'text_delta',
id: randomUUID(),
turnId,
ts: Date.now(),
messageId,
text: chunk,
};
}
// Final stranded drain (grok-build safety): a steer that landed after the
// last boundary still lands in this turn instead of being lost.
for (const { leaseId, event } of drainSteering()) {
yield event;
settleOutstanding(leaseId);
}
if (steered.length > 0) {
const ack = `\n\nAcknowledged steering: ${steered.join(' | ')}`;
text += ack;
yield {
type: 'text_delta',
id: randomUUID(),
turnId,
ts: Date.now(),
messageId,
text: ack,
};
}
const ts = Date.now();
const appendMessage =
this.ctx.appendMessage ??
((message: StoredMessage) => this.ctx.store.appendMessage(this.sessionId, message));
await appendMessage({
type: 'assistant',
id: messageId,
turnId,
ts,
text,
modelId: this.ctx.header.model,
});
yield { type: 'text_complete', id: randomUUID(), turnId, ts, messageId, text };
yield { type: 'complete', id: randomUUID(), turnId, ts: Date.now(), stopReason: 'end_turn' };
} finally {
if (outstanding.length > 0) input.nackSteering?.(outstanding.splice(0));
}
}
async stop(): Promise<void> {
this.stopped = true;
if (this.pendingQuestion && !this.pendingQuestion.hosted) {
this.pendingQuestion.resolve(null);
this.pendingQuestion = undefined;
}
if (this.pendingSandboxBoundary && !this.pendingSandboxBoundary.hosted) {
this.pendingSandboxBoundary.resolve(null);
this.pendingSandboxBoundary = undefined;
}
}
async respondToSandboxBoundary(_response: SandboxBoundaryResponse): Promise<void> {}
async respondToUserQuestion(response: UserQuestionResponse): Promise<void> {
if (this.pendingQuestion?.requestId !== response.requestId) return;
if (this.pendingQuestion.hosted) {
throw new RuntimeInteractionInvariantError(
`Hosted fake question ${response.requestId} must settle through its captured continuation`,
);
}
this.settleQuestionAnswer(this.pendingQuestion.turnId, response.requestId, response.answers);
}
async dispose(): Promise<void> {}
private async *sendQuestionScenario(input: BackendSendInput): AsyncIterable<SessionEvent> {
// A real model needs time to produce its first tool call. Mirror that
// boundary so a newly-created Desktop session can mount its event
// subscription before this deterministic fake emits the request.
await sleep(100);
const turnId = input.turnId;
if (this.stopped) {
yield { type: 'abort', id: randomUUID(), turnId, ts: Date.now(), reason: 'user_stop' };
yield { type: 'complete', id: randomUUID(), turnId, ts: Date.now(), stopReason: 'user_stop' };
return;
}
const toolUseId = randomUUID();
const requestId = randomUUID();
const stepId = randomUUID();
const questions = [
{
question: '首批发布范围选哪个?',
options: [
{ label: '邀请制', description: '先验证核心流程,再逐步扩大范围。' },
{ label: '公开测试', description: '允许所有访客注册,但保留 Beta 标识。' },
],
},
{
question: '上线时间怎么安排?',
options: [{ label: '本周' }, { label: '下周' }],
},
{
question: '是否同步发布公告?',
options: [{ label: '是' }, { label: '否' }],
},
];
const appendMessage =
this.ctx.appendMessage ??
((message: StoredMessage) => this.ctx.store.appendMessage(this.sessionId, message));
const startedAt = Date.now();
await appendMessage({
type: 'tool_call',
id: toolUseId,
turnId,
stepId,
ts: startedAt,
toolName: 'AskUserQuestion',
args: { questions },
});
yield {
type: 'tool_start',
id: randomUUID(),
turnId,
stepId,
ts: startedAt,
toolUseId,
toolName: 'AskUserQuestion',
args: { questions },
};
let resolveResponse!: (response: UserQuestionResponse | null) => void;
const responsePromise = new Promise<UserQuestionResponse | null>((resolve) => {
resolveResponse = resolve;
});
this.pendingQuestion = {
turnId,
requestId,
hosted: input.hostedInteraction !== undefined,
resolve: resolveResponse,
};
const request = {
type: 'user_question_request',
id: randomUUID(),
turnId,
ts: Date.now(),
requestId,
toolUseId,
questions,
} satisfies Extract<SessionEvent, { type: 'user_question_request' }>;
if (input.hostedInteraction) {
const settlement = this.createQuestionSettlement(turnId, requestId);
try {
await input.hostedInteraction.admitUserQuestionRequest({ request, settlement });
} catch (error) {
this.takePendingQuestion(turnId, requestId).resolve(null);
throw error;
}
}
yield request;
const response = await responsePromise;
if (this.pendingQuestion?.requestId === requestId) this.pendingQuestion = undefined;
if (!response || this.stopped) {
yield { type: 'abort', id: randomUUID(), turnId, ts: Date.now(), reason: 'user_stop' };
yield { type: 'complete', id: randomUUID(), turnId, ts: Date.now(), stopReason: 'user_stop' };
return;
}
yield {
type: 'user_question_answer_ack',
id: randomUUID(),
turnId,
ts: Date.now(),
requestId,
toolUseId,
};
const result = {
answers: questions.map((question, index) => ({
question: question.question,
answer: response.answers[index] ?? null,
})),
};
const resultContent = { kind: 'json' as const, value: result };
const resultTs = Date.now();
await appendMessage({
type: 'tool_result',
id: randomUUID(),
turnId,
ts: resultTs,
toolUseId,
isError: false,
content: resultContent,
});
yield {
type: 'tool_result',
id: randomUUID(),
turnId,
ts: resultTs,
toolUseId,
isError: false,
content: resultContent,
};
const messageId = randomUUID();
const text = `Fake question answers: ${response.answers.map((answer) => answer ?? '未回答').join(' / ')}`;
for (const chunk of text.match(/[\s\S]{1,9}/g) ?? [text]) {
yield {
type: 'text_delta',
id: randomUUID(),
turnId,
ts: Date.now(),
messageId,
text: chunk,
};
}
const completedAt = Date.now();
await appendMessage({
type: 'assistant',
id: messageId,
turnId,
ts: completedAt,
text,
modelId: this.ctx.header.model,
});
yield { type: 'text_complete', id: randomUUID(), turnId, ts: completedAt, messageId, text };
yield { type: 'complete', id: randomUUID(), turnId, ts: Date.now(), stopReason: 'end_turn' };
}
private async *sendSandboxBoundaryScenario(input: BackendSendInput): AsyncIterable<SessionEvent> {
await sleep(100);
const turnId = input.turnId;
if (this.stopped) {
yield { type: 'abort', id: randomUUID(), turnId, ts: Date.now(), reason: 'user_stop' };
yield { type: 'complete', id: randomUUID(), turnId, ts: Date.now(), stopReason: 'user_stop' };
return;
}
if (!input.hostedInteraction) {
throw new RuntimeInteractionInvariantError(
'Fake sandbox boundary scenario requires hosted Interaction authority',
);
}
const toolUseId = randomUUID();
const requestId = randomUUID();
const stepId = randomUUID();
const expansion = { network: { enabled: true as const } };
const justification = 'Connect to the deterministic fake test endpoint.';
const appendMessage =
this.ctx.appendMessage ??
((message: StoredMessage) => this.ctx.store.appendMessage(this.sessionId, message));
const startedAt = Date.now();
await appendMessage({
type: 'tool_call',
id: toolUseId,
turnId,
stepId,
ts: startedAt,
toolName: 'RequestSandboxBoundary',
args: { expansion, justification },
});
yield {
type: 'tool_start',
id: randomUUID(),
turnId,
stepId,
ts: startedAt,
toolUseId,
toolName: 'RequestSandboxBoundary',
args: { expansion, justification },
};
let resolveSettlement!: (settlement: SandboxBoundarySettlement | null) => void;
const settlementPromise = new Promise<SandboxBoundarySettlement | null>((resolve) => {
resolveSettlement = resolve;
});
this.pendingSandboxBoundary = {
turnId,
requestId,
hosted: true,
resolve: resolveSettlement,
};
const request = {
type: 'sandbox_boundary_request',
id: randomUUID(),
turnId,
ts: Date.now(),
requestId,
toolUseId,
expansion,
justification,
} satisfies Extract<SessionEvent, { type: 'sandbox_boundary_request' }>;
try {
await input.hostedInteraction.admitSandboxBoundaryRequest({
request,
settlement: this.createSandboxBoundarySettlement(turnId, requestId),
});
} catch (error) {
this.takePendingSandboxBoundary(turnId, requestId).resolve(null);
throw error;
}
yield request;
const settlement = await settlementPromise;
if (this.pendingSandboxBoundary?.requestId === requestId) {
this.pendingSandboxBoundary = undefined;
}
if (!settlement || this.stopped) {
yield { type: 'abort', id: randomUUID(), turnId, ts: Date.now(), reason: 'user_stop' };
yield { type: 'complete', id: randomUUID(), turnId, ts: Date.now(), stopReason: 'user_stop' };
return;
}
if (settlement.request.status === 'pending') {
throw new RuntimeInteractionInvariantError(
`Fake sandbox boundary settlement ${requestId} is still pending`,
);
}
const decision = settlement.request.status === 'denied' ? 'deny' : 'allow';
yield {
type: 'sandbox_boundary_decision_ack',
id: randomUUID(),
turnId,
ts: Date.now(),
requestId,
toolUseId,
decision,
status: settlement.request.status,
revision: settlement.boundary.revision,
};
const resultContent = {
kind: 'json' as const,
value: { decision, status: settlement.request.status },
};
const resultTs = Date.now();
await appendMessage({
type: 'tool_result',
id: randomUUID(),
turnId,
ts: resultTs,
toolUseId,
isError: decision === 'deny',
content: resultContent,
});
yield {
type: 'tool_result',
id: randomUUID(),
turnId,
ts: resultTs,
toolUseId,
isError: decision === 'deny',
content: resultContent,
};
const messageId = randomUUID();
const text = `Fake sandbox boundary decision: ${decision}`;
yield {
type: 'text_delta',
id: randomUUID(),
turnId,
ts: Date.now(),
messageId,
text,
};
const completedAt = Date.now();
await appendMessage({
type: 'assistant',
id: messageId,
turnId,
ts: completedAt,
text,
modelId: this.ctx.header.model,
});
yield { type: 'text_complete', id: randomUUID(), turnId, ts: completedAt, messageId, text };
yield { type: 'complete', id: randomUUID(), turnId, ts: Date.now(), stopReason: 'end_turn' };
}
private createQuestionSettlement(
turnId: string,
requestId: string,
): HostedUserQuestionSettlement {
return Object.freeze({
applyAnswer: async (answer: HostedUserQuestionAnswer): Promise<void> => {
if (Object.hasOwn(answer, 'requestId')) {
throw new RuntimeInteractionInvariantError(
`Fake question settlement ${requestId} received a routed answer`,
);
}
this.settleQuestionAnswer(turnId, requestId, answer.answers);
},
applyClosure: async (_reason: RuntimeUserQuestionClosureReason): Promise<void> => {
this.takePendingQuestion(turnId, requestId).resolve(null);
},
});
}
private createSandboxBoundarySettlement(
turnId: string,
requestId: string,
): HostedSandboxBoundarySettlement {
return Object.freeze({
applyDecision: async (settlement: SandboxBoundarySettlement): Promise<void> => {
if (
settlement.request.sessionId !== this.sessionId ||
settlement.request.requestId !== requestId
) {
throw new RuntimeInteractionInvariantError(
`Fake sandbox boundary settlement ${requestId} changed identity`,
);
}
this.takePendingSandboxBoundary(turnId, requestId).resolve(settlement);
},
applyClosure: async (_reason: RuntimeUserQuestionClosureReason): Promise<void> => {
this.takePendingSandboxBoundary(turnId, requestId).resolve(null);
},
});
}
private takePendingQuestion(turnId: string, requestId: string): PendingQuestion {
const pending = this.pendingQuestion;
if (!pending || pending.turnId !== turnId || pending.requestId !== requestId) {
throw new RuntimeInteractionInvariantError(
`Fake question settlement did not exact-take ${requestId} from turn ${turnId}`,
);
}
this.pendingQuestion = undefined;
return pending;
}
private takePendingSandboxBoundary(turnId: string, requestId: string): PendingSandboxBoundary {
const pending = this.pendingSandboxBoundary;
if (!pending || pending.turnId !== turnId || pending.requestId !== requestId) {
throw new RuntimeInteractionInvariantError(
`Fake sandbox boundary settlement did not exact-take ${requestId} from turn ${turnId}`,
);
}
this.pendingSandboxBoundary = undefined;
return pending;
}
private settleQuestionAnswer(
turnId: string,
requestId: string,
answers: readonly (string | null)[],
): void {
this.takePendingQuestion(turnId, requestId).resolve({
requestId,
answers: [...answers],
});
}
}