diff --git a/.changeset/cli-json-schema-contract.md b/.changeset/cli-json-schema-contract.md new file mode 100644 index 00000000000..ebad9b2623b --- /dev/null +++ b/.changeset/cli-json-schema-contract.md @@ -0,0 +1,15 @@ +--- +"@kilocode/cli": minor +"@kilocode/core-schemas": minor +--- + +Add JSON schema contract (v1.0.0) for CLI --json/--json-io modes + +Output messages now include: + +- schemaVersion: Version identifier for automation compatibility +- messageId: Unique identifier for message tracking +- event: Unified event type for semantic categorization +- status: Message completion status (partial/complete) + +Input messages support both versioned (v1.0.0) and legacy formats for backward compatibility. diff --git a/cli/src/state/hooks/__tests__/useStdinJsonHandler.test.ts b/cli/src/state/hooks/__tests__/useStdinJsonHandler.test.ts index ae0779b0648..f32083f7b87 100644 --- a/cli/src/state/hooks/__tests__/useStdinJsonHandler.test.ts +++ b/cli/src/state/hooks/__tests__/useStdinJsonHandler.test.ts @@ -3,10 +3,20 @@ * * Tests the handleStdinMessage function which handles JSON messages * from stdin in jsonInteractive mode. + * + * Supports both: + * - Schema v1.0.0 (versioned format) + * - Legacy format (backward compatibility) */ import { describe, it, expect, vi, beforeEach } from "vitest" -import { handleStdinMessage, type StdinMessage, type StdinMessageHandlers } from "../useStdinJsonHandler.js" +import { JSON_SCHEMA_VERSION } from "@kilocode/core-schemas" +import { + handleStdinMessage, + handleVersionedMessage, + type StdinMessage, + type StdinMessageHandlers, +} from "../useStdinJsonHandler.js" describe("handleStdinMessage", () => { let handlers: StdinMessageHandlers @@ -246,3 +256,247 @@ describe("handleStdinMessage", () => { }) }) }) + +describe("handleVersionedMessage (schema v1.0.0)", () => { + let handlers: StdinMessageHandlers + let sendAskResponse: ReturnType + let cancelTask: ReturnType + let respondToTool: ReturnType + + beforeEach(() => { + sendAskResponse = vi.fn().mockResolvedValue(undefined) + cancelTask = vi.fn().mockResolvedValue(undefined) + respondToTool = vi.fn().mockResolvedValue(undefined) + + handlers = { + sendAskResponse, + cancelTask, + respondToTool, + } + }) + + describe("user_input messages", () => { + it("should call sendAskResponse for user_input", async () => { + const message = { + schemaVersion: JSON_SCHEMA_VERSION, + type: "user_input", + content: "hello world", + } + + const result = await handleVersionedMessage(message, handlers) + + expect(result.handled).toBe(true) + expect(sendAskResponse).toHaveBeenCalledWith({ + response: "messageResponse", + text: "hello world", + }) + }) + + it("should handle user_input without content", async () => { + const message = { + schemaVersion: JSON_SCHEMA_VERSION, + type: "user_input", + } + + const result = await handleVersionedMessage(message, handlers) + + expect(result.handled).toBe(true) + expect(sendAskResponse).toHaveBeenCalledWith({ + response: "messageResponse", + }) + }) + + it("should pass images in user_input", async () => { + const message = { + schemaVersion: JSON_SCHEMA_VERSION, + type: "user_input", + content: "check this", + images: ["screenshot.png"], + } + + const result = await handleVersionedMessage(message, handlers) + + expect(result.handled).toBe(true) + expect(sendAskResponse).toHaveBeenCalledWith({ + response: "messageResponse", + text: "check this", + images: ["screenshot.png"], + }) + }) + }) + + describe("approval messages", () => { + it("should call respondToTool with yesButtonClicked for approval", async () => { + const message = { + schemaVersion: JSON_SCHEMA_VERSION, + type: "approval", + } + + const result = await handleVersionedMessage(message, handlers) + + expect(result.handled).toBe(true) + expect(respondToTool).toHaveBeenCalledWith({ + response: "yesButtonClicked", + }) + }) + + it("should include content text when approving", async () => { + const message = { + schemaVersion: JSON_SCHEMA_VERSION, + type: "approval", + content: "approved with comment", + } + + await handleVersionedMessage(message, handlers) + + expect(respondToTool).toHaveBeenCalledWith({ + response: "yesButtonClicked", + text: "approved with comment", + }) + }) + + it("should pass images in approval", async () => { + const message = { + schemaVersion: JSON_SCHEMA_VERSION, + type: "approval", + images: ["screenshot.png"], + } + + await handleVersionedMessage(message, handlers) + + expect(respondToTool).toHaveBeenCalledWith({ + response: "yesButtonClicked", + images: ["screenshot.png"], + }) + }) + }) + + describe("rejection messages", () => { + it("should call respondToTool with noButtonClicked for rejection", async () => { + const message = { + schemaVersion: JSON_SCHEMA_VERSION, + type: "rejection", + } + + const result = await handleVersionedMessage(message, handlers) + + expect(result.handled).toBe(true) + expect(respondToTool).toHaveBeenCalledWith({ + response: "noButtonClicked", + }) + }) + + it("should include rejection reason as text", async () => { + const message = { + schemaVersion: JSON_SCHEMA_VERSION, + type: "rejection", + content: "not allowed for security reasons", + } + + await handleVersionedMessage(message, handlers) + + expect(respondToTool).toHaveBeenCalledWith({ + response: "noButtonClicked", + text: "not allowed for security reasons", + }) + }) + + it("should pass images in rejection", async () => { + const message = { + schemaVersion: JSON_SCHEMA_VERSION, + type: "rejection", + content: "rejected", + images: ["evidence.png"], + } + + await handleVersionedMessage(message, handlers) + + expect(respondToTool).toHaveBeenCalledWith({ + response: "noButtonClicked", + text: "rejected", + images: ["evidence.png"], + }) + }) + }) + + describe("abort messages", () => { + it("should call cancelTask for abort", async () => { + const message = { + schemaVersion: JSON_SCHEMA_VERSION, + type: "abort", + } + + const result = await handleVersionedMessage(message, handlers) + + expect(result.handled).toBe(true) + expect(cancelTask).toHaveBeenCalled() + expect(sendAskResponse).not.toHaveBeenCalled() + expect(respondToTool).not.toHaveBeenCalled() + }) + }) + + describe("response messages", () => { + it("should call sendAskResponse with retry_clicked", async () => { + const message = { + schemaVersion: JSON_SCHEMA_VERSION, + type: "response", + response: "retry_clicked", + } + + const result = await handleVersionedMessage(message, handlers) + + expect(result.handled).toBe(true) + expect(sendAskResponse).toHaveBeenCalledWith({ + response: "retry_clicked", + }) + }) + + it("should call sendAskResponse with objectResponse", async () => { + const message = { + schemaVersion: JSON_SCHEMA_VERSION, + type: "response", + response: "objectResponse", + content: JSON.stringify({ choice: "file1.ts" }), + } + + const result = await handleVersionedMessage(message, handlers) + + expect(result.handled).toBe(true) + expect(sendAskResponse).toHaveBeenCalledWith({ + response: "objectResponse", + text: JSON.stringify({ choice: "file1.ts" }), + }) + }) + + it("should call sendAskResponse with messageResponse via response type", async () => { + const message = { + schemaVersion: JSON_SCHEMA_VERSION, + type: "response", + response: "messageResponse", + content: "hello", + } + + const result = await handleVersionedMessage(message, handlers) + + expect(result.handled).toBe(true) + expect(sendAskResponse).toHaveBeenCalledWith({ + response: "messageResponse", + text: "hello", + }) + }) + }) + + describe("unknown message types", () => { + it("should return handled: false for unknown types", async () => { + const message = { + schemaVersion: JSON_SCHEMA_VERSION, + type: "unknownType", + } + + const result = await handleVersionedMessage(message, handlers) + + expect(result.handled).toBe(false) + expect(result.error).toBe("Unknown versioned message type: unknownType") + }) + }) +}) diff --git a/cli/src/state/hooks/useStdinJsonHandler.ts b/cli/src/state/hooks/useStdinJsonHandler.ts index 6ac2fbbef27..7d847db1ae0 100644 --- a/cli/src/state/hooks/useStdinJsonHandler.ts +++ b/cli/src/state/hooks/useStdinJsonHandler.ts @@ -1,11 +1,16 @@ /** * Hook to handle JSON messages from stdin in jsonInteractive mode. * This enables bidirectional communication with the Agent Manager. + * + * Supports both: + * - Schema v1.0.0 (versioned format with schemaVersion field) + * - Legacy format (for backward compatibility) */ import { useEffect } from "react" import { useSetAtom } from "jotai" import { createInterface } from "readline" +import { JSON_SCHEMA_VERSION, type VersionedInputMessage } from "@kilocode/core-schemas" import { sendAskResponseAtom, cancelTaskAtom, respondToToolAtom } from "../atoms/actions.js" import { logs } from "../../services/logs.js" @@ -15,10 +20,17 @@ export interface StdinMessage { text?: string images?: string[] approved?: boolean + // Schema v1.0.0 fields + schemaVersion?: string + content?: string } export interface StdinMessageHandlers { - sendAskResponse: (params: { response: "messageResponse"; text?: string; images?: string[] }) => Promise + sendAskResponse: (params: { + response?: "messageResponse" | "retry_clicked" | "objectResponse" + text?: string + images?: string[] + }) => Promise cancelTask: () => Promise respondToTool: (params: { response: "yesButtonClicked" | "noButtonClicked" @@ -78,6 +90,68 @@ export async function handleStdinMessage( } } +/** + * Check if a raw message is a versioned input (schema v1.0.0) + */ +function isVersionedInputMessage(message: unknown): boolean { + if (typeof message !== "object" || message === null) { + return false + } + const msg = message as Record + return "schemaVersion" in msg && msg.schemaVersion === JSON_SCHEMA_VERSION +} + +/** + * Handle a versioned input message (schema v1.0.0) + * Exported for testing purposes. + */ +export async function handleVersionedMessage( + message: VersionedInputMessage, + handlers: StdinMessageHandlers, +): Promise<{ handled: boolean; error?: string }> { + switch (message.type) { + case "user_input": + await handlers.sendAskResponse({ + response: "messageResponse", + ...(message.content !== undefined && { text: message.content }), + ...(message.images !== undefined && { images: message.images }), + }) + return { handled: true } + + case "approval": + await handlers.respondToTool({ + response: "yesButtonClicked", + ...(message.content !== undefined && { text: message.content }), + ...(message.images !== undefined && { images: message.images }), + }) + return { handled: true } + + case "rejection": + await handlers.respondToTool({ + response: "noButtonClicked", + ...(message.content !== undefined && { text: message.content }), + ...(message.images !== undefined && { images: message.images }), + }) + return { handled: true } + + case "abort": + await handlers.cancelTask() + return { handled: true } + + case "response": + // Handle special response types (retry_clicked, objectResponse, messageResponse) + await handlers.sendAskResponse({ + response: message.response, + ...(message.content !== undefined && { text: message.content }), + ...(message.images !== undefined && { images: message.images }), + }) + return { handled: true } + + default: + return { handled: false, error: `Unknown versioned message type: ${message.type}` } + } +} + export function useStdinJsonHandler(enabled: boolean) { const sendAskResponse = useSetAtom(sendAskResponseAtom) const cancelTask = useSetAtom(cancelTaskAtom) @@ -112,8 +186,34 @@ export function useStdinJsonHandler(enabled: boolean) { if (!trimmed) return try { - const message: StdinMessage = JSON.parse(trimmed) - logs.debug("Received stdin message", "useStdinJsonHandler", { type: message.type }) + const rawMessage = JSON.parse(trimmed) + + // Check for versioned input (schema v1.0.0) + if (isVersionedInputMessage(rawMessage)) { + const versioned = rawMessage as VersionedInputMessage + logs.debug("Received versioned stdin message", "useStdinJsonHandler", { + version: versioned.schemaVersion, + type: versioned.type, + }) + + const result = await handleVersionedMessage(versioned, handlers) + if (!result.handled) { + logs.warn("Unknown versioned message type", "useStdinJsonHandler", { type: versioned.type }) + } + return + } + + // Handle legacy format (backward compatibility) + const message: StdinMessage = rawMessage + if (!message.schemaVersion) { + logs.debug( + "Received legacy stdin message (consider upgrading to schema v1.0.0)", + "useStdinJsonHandler", + { + type: message.type, + }, + ) + } const result = await handleStdinMessage(message, handlers) if (!result.handled) { diff --git a/cli/src/ui/utils/__tests__/eventMapper.test.ts b/cli/src/ui/utils/__tests__/eventMapper.test.ts new file mode 100644 index 00000000000..5e731ae8575 --- /dev/null +++ b/cli/src/ui/utils/__tests__/eventMapper.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from "vitest" +import { getCliMessageEvent, getExtensionMessageEvent } from "../eventMapper.js" +import type { CliMessage } from "../../../types/cli.js" +import type { ExtensionChatMessage } from "../../../types/messages.js" + +function createCliMessage(overrides: Partial): CliMessage { + return { + id: "test-id", + type: "assistant", + content: "test content", + ts: Date.now(), + ...overrides, + } +} + +function createExtensionMessage(overrides: Partial): ExtensionChatMessage { + return { + ts: Date.now(), + type: "say", + ...overrides, + } +} + +describe("getCliMessageEvent", () => { + it("maps welcome type to system.welcome", () => { + const message = createCliMessage({ type: "welcome" }) + expect(getCliMessageEvent(message)).toBe("system.welcome") + }) + + it("maps error type to system.error", () => { + const message = createCliMessage({ type: "error" }) + expect(getCliMessageEvent(message)).toBe("system.error") + }) + + it("maps system type to system.info", () => { + const message = createCliMessage({ type: "system" }) + expect(getCliMessageEvent(message)).toBe("system.info") + }) + + it("maps empty type to system.empty", () => { + const message = createCliMessage({ type: "empty" }) + expect(getCliMessageEvent(message)).toBe("system.empty") + }) + + it("maps user type to user.message", () => { + const message = createCliMessage({ type: "user" }) + expect(getCliMessageEvent(message)).toBe("user.message") + }) + + it("maps assistant type to assistant.message", () => { + const message = createCliMessage({ type: "assistant" }) + expect(getCliMessageEvent(message)).toBe("assistant.message") + }) + + it("maps requestCheckpointRestoreApproval to task.checkpoint", () => { + const message = createCliMessage({ type: "requestCheckpointRestoreApproval" }) + expect(getCliMessageEvent(message)).toBe("task.checkpoint") + }) +}) + +describe("getExtensionMessageEvent", () => { + describe("ask messages", () => { + it("maps tool ask to tool.request", () => { + const message = createExtensionMessage({ type: "ask", ask: "tool" }) + expect(getExtensionMessageEvent(message)).toBe("tool.request") + }) + + it("maps command ask to tool.request", () => { + const message = createExtensionMessage({ type: "ask", ask: "command" }) + expect(getExtensionMessageEvent(message)).toBe("tool.request") + }) + + it("maps followup ask to user.approval", () => { + const message = createExtensionMessage({ type: "ask", ask: "followup" }) + expect(getExtensionMessageEvent(message)).toBe("user.approval") + }) + + it("maps api_req_failed ask to api.request_failed", () => { + const message = createExtensionMessage({ type: "ask", ask: "api_req_failed" }) + expect(getExtensionMessageEvent(message)).toBe("api.request_failed") + }) + + it("maps completion_result ask to task.completed", () => { + const message = createExtensionMessage({ type: "ask", ask: "completion_result" }) + expect(getExtensionMessageEvent(message)).toBe("task.completed") + }) + + it("maps resume_task ask to task.resumed", () => { + const message = createExtensionMessage({ type: "ask", ask: "resume_task" }) + expect(getExtensionMessageEvent(message)).toBe("task.resumed") + }) + + it("maps command_output ask to tool.request", () => { + const message = createExtensionMessage({ type: "ask", ask: "command_output" }) + expect(getExtensionMessageEvent(message)).toBe("tool.request") + }) + + it("maps unknown ask to unknown", () => { + const message = createExtensionMessage({ type: "ask", ask: "some_unknown_type" }) + expect(getExtensionMessageEvent(message)).toBe("unknown") + }) + }) + + describe("say messages", () => { + it("maps text say to assistant.message", () => { + const message = createExtensionMessage({ type: "say", say: "text" }) + expect(getExtensionMessageEvent(message)).toBe("assistant.message") + }) + + it("maps user_feedback say to user.message", () => { + const message = createExtensionMessage({ type: "say", say: "user_feedback" }) + expect(getExtensionMessageEvent(message)).toBe("user.message") + }) + + it("maps user_feedback_diff say to user.message", () => { + const message = createExtensionMessage({ type: "say", say: "user_feedback_diff" }) + expect(getExtensionMessageEvent(message)).toBe("user.message") + }) + + it("maps reasoning say to assistant.reasoning", () => { + const message = createExtensionMessage({ type: "say", say: "reasoning" }) + expect(getExtensionMessageEvent(message)).toBe("assistant.reasoning") + }) + + it("maps completion_result say to assistant.completion", () => { + const message = createExtensionMessage({ type: "say", say: "completion_result" }) + expect(getExtensionMessageEvent(message)).toBe("assistant.completion") + }) + + it("maps api_req_started say to api.request_started", () => { + const message = createExtensionMessage({ type: "say", say: "api_req_started" }) + expect(getExtensionMessageEvent(message)).toBe("api.request_started") + }) + + it("maps api_req_finished say to api.request_completed", () => { + const message = createExtensionMessage({ type: "say", say: "api_req_finished" }) + expect(getExtensionMessageEvent(message)).toBe("api.request_completed") + }) + + it("maps command_output say to tool.output", () => { + const message = createExtensionMessage({ type: "say", say: "command_output" }) + expect(getExtensionMessageEvent(message)).toBe("tool.output") + }) + + it("maps condense_context say to context.condensed", () => { + const message = createExtensionMessage({ type: "say", say: "condense_context" }) + expect(getExtensionMessageEvent(message)).toBe("context.condensed") + }) + + it("maps sliding_window_truncation say to context.truncated", () => { + const message = createExtensionMessage({ type: "say", say: "sliding_window_truncation" }) + expect(getExtensionMessageEvent(message)).toBe("context.truncated") + }) + + it("maps error say to system.error", () => { + const message = createExtensionMessage({ type: "say", say: "error" }) + expect(getExtensionMessageEvent(message)).toBe("system.error") + }) + + it("maps unknown say to unknown", () => { + const message = createExtensionMessage({ type: "say", say: "some_unknown_type" }) + expect(getExtensionMessageEvent(message)).toBe("unknown") + }) + }) + + it("returns unknown for message without ask or say", () => { + const message = createExtensionMessage({ type: "ask" }) + expect(getExtensionMessageEvent(message)).toBe("unknown") + }) +}) diff --git a/cli/src/ui/utils/__tests__/jsonOutput.test.ts b/cli/src/ui/utils/__tests__/jsonOutput.test.ts index f8349097df4..a292867836c 100644 --- a/cli/src/ui/utils/__tests__/jsonOutput.test.ts +++ b/cli/src/ui/utils/__tests__/jsonOutput.test.ts @@ -3,9 +3,12 @@ * * This test suite verifies that messages are correctly formatted as JSON * for CI mode and other non-interactive output scenarios. + * + * Schema v1.0.0 adds: schemaVersion, messageId, event, status */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import { JSON_SCHEMA_VERSION } from "@kilocode/core-schemas" import { formatMessageAsJson, outputJsonMessage, outputJsonMessages } from "../jsonOutput.js" import type { UnifiedMessage } from "../../../state/atoms/ui.js" import type { ExtensionChatMessage } from "../../../types/messages.js" @@ -23,8 +26,10 @@ describe("jsonOutput", () => { }) describe("formatMessageAsJson", () => { - it("should format basic CLI message", () => { + it("should format basic CLI message with schema v1.0.0 fields", () => { const cliMessage: CliMessage = { + id: "test-id", + type: "assistant", ts: 1234567890, content: "Hello from CLI", } @@ -36,11 +41,34 @@ describe("jsonOutput", () => { const result = formatMessageAsJson(unifiedMessage) - expect(result).toEqual({ - timestamp: 1234567890, + // Verify v1.0.0 schema fields + expect(result.schemaVersion).toBe(JSON_SCHEMA_VERSION) + expect(result.messageId).toMatch(/^cli-1234567890-/) + expect(result.event).toBe("assistant.message") + expect(result.status).toBe("complete") + + // Verify backward-compatible fields + expect(result.timestamp).toBe(1234567890) + expect(result.source).toBe("cli") + expect(result.content).toBe("Hello from CLI") + }) + + it("should set status to partial for partial CLI messages", () => { + const cliMessage: CliMessage = { + id: "test-id", + type: "assistant", + ts: 1234567890, + content: "Hello", + partial: true, + } + + const unifiedMessage: UnifiedMessage = { source: "cli", - content: "Hello from CLI", - }) + message: cliMessage, + } + + const result = formatMessageAsJson(unifiedMessage) + expect(result.status).toBe("partial") }) it("should parse valid JSON in text field and move to metadata", () => { @@ -61,15 +89,20 @@ describe("jsonOutput", () => { const result = formatMessageAsJson(unifiedMessage) - expect(result).toEqual({ - timestamp: 1234567890, - source: "extension", - type: "ask", - ask: "tool", - metadata: { - tool: "readFile", - path: "test.ts", - }, + // Verify v1.0.0 schema fields + expect(result.schemaVersion).toBe(JSON_SCHEMA_VERSION) + expect(result.messageId).toMatch(/^ext-1234567890-/) + expect(result.event).toBe("tool.request") + expect(result.status).toBe("complete") + + // Verify backward-compatible fields + expect(result.timestamp).toBe(1234567890) + expect(result.source).toBe("extension") + expect(result.type).toBe("ask") + expect(result.ask).toBe("tool") + expect(result.metadata).toEqual({ + tool: "readFile", + path: "test.ts", }) expect(result).not.toHaveProperty("content") }) @@ -92,16 +125,21 @@ describe("jsonOutput", () => { const result = formatMessageAsJson(unifiedMessage) - expect(result).toEqual({ - timestamp: 1234567890, - source: "extension", - type: "say", - say: "codebase_search_result", - metadata: [ - { file: "test1.ts", line: 10 }, - { file: "test2.ts", line: 20 }, - ], - }) + // Verify v1.0.0 schema fields are present + expect(result.schemaVersion).toBe(JSON_SCHEMA_VERSION) + expect(result.messageId).toMatch(/^ext-/) + expect(result.event).toBe("assistant.message") + expect(result.status).toBe("complete") + + // Verify backward-compatible fields + expect(result.timestamp).toBe(1234567890) + expect(result.source).toBe("extension") + expect(result.type).toBe("say") + expect(result.say).toBe("codebase_search_result") + expect(result.metadata).toEqual([ + { file: "test1.ts", line: 10 }, + { file: "test2.ts", line: 20 }, + ]) }) it("should keep JSON primitives as content", () => { @@ -119,13 +157,18 @@ describe("jsonOutput", () => { const result = formatMessageAsJson(unifiedMessage) - expect(result).toEqual({ - timestamp: 1234567890, - source: "extension", - type: "say", - say: "text", - content: "null", - }) + // Verify v1.0.0 schema fields + expect(result.schemaVersion).toBe(JSON_SCHEMA_VERSION) + expect(result.messageId).toMatch(/^ext-/) + expect(result.event).toBe("assistant.message") + expect(result.status).toBe("complete") + + // Verify backward-compatible fields + expect(result.timestamp).toBe(1234567890) + expect(result.source).toBe("extension") + expect(result.type).toBe("say") + expect(result.say).toBe("text") + expect(result.content).toBe("null") }) it("should handle malformed JSON as plain text", () => { @@ -143,13 +186,18 @@ describe("jsonOutput", () => { const result = formatMessageAsJson(unifiedMessage) - expect(result).toEqual({ - timestamp: 1234567890, - source: "extension", - type: "ask", - ask: "tool", - content: "{ invalid json", - }) + // Verify v1.0.0 schema fields + expect(result.schemaVersion).toBe(JSON_SCHEMA_VERSION) + expect(result.messageId).toMatch(/^ext-/) + expect(result.event).toBe("tool.request") + expect(result.status).toBe("complete") + + // Verify backward-compatible fields + expect(result.timestamp).toBe(1234567890) + expect(result.source).toBe("extension") + expect(result.type).toBe("ask") + expect(result.ask).toBe("tool") + expect(result.content).toBe("{ invalid json") expect(result).not.toHaveProperty("metadata") }) @@ -167,12 +215,19 @@ describe("jsonOutput", () => { const result = formatMessageAsJson(unifiedMessage) - expect(result).toEqual({ - timestamp: 1234567890, - source: "extension", - type: "say", - say: "error", - }) + // Verify v1.0.0 schema fields + expect(result.schemaVersion).toBe(JSON_SCHEMA_VERSION) + expect(result.messageId).toMatch(/^ext-/) + expect(result.event).toBe("system.error") + expect(result.status).toBe("complete") + + // Verify backward-compatible fields + expect(result.timestamp).toBe(1234567890) + expect(result.source).toBe("extension") + expect(result.type).toBe("say") + expect(result.say).toBe("error") + expect(result).not.toHaveProperty("content") + expect(result).not.toHaveProperty("metadata") }) }) @@ -194,13 +249,19 @@ describe("jsonOutput", () => { expect(consoleLogSpy).toHaveBeenCalledTimes(1) const output = JSON.parse(consoleLogSpy.mock.calls[0][0]) - expect(output).toEqual({ - timestamp: 1234567890, - source: "extension", - type: "say", - say: "text", - content: "Test message", - }) + + // Verify v1.0.0 schema fields + expect(output.schemaVersion).toBe(JSON_SCHEMA_VERSION) + expect(output.messageId).toMatch(/^ext-/) + expect(output.event).toBe("assistant.message") + expect(output.status).toBe("complete") + + // Verify backward-compatible fields + expect(output.timestamp).toBe(1234567890) + expect(output.source).toBe("extension") + expect(output.type).toBe("say") + expect(output.say).toBe("text") + expect(output.content).toBe("Test message") }) }) @@ -232,20 +293,23 @@ describe("jsonOutput", () => { expect(consoleLogSpy).toHaveBeenCalledTimes(1) const output = JSON.parse(consoleLogSpy.mock.calls[0][0]) expect(output).toHaveLength(2) - expect(output[0]).toEqual({ - timestamp: 1234567890, - source: "extension", - type: "say", - say: "text", - content: "Message 1", - }) - expect(output[1]).toEqual({ - timestamp: 1234567891, - source: "extension", - type: "say", - say: "text", - content: "Message 2", - }) + + // Verify first message has v1.0.0 schema fields + expect(output[0].schemaVersion).toBe(JSON_SCHEMA_VERSION) + expect(output[0].messageId).toMatch(/^ext-/) + expect(output[0].event).toBe("assistant.message") + expect(output[0].status).toBe("complete") + expect(output[0].timestamp).toBe(1234567890) + expect(output[0].source).toBe("extension") + expect(output[0].type).toBe("say") + expect(output[0].say).toBe("text") + expect(output[0].content).toBe("Message 1") + + // Verify second message + expect(output[1].schemaVersion).toBe(JSON_SCHEMA_VERSION) + expect(output[1].messageId).toMatch(/^ext-/) + expect(output[1].timestamp).toBe(1234567891) + expect(output[1].content).toBe("Message 2") }) }) }) diff --git a/cli/src/ui/utils/__tests__/messageId.test.ts b/cli/src/ui/utils/__tests__/messageId.test.ts new file mode 100644 index 00000000000..55353ffcd99 --- /dev/null +++ b/cli/src/ui/utils/__tests__/messageId.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from "vitest" +import { generateCliMessageId, generateExtensionMessageId } from "../messageId.js" +import type { CliMessage } from "../../../types/cli.js" +import type { ExtensionChatMessage } from "../../../types/messages.js" + +function createCliMessage(overrides: Partial): CliMessage { + return { + id: "test-id", + type: "assistant", + content: "test content", + ts: 1704067200000, + ...overrides, + } +} + +function createExtensionMessage(overrides: Partial): ExtensionChatMessage { + return { + ts: 1704067200000, + type: "say", + ...overrides, + } +} + +describe("generateCliMessageId", () => { + it("generates ID with cli prefix", () => { + const message = createCliMessage({}) + const id = generateCliMessageId(message) + expect(id).toMatch(/^cli-/) + }) + + it("includes timestamp in ID", () => { + const message = createCliMessage({ ts: 1704067200000 }) + const id = generateCliMessageId(message) + expect(id).toContain("1704067200000") + }) + + it("generates deterministic IDs for same input", () => { + const message = createCliMessage({ id: "unique-id", ts: 1704067200000 }) + const id1 = generateCliMessageId(message) + const id2 = generateCliMessageId(message) + expect(id1).toBe(id2) + }) + + it("generates different IDs for different messages", () => { + const message1 = createCliMessage({ id: "id-1", ts: 1704067200000 }) + const message2 = createCliMessage({ id: "id-2", ts: 1704067200000 }) + const id1 = generateCliMessageId(message1) + const id2 = generateCliMessageId(message2) + expect(id1).not.toBe(id2) + }) + + it("handles messages without id by using content", () => { + const message = createCliMessage({ id: "", content: "some content", ts: 1704067200000 }) + const id = generateCliMessageId(message) + expect(id).toMatch(/^cli-1704067200000-/) + }) + + it("handles messages without content by using type", () => { + const message = createCliMessage({ id: "", content: "", type: "error", ts: 1704067200000 }) + const id = generateCliMessageId(message) + expect(id).toMatch(/^cli-1704067200000-/) + }) +}) + +describe("generateExtensionMessageId", () => { + it("generates ID with ext prefix", () => { + const message = createExtensionMessage({}) + const id = generateExtensionMessageId(message) + expect(id).toMatch(/^ext-/) + }) + + it("includes timestamp in ID", () => { + const message = createExtensionMessage({ ts: 1704067200000 }) + const id = generateExtensionMessageId(message) + expect(id).toContain("1704067200000") + }) + + it("generates deterministic IDs for same input", () => { + const message = createExtensionMessage({ type: "say", say: "text", text: "hello", ts: 1704067200000 }) + const id1 = generateExtensionMessageId(message) + const id2 = generateExtensionMessageId(message) + expect(id1).toBe(id2) + }) + + it("generates different IDs for different message types", () => { + const message1 = createExtensionMessage({ type: "ask", ask: "tool", ts: 1704067200000 }) + const message2 = createExtensionMessage({ type: "say", say: "text", ts: 1704067200000 }) + const id1 = generateExtensionMessageId(message1) + const id2 = generateExtensionMessageId(message2) + expect(id1).not.toBe(id2) + }) + + it("includes ask type in hash calculation", () => { + const message1 = createExtensionMessage({ type: "ask", ask: "tool", ts: 1704067200000 }) + const message2 = createExtensionMessage({ type: "ask", ask: "command", ts: 1704067200000 }) + const id1 = generateExtensionMessageId(message1) + const id2 = generateExtensionMessageId(message2) + expect(id1).not.toBe(id2) + }) + + it("includes say type in hash calculation", () => { + const message1 = createExtensionMessage({ type: "say", say: "text", ts: 1704067200000 }) + const message2 = createExtensionMessage({ type: "say", say: "error", ts: 1704067200000 }) + const id1 = generateExtensionMessageId(message1) + const id2 = generateExtensionMessageId(message2) + expect(id1).not.toBe(id2) + }) + + it("generates same ID for messages with different text (streaming stability)", () => { + // During streaming, text changes but messageId should remain stable + const partialMessage = createExtensionMessage({ + type: "say", + say: "text", + text: "Hello", + partial: true, + ts: 1704067200000, + }) + const completeMessage = createExtensionMessage({ + type: "say", + say: "text", + text: "Hello, world!", + partial: false, + ts: 1704067200000, + }) + const id1 = generateExtensionMessageId(partialMessage) + const id2 = generateExtensionMessageId(completeMessage) + expect(id1).toBe(id2) // Same ID for streaming updates + }) +}) diff --git a/cli/src/ui/utils/eventMapper.ts b/cli/src/ui/utils/eventMapper.ts new file mode 100644 index 00000000000..ce8fe0ecf92 --- /dev/null +++ b/cli/src/ui/utils/eventMapper.ts @@ -0,0 +1,32 @@ +/** + * Event mapping utilities for JSON schema contract + * + * Maps various message formats to unified event types for consistent + * automation integration. + */ + +import { type OutputEvent, mapCliTypeToEvent, mapAskToEvent, mapSayToEvent } from "@kilocode/core-schemas" +import type { CliMessage } from "../../types/cli.js" +import type { ExtensionChatMessage } from "../../types/messages.js" + +/** + * Get unified event type from a CLI message + */ +export function getCliMessageEvent(message: CliMessage): OutputEvent { + return mapCliTypeToEvent(message.type) +} + +/** + * Get unified event type from an extension message + */ +export function getExtensionMessageEvent(message: ExtensionChatMessage): OutputEvent { + if (message.type === "ask" && message.ask) { + return mapAskToEvent(message.ask) + } + + if (message.type === "say" && message.say) { + return mapSayToEvent(message.say) + } + + return "unknown" +} diff --git a/cli/src/ui/utils/jsonOutput.ts b/cli/src/ui/utils/jsonOutput.ts index 729151c1447..fa5b8c5d299 100644 --- a/cli/src/ui/utils/jsonOutput.ts +++ b/cli/src/ui/utils/jsonOutput.ts @@ -1,37 +1,57 @@ /** * JSON output utilities for CI mode * Converts messages to JSON format for non-interactive output + * + * Schema v1.0.0 adds: + * - schemaVersion: Version identifier for automation compatibility + * - event: Unified event type for semantic categorization + * - messageId: Unique identifier for message tracking + * - status: Message completion status (partial/complete) */ +import { JSON_SCHEMA_VERSION, type MessageStatus } from "@kilocode/core-schemas" import type { UnifiedMessage } from "../../state/atoms/ui.js" import type { ExtensionChatMessage } from "../../types/messages.js" import type { CliMessage } from "../../types/cli.js" +import { getCliMessageEvent, getExtensionMessageEvent } from "./eventMapper.js" +import { generateCliMessageId, generateExtensionMessageId } from "./messageId.js" /** - * Convert a CLI message to JSON output format + * Convert a CLI message to JSON output format (v1.0.0) */ function formatCliMessage(message: CliMessage) { const { ts, ...restOfMessage } = message + const status: MessageStatus = message.partial ? "partial" : "complete" + return { + schemaVersion: JSON_SCHEMA_VERSION, + messageId: generateCliMessageId(message), timestamp: ts, - source: "cli", - ...restOfMessage, + source: "cli" as const, + event: getCliMessageEvent(message), + status, + ...restOfMessage, // preserves partial for backward compatibility } } /** - * Convert an extension message to JSON output format + * Convert an extension message to JSON output format (v1.0.0) * * If text is valid JSON (object/array), it's placed in 'metadata' field. * If text is plain text or malformed JSON, it's placed in 'content' field. */ function formatExtensionMessage(message: ExtensionChatMessage) { const { ts, text, ...restOfMessage } = message + const status: MessageStatus = message.partial ? "partial" : "complete" const output: Record = { + schemaVersion: JSON_SCHEMA_VERSION, + messageId: generateExtensionMessageId(message), timestamp: ts, - source: "extension", - ...restOfMessage, + source: "extension" as const, + event: getExtensionMessageEvent(message), + status, + ...restOfMessage, // preserves partial for backward compatibility } if (text) { diff --git a/cli/src/ui/utils/messageId.ts b/cli/src/ui/utils/messageId.ts new file mode 100644 index 00000000000..74ea3b8fa20 --- /dev/null +++ b/cli/src/ui/utils/messageId.ts @@ -0,0 +1,55 @@ +/** + * Message ID generation utilities for JSON schema contract + * + * Generates unique, deterministic message IDs for tracking messages + * in automation workflows. + */ + +import type { CliMessage } from "../../types/cli.js" +import type { ExtensionChatMessage } from "../../types/messages.js" + +/** + * Generate a short hash from string content + */ +function shortHash(str: string): string { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash // Convert to 32-bit integer + } + // Convert to base36 and take last 6 characters for brevity + return Math.abs(hash).toString(36).slice(-6).padStart(6, "0") +} + +/** + * Generate a unique message ID for a CLI message + * + * Format: cli-{timestamp}-{hash} + * Uses existing id if available, otherwise generates from content + */ +export function generateCliMessageId(message: CliMessage): string { + // Use existing id if present + if (message.id) { + return `cli-${message.ts}-${shortHash(message.id)}` + } + + // Generate hash from content + const contentHash = shortHash(message.content || message.type) + return `cli-${message.ts}-${contentHash}` +} + +/** + * Generate a unique message ID for an extension message + * + * Format: ext-{timestamp}-{hash} + * Uses stable message properties (not text) to create deterministic hash + * that remains consistent across partial/complete streaming updates. + */ +export function generateExtensionMessageId(message: ExtensionChatMessage): string { + // Create hash from stable message properties only (not text, which changes during streaming) + const hashSource = [message.type, message.ask || "", message.say || ""].join("|") + + const contentHash = shortHash(hashSource) + return `ext-${message.ts}-${contentHash}` +} diff --git a/packages/core-schemas/src/messages/events.ts b/packages/core-schemas/src/messages/events.ts new file mode 100644 index 00000000000..d4a118528cb --- /dev/null +++ b/packages/core-schemas/src/messages/events.ts @@ -0,0 +1,191 @@ +import { z } from "zod" + +/** + * Current JSON schema version for CLI output/input + */ +export const JSON_SCHEMA_VERSION = "1.0.0" + +/** + * Unified event types for CLI JSON output + * + * These provide semantic categorization of all message types, + * making it easier for automation tools to handle different events. + */ +export const outputEventSchema = z.enum([ + // System events + "system.welcome", + "system.error", + "system.info", + "system.ready", + "system.empty", + + // User interaction + "user.message", + "user.approval", + + // Assistant events + "assistant.message", + "assistant.reasoning", + "assistant.completion", + + // Tool events + "tool.request", + "tool.approved", + "tool.rejected", + "tool.output", + + // API events + "api.request_started", + "api.request_completed", + "api.request_failed", + "api.request_retried", + + // Task lifecycle + "task.resumed", + "task.completed", + "task.checkpoint", + + // Context management + "context.condensed", + "context.truncated", + + // Unknown/unmapped events + "unknown", +]) + +export type OutputEvent = z.infer + +/** + * Message status for streaming support + */ +export const messageStatusSchema = z.enum(["partial", "complete"]) + +export type MessageStatus = z.infer + +/** + * CLI message types + */ +export const cliMessageTypeSchema = z.enum([ + "user", + "assistant", + "system", + "error", + "welcome", + "empty", + "requestCheckpointRestoreApproval", +]) + +export type CliMessageType = z.infer + +/** + * Map CLI message type to unified event + */ +export function mapCliTypeToEvent(type: string): OutputEvent { + switch (type) { + case "welcome": + return "system.welcome" + case "error": + return "system.error" + case "system": + return "system.info" + case "empty": + return "system.empty" + case "user": + return "user.message" + case "assistant": + return "assistant.message" + case "requestCheckpointRestoreApproval": + return "task.checkpoint" + default: + return "unknown" + } +} + +/** + * Map extension "ask" type to unified event + */ +export function mapAskToEvent(ask: string): OutputEvent { + switch (ask) { + case "followup": + return "user.approval" + case "tool": + case "command": + case "browser_action_launch": + case "use_mcp_server": + return "tool.request" + case "api_req_failed": + return "api.request_failed" + case "resume_task": + case "resume_completed_task": + return "task.resumed" + case "completion_result": + return "task.completed" + case "checkpoint_restore": + return "task.checkpoint" + case "command_output": + return "tool.request" + case "mistake_limit_reached": + case "auto_approval_max_req_reached": + case "payment_required_prompt": + case "invalid_model": + case "report_bug": + case "condense": + return "user.approval" + default: + return "unknown" + } +} + +/** + * Map extension "say" type to unified event + */ +export function mapSayToEvent(say: string): OutputEvent { + switch (say) { + case "text": + return "assistant.message" + case "user_feedback": + case "user_feedback_diff": + return "user.message" + case "reasoning": + return "assistant.reasoning" + case "completion_result": + return "assistant.completion" + case "api_req_started": + return "api.request_started" + case "api_req_finished": + return "api.request_completed" + case "api_req_retried": + case "api_req_retry_delayed": + case "api_req_rate_limit_wait": + return "api.request_retried" + case "api_req_deleted": + return "api.request_failed" + case "command_output": + case "browser_action": + case "browser_action_result": + case "mcp_server_request_started": + case "mcp_server_response": + case "subtask_result": + return "tool.output" + case "checkpoint_saved": + return "task.checkpoint" + case "condense_context": + return "context.condensed" + case "condense_context_error": + return "system.error" + case "sliding_window_truncation": + return "context.truncated" + case "error": + case "rooignore_error": + case "diff_error": + case "shell_integration_warning": + return "system.error" + case "image": + case "codebase_search_result": + case "user_edit_todos": + case "browser_session_status": + return "assistant.message" + default: + return "unknown" + } +} diff --git a/packages/core-schemas/src/messages/index.ts b/packages/core-schemas/src/messages/index.ts index 9376c9a2fa1..4c6657fe232 100644 --- a/packages/core-schemas/src/messages/index.ts +++ b/packages/core-schemas/src/messages/index.ts @@ -3,3 +3,8 @@ export * from "./cli.js" // Extension message types export * from "./extension.js" + +// JSON schema contract (v1.0.0) +export * from "./events.js" +export * from "./output.js" +export * from "./input.js" diff --git a/packages/core-schemas/src/messages/input.ts b/packages/core-schemas/src/messages/input.ts new file mode 100644 index 00000000000..af4428994df --- /dev/null +++ b/packages/core-schemas/src/messages/input.ts @@ -0,0 +1,168 @@ +import { z } from "zod" +import { JSON_SCHEMA_VERSION } from "./events.js" + +/** + * Input message schema for --json-io mode + * + * Supports both versioned (v1.0.0) and legacy formats for backward compatibility. + */ + +/** + * Versioned input message schema (v1.0.0) + * + * Use discriminated union to enforce response field when type="response" + */ +const baseVersionedInputSchema = z.object({ + schemaVersion: z.literal(JSON_SCHEMA_VERSION), + content: z.string().optional(), + images: z.array(z.string()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}) + +export const versionedInputMessageSchema = z.discriminatedUnion("type", [ + // User input + baseVersionedInputSchema.extend({ + type: z.literal("user_input"), + }), + // Tool approval + baseVersionedInputSchema.extend({ + type: z.literal("approval"), + }), + // Tool rejection + baseVersionedInputSchema.extend({ + type: z.literal("rejection"), + }), + // Abort task + baseVersionedInputSchema.extend({ + type: z.literal("abort"), + }), + // Special askResponse (retry_clicked, objectResponse, messageResponse) + baseVersionedInputSchema.extend({ + type: z.literal("response"), + response: z.enum(["messageResponse", "retry_clicked", "objectResponse"]), // Required for type="response" + }), +]) + +/** + * Legacy input message schema (pre-v1.0.0) + * + * Matches the actual format accepted by useStdinJsonHandler. + * Examples: + * {"type": "askResponse", "text": "hello"} + * {"type": "askResponse", "askResponse": "yesButtonClicked"} + * {"type": "respondToApproval", "approved": true} + * {"type": "cancelTask"} + */ +export const legacyInputMessageSchema = z.object({ + // Message type determines how to handle + type: z.enum(["askResponse", "respondToApproval", "cancelTask"]), + + // For askResponse: the response type or defaults to messageResponse + askResponse: z.string().optional(), + + // User text input + text: z.string().optional(), + + // Image attachments + images: z.array(z.string()).optional(), + + // For respondToApproval: approval decision + approved: z.boolean().optional(), +}) + +/** + * Combined input message schema + * + * Accepts both versioned and legacy formats. + * Consumers should check for schemaVersion to determine format. + */ +export const inputMessageSchema = z.union([versionedInputMessageSchema, legacyInputMessageSchema]) + +// Inferred types +export type VersionedInputMessage = z.infer +export type LegacyInputMessage = z.infer +export type InputMessage = z.infer + +/** + * Check if an input message is versioned (v1.0.0) + */ +export function isVersionedInput(input: InputMessage): input is VersionedInputMessage { + return "schemaVersion" in input && input.schemaVersion === JSON_SCHEMA_VERSION +} + +/** + * Normalize legacy input to versioned format + */ +export function normalizeInput(input: InputMessage): VersionedInputMessage { + if (isVersionedInput(input)) { + return input + } + + // Convert legacy format to versioned format + const legacy = input as LegacyInputMessage + + switch (legacy.type) { + case "cancelTask": + return { + schemaVersion: JSON_SCHEMA_VERSION, + type: "abort", + images: legacy.images, + } + + case "respondToApproval": + return { + schemaVersion: JSON_SCHEMA_VERSION, + type: legacy.approved ? "approval" : "rejection", + content: legacy.text, + images: legacy.images, + } + + case "askResponse": + // Map common responses to semantic types + if (legacy.askResponse === "yesButtonClicked") { + return { + schemaVersion: JSON_SCHEMA_VERSION, + type: "approval", + content: legacy.text, + images: legacy.images, + } + } + if (legacy.askResponse === "noButtonClicked") { + return { + schemaVersion: JSON_SCHEMA_VERSION, + type: "rejection", + content: legacy.text, + images: legacy.images, + } + } + // Special response types (retry_clicked, objectResponse) use response field + if ( + legacy.askResponse === "retry_clicked" || + legacy.askResponse === "objectResponse" || + legacy.askResponse === "messageResponse" + ) { + return { + schemaVersion: JSON_SCHEMA_VERSION, + type: "response", + response: legacy.askResponse as "retry_clicked" | "objectResponse" | "messageResponse", + content: legacy.text, + images: legacy.images, + } + } + // Unknown askResponse: treat as user input + return { + schemaVersion: JSON_SCHEMA_VERSION, + type: "user_input", + content: legacy.text ?? "", + images: legacy.images, + } + + default: + return { + schemaVersion: JSON_SCHEMA_VERSION, + type: "user_input", + content: "", + images: legacy.images, + } + } +} diff --git a/packages/core-schemas/src/messages/output.ts b/packages/core-schemas/src/messages/output.ts new file mode 100644 index 00000000000..204406c9092 --- /dev/null +++ b/packages/core-schemas/src/messages/output.ts @@ -0,0 +1,74 @@ +import { z } from "zod" +import { JSON_SCHEMA_VERSION, outputEventSchema, messageStatusSchema } from "./events.js" + +/** + * Base fields for all JSON output messages + */ +const outputMessageBaseSchema = z.object({ + schemaVersion: z.literal(JSON_SCHEMA_VERSION), + messageId: z.string(), + timestamp: z.number(), + source: z.enum(["cli", "extension"]), + event: outputEventSchema, + status: messageStatusSchema, +}) + +/** + * CLI-sourced output message schema + * + * Uses passthrough() to allow additional fields for backward compatibility. + */ +export const cliOutputMessageSchema = outputMessageBaseSchema + .extend({ + source: z.literal("cli"), + type: z.enum(["user", "assistant", "system", "error", "welcome", "empty", "requestCheckpointRestoreApproval"]), + id: z.string(), + content: z.string(), + partial: z.boolean().optional(), + metadata: z.union([z.record(z.string(), z.unknown()), z.array(z.unknown())]).optional(), + payload: z.unknown().optional(), + }) + .passthrough() + +/** + * Extension-sourced output message schema + * + * Uses passthrough() to allow additional fields for backward compatibility. + * Explicitly declares common fields for better documentation. + */ +export const extensionOutputMessageSchema = outputMessageBaseSchema + .extend({ + source: z.literal("extension"), + type: z.enum(["ask", "say"]), + ask: z.string().optional(), + say: z.string().optional(), + content: z.string().optional(), + metadata: z.union([z.record(z.string(), z.unknown()), z.array(z.unknown())]).optional(), + // Common extension fields for backward compatibility + partial: z.boolean().optional(), + images: z.array(z.string()).optional(), + reasoning: z.string().optional(), + conversationHistoryIndex: z.number().optional(), + checkpoint: z.record(z.string(), z.unknown()).optional(), + isProtected: z.boolean().optional(), + apiProtocol: z.enum(["openai", "anthropic"]).optional(), + isAnswered: z.boolean().optional(), + progressStatus: z.unknown().optional(), + contextCondense: z.unknown().optional(), + contextTruncation: z.unknown().optional(), + }) + .passthrough() + +/** + * Union of all output message types + */ +export const outputMessageSchema = z.discriminatedUnion("source", [ + cliOutputMessageSchema, + extensionOutputMessageSchema, +]) + +// Inferred types +export type OutputMessageBase = z.infer +export type CliOutputMessage = z.infer +export type ExtensionOutputMessage = z.infer +export type OutputMessage = z.infer