From d3a89c90eada8092157b2e39fa7a07d5afe13b4e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 28 Apr 2026 20:08:06 -0700 Subject: [PATCH] feat: add realistic kitchen sink fixture flows --- README.md | 20 +- scripts/check-kitchen-runtime.mjs | 54 ++++- scripts/check-pack-payload.mjs | 1 + src/kitchen-runtime.js | 46 +++- src/scenarios.js | 353 +++++++++++++++++++++++++++--- 5 files changed, 425 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 1ebd92b..5a0a2ca 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,10 @@ The fixture can be used dry, without an LLM: ```text kitchen image generate a kitchen sink +kitchen image rate limit +kitchen image timeout kitchen search kitchen sink provider routing +kitchen fetch kitchen://fixture/redirect kitchen explain the fixture ``` @@ -32,18 +35,23 @@ It also exposes provider and tool surfaces for live model routing: commands, tools, providers, hooks, channel delivery, and tests. - `kitchen_sink_image_job` returns a deterministic image job, waits 10 seconds in real runtime execution, then returns the bundled `kitchen_sink_office.png` - image payload. + image payload with PNG dimensions, byte size, SHA-256 hash, seed, model, and + finish metadata. - `kitchen-sink-image` is a registered image generation provider with aliases - `kitchen`, `kitchen-sink`, and `openclaw-kitchen-sink`. + `kitchen`, `kitchen-sink`, and `openclaw-kitchen-sink`; prompts containing + `rate limit`, `timeout`, or `fail` exercise deterministic provider error + paths. - `kitchen-sink-media` describes images with deterministic fixture text. - `kitchen-sink-search` and `kitchen-sink-fetch` provide credential-free web - tool fixtures. + tool fixtures with realistic status codes, request ids, result metadata, + redirects, headers, cache metadata, links, and markdown content. - `kitchen-sink-channel` is a credential-free channel fixture that can resolve - local accounts, route outbound sessions, and deliver deterministic text/media - records. + local ready/disabled/misconfigured accounts, route outbound sessions, and + deliver deterministic text/media records. - `kitchen-sink-llm` exposes a deterministic text-provider catalog row, provider-owned stream function, and prompt guidance so live LLM providers can - discover the Kitchen Sink routes. + discover the Kitchen Sink routes; responses describe which real plugin + surface would handle image, search, fetch, and failure prompts. - generated hooks classify Kitchen Sink prompts, tool calls, and provider selections into shared scenario ids such as `image.generate`, `web.search`, and `text.reply`. diff --git a/scripts/check-kitchen-runtime.mjs b/scripts/check-kitchen-runtime.mjs index d526735..41b3a18 100644 --- a/scripts/check-kitchen-runtime.mjs +++ b/scripts/check-kitchen-runtime.mjs @@ -53,6 +53,9 @@ const channel = findRegistration("registerChannel", "kitchen-sink-channel"); const channelAccount = channel.config.resolveAccount({}, "local"); assert.equal(channelAccount.configured, true); assert.equal(channelAccount.enabled, true); +assert.equal(channelAccount.statusState, "ready"); +assert.equal(channelAccount.health.ok, true); +assert.equal(channel.config.resolveAccount({ disabled: true }, "disabled").statusState, "disabled"); const channelDelivery = await channel.outbound.sendText({ cfg: {}, to: "kitchen demo", @@ -60,6 +63,8 @@ const channelDelivery = await channel.outbound.sendText({ }); assert.equal(channelDelivery.channel, "kitchen-sink-channel"); assert.equal(channelDelivery.conversationId, "kitchen-demo"); +assert.equal(channelDelivery.deliveryStatus, "sent"); +assert.equal(channelDelivery.transport, "kitchen-sink-local"); assert.equal(channelDelivery.meta.scenarioId, "image.generate"); const channelRoute = await channel.messaging.resolveOutboundSessionRoute({ cfg: {}, @@ -92,7 +97,7 @@ const imageProvider = findRegistration("registerImageGenerationProvider", "kitch assert.equal(imageProvider.defaultModel, "kitchen-sink-image-v1"); const sleeps = []; -const { PLUGIN_ID, runKitchenScenario } = await import("../src/scenarios.js"); +const { PLUGIN_ID, runKitchenImageTool, runKitchenScenario } = await import("../src/scenarios.js"); const { createKitchenSinkRuntime } = await import("../src/kitchen-runtime.js"); const fastRuntime = createKitchenSinkRuntime({ delayMs: 10_000, @@ -107,10 +112,24 @@ assert.equal(imageResult.scenarioId, "image.generate"); assert.equal(imageResult.route, "provider:image"); assert.equal(imageResult.job.status, "completed"); assert.equal(imageResult.job.pluginId, "openclaw-kitchen-sink-fixture"); +assert.equal(imageResult.job.progressPercent, 100); +assert.equal(imageResult.job.statusUrl, `kitchen://jobs/${imageResult.job.id}`); +assert.deepEqual( + imageResult.job.timeline.map((entry) => entry.status), + ["queued", "running", "completed"], +); +assert.equal(imageResult.job.output.contentHash, "e126064123bb13d8"); assert.equal(imageResult.image.metadata.pluginId, "openclaw-kitchen-sink-fixture"); +assert.equal(imageResult.image.metadata.assetId, "office-lobby-sink"); assert.equal(imageResult.image.metadata.assetName, "kitchen_sink_office.png"); +assert.equal(imageResult.image.metadata.source, "bundled-real-image"); +assert.equal(imageResult.image.metadata.model, "kitchen-sink-image-v1"); assert.equal(imageResult.image.metadata.width, 1024); assert.equal(imageResult.image.metadata.height, 1024); +assert.equal(imageResult.image.metadata.sizeBytes, 948291); +assert.equal(imageResult.image.metadata.sha256, "e126064123bb13d8ee01a22c204e079bc22397c103ed1c3a191c60d5ae3319aa"); +assert.equal(imageResult.image.metadata.contentHash, "e126064123bb13d8"); +assert.equal(imageResult.image.metadata.finishReason, "success"); assert.equal(imageResult.image.mimeType, "image/png"); assert.equal(imageResult.image.fileName, `${imageResult.job.id}.png`); assert.deepEqual( @@ -119,14 +138,35 @@ assert.deepEqual( ); assert.ok(imageResult.image.dataUrl.startsWith("data:image/png;base64,")); +const failedImageResult = await fastRuntime.runImageJob({ prompt: "kitchen rate limit image" }); +assert.deepEqual(sleeps, [10_000, 10_000]); +assert.equal(failedImageResult.job.status, "failed"); +assert.deepEqual( + failedImageResult.job.timeline.map((entry) => entry.status), + ["queued", "running", "failed"], +); +assert.equal(failedImageResult.error.code, "rate_limited"); +assert.equal(failedImageResult.error.statusCode, 429); +assert.equal(failedImageResult.error.retryAfterMs, 30_000); + +const failedToolResult = await runKitchenImageTool(fastRuntime, { prompt: "kitchen timeout image" }); +assert.equal(failedToolResult.ok, false); +assert.equal(failedToolResult.error.code, "timeout"); +assert.equal(failedToolResult.mediaUrl, undefined); + const scenarioResult = await runKitchenScenario(fastRuntime, { scenario: "web.fetch", - url: "kitchen://fixture/readme", + url: "kitchen://fixture/redirect", route: "test:scenario-engine", }); assert.equal(PLUGIN_ID, "openclaw-kitchen-sink-fixture"); assert.equal(scenarioResult.scenarioId, "web.fetch"); assert.equal(scenarioResult.route, "test:scenario-engine"); +assert.equal(scenarioResult.ok, true); +assert.equal(scenarioResult.statusCode, 200); +assert.equal(scenarioResult.finalUrl, "kitchen://fixture/readme"); +assert.equal(scenarioResult.redirects.length, 1); +assert.equal(scenarioResult.headers["x-kitchen-sink-fixture"], "true"); assert.match(scenarioResult.content, /deterministic document/); const mediaProvider = findRegistration("registerMediaUnderstandingProvider", "kitchen-sink-media"); @@ -141,11 +181,20 @@ const searchTool = searchProvider.createTool({}); const searchResult = await searchTool.execute({ query: "kitchen sink image provider" }); assert.equal(searchResult.results.length, 3); assert.equal(searchResult.provider, "kitchen-sink-search"); +assert.equal(searchResult.ok, true); +assert.equal(searchResult.statusCode, 200); +assert.equal(searchResult.results[0].id, "ks-result-image-provider"); +assert.equal(searchResult.results[0].metadata.provider, "kitchen-sink-image"); +const emptySearchResult = await searchTool.execute({ query: "kitchen empty results" }); +assert.equal(emptySearchResult.ok, true); +assert.equal(emptySearchResult.results.length, 0); const textProvider = findRegistration("registerProvider", "kitchen-sink-llm"); const catalog = await textProvider.staticCatalog.run({ config: {}, env: {} }); assert.equal(catalog.provider.models[0].id, "kitchen-sink-text-v1"); assert.equal(catalog.provider.models[0].api, "kitchen-sink"); +const authResult = await textProvider.auth[0].run(); +assert.equal(authResult.profiles[0].id, "kitchen-sink-local"); const streamFn = textProvider.createStreamFn({}); const stream = streamFn(catalog.provider.models[0], { messages: [{ role: "user", content: "kitchen explain text inference", timestamp: 0 }], @@ -157,6 +206,7 @@ for await (const event of stream) { const streamMessage = await stream.result(); assert.deepEqual(streamEvents, ["start", "text_start", "text_delta", "text_end", "done"]); assert.match(streamMessage.content[0].text, /kitchen explain text inference/); +assert.ok(streamMessage.usage.totalTokens > 0); const imageTool = findRegistration("registerTool", "kitchen_sink_image_job"); assert.equal(typeof imageTool.execute, "function"); diff --git a/scripts/check-pack-payload.mjs b/scripts/check-pack-payload.mjs index 4c9178f..5acf9bc 100644 --- a/scripts/check-pack-payload.mjs +++ b/scripts/check-pack-payload.mjs @@ -31,6 +31,7 @@ const requiredFiles = [ "openclaw.plugin.json", "plugin-inspector.config.json", "src/index.js", + "src/assets/kitchen_sink_office.png", "src/kitchen-runtime.js", "src/scenarios.js", "src/setup.js", diff --git a/src/kitchen-runtime.js b/src/kitchen-runtime.js index 24c6e51..bd363eb 100644 --- a/src/kitchen-runtime.js +++ b/src/kitchen-runtime.js @@ -181,10 +181,10 @@ function buildKitchenChannel() { config: { listAccountIds: () => [CHANNEL_ACCOUNT_ID], defaultAccountId: () => CHANNEL_ACCOUNT_ID, - resolveAccount: (_cfg, accountId) => kitchenChannelAccount(accountId || CHANNEL_ACCOUNT_ID), - isEnabled: () => true, - isConfigured: () => true, - describeAccount: (account) => kitchenChannelAccount(account.accountId), + resolveAccount: (cfg, accountId) => kitchenChannelAccount(accountId || CHANNEL_ACCOUNT_ID, cfg), + isEnabled: (cfg) => cfg?.disabled !== true, + isConfigured: (cfg) => cfg?.configured !== false, + describeAccount: (account) => kitchenChannelAccount(account.accountId, account), resolveDefaultTo: () => "kitchen", }, status: { @@ -265,17 +265,28 @@ function buildKitchenImageProvider(runtime) { scenario: "image.generate", prompt: req?.prompt, route: "provider:image", + model: req?.model, }); + if (result.error) { + throw kitchenProviderError(result); + } return { images: [stripDataUrl(result.image)], model: req?.model || DEFAULT_IMAGE_MODEL, metadata: { kitchenSink: true, job: result.job, + asset: result.image.metadata, provider: IMAGE_PROVIDER_ID, pluginId: PLUGIN_ID, scenarioId: result.scenarioId, route: result.route, + request: { + prompt: req?.prompt, + size: req?.size, + aspectRatio: req?.aspectRatio, + count: req?.count || 1, + }, }, }; }, @@ -313,7 +324,14 @@ function buildKitchenTextProvider() { hint: "Deterministic local fixture provider.", kind: "custom", run: async () => ({ - profiles: [], + profiles: [ + { + id: "kitchen-sink-local", + label: "Kitchen Sink Local", + configured: true, + source: "fixture", + }, + ], defaultModel: `${TEXT_PROVIDER_ID}/${DEFAULT_TEXT_MODEL}`, notes: ["Kitchen Sink LLM is deterministic and does not call a network service."], }), @@ -538,6 +556,24 @@ function pluginConfigPath() { return `plugins.${PLUGIN_ID}`; } +function kitchenProviderError(result) { + const error = new Error(result.error.message); + error.name = "KitchenSinkProviderError"; + error.code = result.error.code; + error.statusCode = result.error.statusCode; + error.retryable = result.error.retryable; + error.retryAfterMs = result.error.retryAfterMs; + error.metadata = { + kitchenSink: true, + job: result.job, + pluginId: PLUGIN_ID, + provider: IMAGE_PROVIDER_ID, + scenarioId: result.scenarioId, + route: result.route, + }; + return error; +} + function optionalRegister(api, method, register) { if (typeof api?.[method] !== "function") { return; diff --git a/src/scenarios.js b/src/scenarios.js index cd558bf..1a07001 100644 --- a/src/scenarios.js +++ b/src/scenarios.js @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; export const PLUGIN_ID = "openclaw-kitchen-sink-fixture"; @@ -16,6 +17,21 @@ const KITCHEN_SINK_OFFICE_IMAGE_FILE = "kitchen_sink_office.png"; const KITCHEN_SINK_OFFICE_IMAGE = readFileSync( new URL(`./assets/${KITCHEN_SINK_OFFICE_IMAGE_FILE}`, import.meta.url), ); +const KITCHEN_SINK_OFFICE_SHA256 = sha256Hex(KITCHEN_SINK_OFFICE_IMAGE); +const KITCHEN_IMAGE_FIXTURES = [ + { + id: "office-lobby-sink", + label: "Kitchen Sink Office", + assetName: KITCHEN_SINK_OFFICE_IMAGE_FILE, + buffer: KITCHEN_SINK_OFFICE_IMAGE, + sha256: KITCHEN_SINK_OFFICE_SHA256, + mimeType: "image/png", + width: 1024, + height: 1024, + description: "office lobby scene with a lobster-costumed figure holding a real sink", + source: "bundled-real-image", + }, +]; export function createKitchenScenarioRuntime(options = {}) { const runtime = { @@ -48,10 +64,43 @@ export async function runKitchenScenario(runtime, request = {}) { const scenario = normalizeScenario(request.scenario); if (scenario === "image.generate") { const prompt = normalizePrompt(request.prompt, "a kitchen sink fixture image"); - const job = createKitchenJob("image", prompt, runtime.now(), runtime.delayMs, scenario, request.route); + const queuedJob = createKitchenJob("image", prompt, runtime.now(), runtime.delayMs, scenario, request.route); + const runningJob = transitionKitchenJob(queuedJob, "running", runtime.now(), { + progressPercent: 50, + progressSummary: "Kitchen Sink image provider accepted the request.", + }); await runtime.sleep(runtime.delayMs); - const completedJob = { ...job, status: "completed", completedAt: runtime.now().toISOString() }; - const image = createKitchenSinkImageAsset({ prompt, jobId: job.id, scenario }); + const failure = classifyKitchenFailure(prompt); + if (failure) { + return { + scenarioId: scenario, + route: request.route || "provider:image", + job: transitionKitchenJob(runningJob, "failed", runtime.now(), { + error: failure, + progressPercent: 100, + progressSummary: failure.message, + }), + error: failure, + }; + } + const image = createKitchenSinkImageAsset({ + prompt, + jobId: queuedJob.id, + scenario, + model: request.model || DEFAULT_IMAGE_MODEL, + }); + const completedAt = runtime.now(); + const completedJob = transitionKitchenJob(runningJob, "completed", completedAt, { + completedAt: completedAt.toISOString(), + progressPercent: 100, + progressSummary: `Returned bundled ${image.metadata.assetName}.`, + output: { + fileName: image.fileName, + mimeType: image.mimeType, + sizeBytes: image.metadata.sizeBytes, + contentHash: image.metadata.contentHash, + }, + }); return { scenarioId: scenario, route: request.route || "provider:image", @@ -88,27 +137,44 @@ export async function runKitchenScenario(runtime, request = {}) { const prompt = normalizePrompt(request.prompt, "explain the kitchen sink fixture"); const job = createKitchenJob("text", prompt, runtime.now(), 0, scenario, request.route); + const text = kitchenTextResponse(prompt); + const completedAt = runtime.now(); return { scenarioId: "text.reply", route: request.route || "provider:text", - job: { ...job, status: "completed", completedAt: runtime.now().toISOString() }, - text: kitchenTextResponse(prompt), + job: transitionKitchenJob(job, "completed", completedAt, { + completedAt: completedAt.toISOString(), + progressPercent: 100, + progressSummary: "Kitchen Sink text provider produced a deterministic reply.", + }), + text, + usage: estimateUsage(prompt, text), }; } -export function createKitchenSinkImageAsset({ prompt, jobId, scenario = "image.generate" }) { - const buffer = Buffer.from(KITCHEN_SINK_OFFICE_IMAGE); +export function createKitchenSinkImageAsset({ prompt, jobId, scenario = "image.generate", model = DEFAULT_IMAGE_MODEL }) { + const fixture = selectKitchenImageFixture(prompt); + const buffer = Buffer.from(fixture.buffer); + const seed = stableHash(`${jobId}:${prompt}:${fixture.id}`); return { buffer, - mimeType: "image/png", + mimeType: fixture.mimeType, fileName: `${jobId}.png`, - dataUrl: `data:image/png;base64,${buffer.toString("base64")}`, - revisedPrompt: `Kitchen sink office fixture image for: ${prompt}`, + dataUrl: `data:${fixture.mimeType};base64,${buffer.toString("base64")}`, + revisedPrompt: `Kitchen Sink office image fixture: ${prompt}`, metadata: { kitchenSink: true, - assetName: KITCHEN_SINK_OFFICE_IMAGE_FILE, - width: 1024, - height: 1024, + assetId: fixture.id, + assetName: fixture.assetName, + source: fixture.source, + model, + width: fixture.width, + height: fixture.height, + sizeBytes: buffer.byteLength, + sha256: fixture.sha256, + contentHash: fixture.sha256.slice(0, 16), + seed, + finishReason: "success", pluginId: PLUGIN_ID, scenarioId: scenario, jobId, @@ -126,6 +192,7 @@ export function kitchenPromptGuidance() { "Kitchen Sink fixture plugin:", "- Use the kitchen_sink_image_job tool when the user asks for a kitchen sink image without selecting an image provider.", "- Use provider kitchen-sink-image for image generation when the configured image provider is Kitchen Sink.", + "- Image prompts containing rate limit, timeout, or fail trigger deterministic failure paths for retry/error handling.", "- Use kitchen_sink_search for deterministic search fixture queries.", "- Use kitchen_sink_text for deterministic text fixture responses.", ]; @@ -140,6 +207,8 @@ export function createKitchenChannelDelivery({ kind = "text", text = "", to = "k conversationId: normalizedTo, channelId: normalizedTo, timestamp: Date.now(), + deliveryStatus: "sent", + transport: "kitchen-sink-local", meta: { kitchenSink: true, pluginId: PLUGIN_ID, @@ -149,17 +218,29 @@ export function createKitchenChannelDelivery({ kind = "text", text = "", to = "k }; } -export function kitchenChannelAccount(accountId = CHANNEL_ACCOUNT_ID) { +export function kitchenChannelAccount(accountId = CHANNEL_ACCOUNT_ID, config = {}) { + const normalizedAccountId = accountId || CHANNEL_ACCOUNT_ID; + const enabled = normalizedAccountId !== "disabled" && config?.disabled !== true; + const configured = normalizedAccountId !== "missing" && config?.configured !== false; + const ok = enabled && configured; return { - accountId: accountId || CHANNEL_ACCOUNT_ID, - name: "Kitchen Sink Local", - enabled: true, - configured: true, - statusState: "fixture", - linked: true, - running: true, - connected: true, + accountId: normalizedAccountId, + name: normalizedAccountId === CHANNEL_ACCOUNT_ID ? "Kitchen Sink Local" : `Kitchen Sink ${normalizedAccountId}`, + enabled, + configured, + statusState: ok ? "ready" : enabled ? "needs_setup" : "disabled", + linked: configured, + running: ok, + connected: ok, mode: "local", + health: { + ok, + checkedAt: "2026-04-28T00:00:00.000Z", + message: ok + ? "Kitchen Sink local fixture account is ready." + : "Kitchen Sink local fixture account is intentionally unavailable.", + }, + capabilities: ["text", "media", "threads", "dry-run"], }; } @@ -202,32 +283,88 @@ export async function runKitchenImageTool(runtime, input) { prompt: readPrompt(input), route: "tool:kitchen_sink_image_job", }); + if (result.error) { + return { + ...result, + ok: false, + }; + } return { ...result, + ok: true, mediaUrl: result.image.dataUrl, }; } export async function runKitchenSearch(query) { const normalized = normalizePrompt(query, "kitchen sink"); + const requestId = `ks_search_${stableHash(normalized).slice(0, 10)}`; + const failure = classifyKitchenFailure(normalized); + if (failure) { + return { + provider: WEB_SEARCH_PROVIDER_ID, + requestId, + query: normalized, + ok: false, + statusCode: failure.statusCode, + latencyMs: 12, + error: failure, + results: [], + }; + } + if (/\b(empty|no results|zero)\b/i.test(normalized)) { + return { + provider: WEB_SEARCH_PROVIDER_ID, + requestId, + query: normalized, + ok: true, + statusCode: 200, + latencyMs: 18, + results: [], + answer: "No Kitchen Sink fixture results matched the deterministic empty-result query.", + }; + } return { provider: WEB_SEARCH_PROVIDER_ID, + requestId, query: normalized, + ok: true, + statusCode: 200, + latencyMs: 24, + answer: `Kitchen Sink found fixture routes for "${normalized}".`, results: [ { + id: "ks-result-image-provider", title: "Kitchen Sink image fixture", url: "https://github.com/openclaw/kitchen-sink#image-fixture", + displayUrl: "github.com/openclaw/kitchen-sink#image-fixture", snippet: `Deterministic image job route for "${normalized}".`, + source: "kitchen-sink-docs", + score: 0.98, + faviconUrl: "https://github.githubassets.com/favicons/favicon.svg", + metadata: { route: "provider:image", provider: IMAGE_PROVIDER_ID }, }, { + id: "ks-result-dry-command", title: "Kitchen Sink dry command route", url: "https://github.com/openclaw/kitchen-sink#dry-command-route", + displayUrl: "github.com/openclaw/kitchen-sink#dry-command-route", snippet: "The kitchen prefix works without live LLM credentials.", + source: "kitchen-sink-docs", + score: 0.91, + faviconUrl: "https://github.githubassets.com/favicons/favicon.svg", + metadata: { route: "prefix:kitchen", provider: "command" }, }, { + id: "ks-result-provider-route", title: "Kitchen Sink provider route", url: "https://github.com/openclaw/kitchen-sink#provider-route", + displayUrl: "github.com/openclaw/kitchen-sink#provider-route", snippet: "The image, media, text, fetch, and search providers are registered by the plugin.", + source: "kitchen-sink-docs", + score: 0.87, + faviconUrl: "https://github.githubassets.com/favicons/favicon.svg", + metadata: { route: "provider:*", provider: PLUGIN_ID }, }, ], }; @@ -235,25 +372,73 @@ export async function runKitchenSearch(query) { export async function runKitchenFetch(url) { const target = normalizePrompt(url, "kitchen://fixture/readme"); + const failure = classifyKitchenFailure(target); + const finalUrl = /\bredirect\b/i.test(target) ? "kitchen://fixture/readme" : target; + const missing = /\b(404|missing|not found)\b/i.test(target); + const statusCode = failure?.statusCode || (missing ? 404 : 200); + const ok = statusCode >= 200 && statusCode < 400; + const title = failure + ? "Kitchen Sink fixture error" + : missing + ? "Kitchen Sink fixture not found" + : "Kitchen Sink fixture document"; + const content = ok + ? `Kitchen Sink fetched "${finalUrl}". This deterministic document proves plugin web-fetch routing without network access.` + : `Kitchen Sink could not fetch "${target}" in the deterministic fixture corpus.`; return { provider: WEB_FETCH_PROVIDER_ID, + requestId: `ks_fetch_${stableHash(target).slice(0, 10)}`, + ok, + statusCode, url: target, - title: "Kitchen Sink fixture document", - content: `Kitchen Sink fetched "${target}". This deterministic document proves plugin web-fetch routing without network access.`, + finalUrl, + title, + contentType: "text/markdown; charset=utf-8", + headers: { + "cache-control": "max-age=3600", + "content-type": "text/markdown; charset=utf-8", + "x-kitchen-sink-fixture": "true", + }, + redirects: finalUrl === target ? [] : [{ statusCode: 302, from: target, to: finalUrl }], + cache: { status: "HIT", maxAgeSeconds: 3600 }, + links: [ + { href: "kitchen://fixture/image-provider", text: "Image provider fixture" }, + { href: "kitchen://fixture/search", text: "Search fixture" }, + ], + markdown: `# ${title}\n\n${content}\n`, + content, + ...(ok ? {} : { error: failure || { code: "not_found", message: "Fixture document was not found.", retryable: false } }), }; } export function kitchenImageReply(result) { + if (result.error) { + return { + text: `kitchen image job ${result.job.id} failed: ${result.error.message}`, + presentation: { + title: "Kitchen Sink Image Failed", + tone: "danger", + blocks: [ + { type: "text", text: `job: ${result.job.id}` }, + { type: "context", text: `code=${result.error.code} retryable=${String(result.error.retryable)}` }, + ], + }, + channelData: { + kitchenSink: result, + }, + }; + } return { text: `kitchen image job ${result.job.id} completed after ${Math.round(result.job.delayMs / 1000)}s. ` + - `provider=${IMAGE_PROVIDER_ID} model=${DEFAULT_IMAGE_MODEL}`, + `provider=${IMAGE_PROVIDER_ID} model=${result.image.metadata.model} asset=${result.image.metadata.assetName}`, mediaUrl: result.image.dataUrl, presentation: { title: "Kitchen Sink Image", tone: "success", blocks: [ { type: "text", text: `job: ${result.job.id}` }, + { type: "text", text: `asset: ${result.image.metadata.assetName}` }, { type: "context", text: result.image.revisedPrompt }, ], }, @@ -289,14 +474,15 @@ export function kitchenTextModelDefinition() { export function createKitchenTextStream(model, context) { const stream = createAssistantMessageEventStream(); queueMicrotask(() => { - const text = kitchenTextResponse(extractLastUserPrompt(context)); + const prompt = extractLastUserPrompt(context); + const text = kitchenTextResponse(prompt); const message = { role: "assistant", content: [{ type: "text", text }], api: model?.api || "kitchen-sink", provider: TEXT_PROVIDER_ID, model: model?.id || DEFAULT_TEXT_MODEL, - usage: zeroUsage(), + usage: estimateUsage(prompt, text), stopReason: "stop", timestamp: Date.now(), }; @@ -311,10 +497,32 @@ export function createKitchenTextStream(model, context) { } export function kitchenTextResponse(prompt) { + const normalized = normalizePrompt(prompt, "kitchen sink text inference"); + if (/\b(image|picture|draw|generate)\b/i.test(normalized)) { + return [ + "Kitchen Sink text fixture:", + `prompt="${normalized}"`, + `I would route this to ${IMAGE_PROVIDER_ID}/${DEFAULT_IMAGE_MODEL}, create a queued image job, wait for completion, then return the bundled kitchen_sink_office.png asset with PNG metadata.`, + ].join(" "); + } + if (/\b(search|find|lookup|web)\b/i.test(normalized)) { + return [ + "Kitchen Sink text fixture:", + `prompt="${normalized}"`, + `I would call ${WEB_SEARCH_PROVIDER_ID} for ranked fixture results and ${WEB_FETCH_PROVIDER_ID} for deterministic document fetches.`, + ].join(" "); + } + if (/\b(rate limit|timeout|fail|error)\b/i.test(normalized)) { + return [ + "Kitchen Sink text fixture:", + `prompt="${normalized}"`, + "Failure fixtures are available: rate limit returns 429 with retry metadata, timeout returns 504, and fail returns a deterministic provider error.", + ].join(" "); + } return [ - "kitchen sink text fixture:", - `prompt="${prompt}"`, - "routes: direct prefix, registered tools, image provider, media understanding, web search, web fetch, and text provider catalog.", + "Kitchen Sink text fixture:", + `prompt="${normalized}"`, + "Available realistic surfaces: direct prefix, registered tools, image provider lifecycle, media understanding, web search, web fetch, channel health, hooks, detached tasks, and text provider catalog.", ].join(" "); } @@ -352,6 +560,12 @@ export function stripDataUrl(image) { } export function renderSearchText(result) { + if (result.error) { + return `Kitchen Sink search failed: ${result.error.message}`; + } + if (result.results.length === 0) { + return result.answer || "Kitchen Sink search returned no results."; + } return result.results.map((entry, index) => `${index + 1}. ${entry.title} - ${entry.snippet}`).join("\n"); } @@ -408,16 +622,38 @@ export function observeKitchenHook(name, event, context) { function createKitchenJob(kind, prompt, date, delayMs, scenarioId, route) { const id = `ks_${kind}_${stableHash(`${kind}:${prompt}`).slice(0, 10)}`; + const createdAt = date.toISOString(); return { id, kind, - status: "running", + status: "queued", prompt, delayMs, - createdAt: date.toISOString(), + createdAt, + queuedAt: createdAt, + lastEventAt: createdAt, + progressPercent: 0, + progressSummary: "Kitchen Sink job queued.", pluginId: PLUGIN_ID, scenarioId, route: route || defaultRouteForScenario(scenarioId), + statusUrl: `kitchen://jobs/${id}`, + timeline: [{ status: "queued", at: createdAt, summary: "Kitchen Sink job queued." }], + }; +} + +function transitionKitchenJob(job, status, date, patch = {}) { + const at = date.toISOString(); + const summary = patch.progressSummary || `Kitchen Sink job ${status}.`; + return { + ...job, + ...patch, + status, + startedAt: status === "running" ? at : job.startedAt, + completedAt: status === "completed" ? patch.completedAt || at : job.completedAt, + failedAt: status === "failed" ? at : job.failedAt, + lastEventAt: at, + timeline: [...(job.timeline || []), { status, at, summary }], }; } @@ -503,13 +739,15 @@ function extractLastUserPrompt(context) { return "kitchen sink text inference"; } -function zeroUsage() { +function estimateUsage(prompt = "", text = "") { + const input = estimateTokens(prompt); + const output = estimateTokens(text); return { - input: 0, - output: 0, + input, + output, cacheRead: 0, cacheWrite: 0, - totalTokens: 0, + totalTokens: input + output, cost: { input: 0, output: 0, @@ -520,6 +758,49 @@ function zeroUsage() { }; } +function estimateTokens(text) { + return Math.max(1, Math.ceil(String(text).trim().split(/\s+/).filter(Boolean).length * 1.35)); +} + +function classifyKitchenFailure(prompt) { + const text = String(prompt ?? "").toLowerCase(); + if (/\brate[ -]?limit|429|too many requests\b/.test(text)) { + return { + code: "rate_limited", + statusCode: 429, + message: "Kitchen Sink fixture simulated a provider rate limit.", + retryable: true, + retryAfterMs: 30_000, + }; + } + if (/\btimeout|timed out|504\b/.test(text)) { + return { + code: "timeout", + statusCode: 504, + message: "Kitchen Sink fixture simulated an upstream timeout.", + retryable: true, + retryAfterMs: 5_000, + }; + } + if (/\bfail|error|500\b/.test(text)) { + return { + code: "fixture_failed", + statusCode: 500, + message: "Kitchen Sink fixture simulated a provider failure.", + retryable: false, + }; + } + return undefined; +} + +function selectKitchenImageFixture(_prompt) { + return KITCHEN_IMAGE_FIXTURES[0]; +} + +function sha256Hex(buffer) { + return createHash("sha256").update(buffer).digest("hex"); +} + function readString(input, key) { if (input && typeof input === "object" && typeof input[key] === "string") { return input[key];