sketch: add support for 'external' message types
- adds a new CodingAgentMessageType for loop.AgentMessage
- adds an new /external handler to loophttp.go
- modifies Agent to pass the .TextContent of ExternalMessage into the convo
as though it came from the user.
- adds sketch-external-message web component, with a template for
github workflow run events specifically.
- adds demos for sketch-external-message
diff --git a/cmd/go2ts/go2ts.go b/cmd/go2ts/go2ts.go
index a4015e0..956751c 100644
--- a/cmd/go2ts/go2ts.go
+++ b/cmd/go2ts/go2ts.go
@@ -50,6 +50,7 @@
loop.PortMessageType,
loop.CompactMessageType,
loop.SlugMessageType,
+ loop.ExternalMessageType,
},
)
diff --git a/loop/agent.go b/loop/agent.go
index 8339369..0877a5a 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -167,21 +167,25 @@
// ModelName returns the name of the model the agent is using.
ModelName() string
+
+ // ExternalMessage enqueues an external message to the agent and returns immediately.
+ ExternalMessage(ctx context.Context, msg ExternalMessage) error
}
type CodingAgentMessageType string
const (
- UserMessageType CodingAgentMessageType = "user"
- AgentMessageType CodingAgentMessageType = "agent"
- ErrorMessageType CodingAgentMessageType = "error"
- BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
- ToolUseMessageType CodingAgentMessageType = "tool"
- CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
- AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
- CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
- PortMessageType CodingAgentMessageType = "port" // for port monitoring events
- SlugMessageType CodingAgentMessageType = "slug" // for slug updates
+ UserMessageType CodingAgentMessageType = "user"
+ AgentMessageType CodingAgentMessageType = "agent"
+ ErrorMessageType CodingAgentMessageType = "error"
+ BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
+ ToolUseMessageType CodingAgentMessageType = "tool"
+ CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
+ AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
+ CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
+ PortMessageType CodingAgentMessageType = "port" // for port monitoring events
+ SlugMessageType CodingAgentMessageType = "slug" // for slug updates
+ ExternalMessageType CodingAgentMessageType = "external" // for external notifications
cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
)
@@ -191,12 +195,13 @@
// EndOfTurn indicates that the AI is done working and is ready for the next user input.
EndOfTurn bool `json:"end_of_turn"`
- Content string `json:"content"`
- ToolName string `json:"tool_name,omitempty"`
- ToolInput string `json:"input,omitempty"`
- ToolResult string `json:"tool_result,omitempty"`
- ToolError bool `json:"tool_error,omitempty"`
- ToolCallId string `json:"tool_call_id,omitempty"`
+ Content string `json:"content"`
+ ExternalMessage *ExternalMessage `json:"external_message,omitempty"`
+ ToolName string `json:"tool_name,omitempty"`
+ ToolInput string `json:"input,omitempty"`
+ ToolResult string `json:"tool_result,omitempty"`
+ ToolError bool `json:"tool_error,omitempty"`
+ ToolCallId string `json:"tool_call_id,omitempty"`
// ToolCalls is a list of all tool calls requested in this message (name and input pairs)
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
@@ -480,6 +485,18 @@
outstandingToolCalls map[string]string
}
+// ExternalMessage implements CodingAgent.
+// TODO: Debounce and/or coalesce these messages so they're less disruptive to the conversation.
+func (a *Agent) ExternalMessage(ctx context.Context, msg ExternalMessage) error {
+ agentMsg := AgentMessage{
+ Type: ExternalMessageType,
+ ExternalMessage: &msg,
+ }
+ a.pushToOutbox(ctx, agentMsg)
+ a.inbox <- msg.TextContent
+ return nil
+}
+
// TokenContextWindow implements CodingAgent.
func (a *Agent) TokenContextWindow() int {
return a.config.Service.TokenContextWindow()
@@ -2729,3 +2746,11 @@
}
return ""
}
+
+// ExternalMsg represents a message from a source external to the agent/user conversation,
+// such as the outcome of a github workflow run.
+type ExternalMessage struct {
+ MessageType string `json:"message_type"`
+ Body any `json:"body"`
+ TextContent string `json:"text_content"`
+}
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index dacc4f6..856932d 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -706,6 +706,35 @@
w.WriteHeader(http.StatusOK)
})
+ // Handler for POST /external - e.g. where you send messages about e.g. github workflow
+ // outcomes and other external events that the agent wouldn't otherwise be aware of.
+ s.mux.HandleFunc("/external", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var msg loop.ExternalMessage
+
+ decoder := json.NewDecoder(r.Body)
+ if err := decoder.Decode(&msg); err != nil {
+ httpError(w, r, "Invalid request body: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ defer r.Body.Close()
+
+ if msg.TextContent == "" {
+ httpError(w, r, "Message cannot be empty", http.StatusBadRequest)
+ return
+ }
+
+ if err := agent.ExternalMessage(r.Context(), msg); err != nil {
+ httpError(w, r, "agent ExternalMessage error: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+ })
+
// Handler for POST /upload - uploads a file to /tmp
s.mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
diff --git a/loop/server/loophttp_test.go b/loop/server/loophttp_test.go
index fa591b2..8d4583e 100644
--- a/loop/server/loophttp_test.go
+++ b/loop/server/loophttp_test.go
@@ -38,6 +38,11 @@
model string
}
+// ExternalMessage implements loop.CodingAgent.
+func (m *mockAgent) ExternalMessage(ctx context.Context, msg loop.ExternalMessage) error {
+ panic("unimplemented")
+}
+
// TokenContextWindow implements loop.CodingAgent.
func (m *mockAgent) TokenContextWindow() int {
return 200000
diff --git a/webui/package-lock.json b/webui/package-lock.json
index b5b0478..ce9e06f 100644
--- a/webui/package-lock.json
+++ b/webui/package-lock.json
@@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
+ "@octokit/webhooks-types": "^7.6.1",
"@tailwindcss/cli": "^4.1.10",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/vite": "^4.1.11",
@@ -1427,6 +1428,12 @@
"node": ">= 8"
}
},
+ "node_modules/@octokit/webhooks-types": {
+ "version": "7.6.1",
+ "resolved": "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-7.6.1.tgz",
+ "integrity": "sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw==",
+ "license": "MIT"
+ },
"node_modules/@open-draft/deferred-promise": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz",
diff --git a/webui/package.json b/webui/package.json
index c2f15aa..90fb3fd 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -26,6 +26,7 @@
"test:playwright": "playwright test -c playwright-ct.config.ts"
},
"dependencies": {
+ "@octokit/webhooks-types": "^7.6.1",
"@tailwindcss/cli": "^4.1.10",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/vite": "^4.1.11",
diff --git a/webui/src/types.ts b/webui/src/types.ts
index e4ee48c..d0ed5de 100644
--- a/webui/src/types.ts
+++ b/webui/src/types.ts
@@ -1,6 +1,12 @@
// Auto-generated by sketch.dev/cmd/go2ts.go
// DO NOT EDIT. This file is automatically generated.
+export interface ExternalMessage {
+ message_type: string;
+ body: any;
+ text_content: string;
+}
+
export interface ToolCall {
name: string;
input: string;
@@ -29,6 +35,7 @@
type: CodingAgentMessageType;
end_of_turn: boolean;
content: string;
+ external_message?: ExternalMessage | null;
tool_name?: string;
input?: string;
tool_result?: string;
@@ -166,6 +173,6 @@
subject: string;
}
-export type CodingAgentMessageType = 'user' | 'agent' | 'error' | 'budget' | 'tool' | 'commit' | 'auto' | 'port' | 'compact' | 'slug';
+export type CodingAgentMessageType = 'user' | 'agent' | 'error' | 'budget' | 'tool' | 'commit' | 'auto' | 'port' | 'compact' | 'slug' | 'external';
export type Duration = number;
diff --git a/webui/src/web-components/demo/demo-framework/demo-runner.ts b/webui/src/web-components/demo/demo-framework/demo-runner.ts
index 680651b..2871929 100644
--- a/webui/src/web-components/demo/demo-framework/demo-runner.ts
+++ b/webui/src/web-components/demo/demo-framework/demo-runner.ts
@@ -96,6 +96,7 @@
"sketch-diff-range-picker",
"sketch-timeline",
"sketch-timeline-message",
+ "sketch-external-message",
"sketch-todo-panel",
"sketch-tool-calls",
"sketch-view-mode-select",
diff --git a/webui/src/web-components/demo/sketch-external-message.demo.ts b/webui/src/web-components/demo/sketch-external-message.demo.ts
new file mode 100644
index 0000000..cdaa86d
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-external-message.demo.ts
@@ -0,0 +1,120 @@
+import { DemoModule } from "./demo-framework/types";
+import { demoUtils } from "./demo-fixtures/index";
+import { SketchExternalMessage } from "../sketch-external-message";
+
+const demo: DemoModule = {
+ title: "Sketch External Message Demo",
+ description:
+ "Demonstration of external message components for various external message types",
+ imports: ["../sketch-external-message.ts"],
+
+ setup: async (container: HTMLElement) => {
+ const section = demoUtils.createDemoSection(
+ "External Message Components",
+ "Shows different external message components for various message types",
+ );
+
+ // external messages
+ const externalMessages = [
+ {
+ name: "github_workflow_run: completed, success",
+ message_type: "github_workflow_run",
+ body: {
+ workflow: {
+ name: "go_tests",
+ },
+ workflow_run: {
+ id: 123456789,
+ status: "completed",
+ conclusion: "success",
+ head_branch: "user/sketch/slug-name",
+ head_commit: "abc123",
+ html_url: "https://github.com/orgs/your-org/actions/runs/123456789",
+ },
+ },
+ },
+ {
+ name: "github_workflow_run: completed, failed",
+ message_type: "github_workflow_run",
+ body: {
+ workflow: {
+ name: "go_tests",
+ },
+ workflow_run: {
+ id: 123456789,
+ status: "completed",
+ conclusion: "failure",
+ head_branch: "user/sketch/slug-name",
+ head_commit: "abc123",
+ html_url: "https://github.com/orgs/your-org/actions/runs/123456789",
+ },
+ },
+ },
+ {
+ name: "github_workflow_run: queued",
+ message_type: "github_workflow_run",
+ body: {
+ workflow: {
+ name: "go_tests",
+ },
+ workflow_run: {
+ id: 123456789,
+ status: "queued",
+ head_branch: "user/sketch/slug-name",
+ head_commit: "abc123",
+ html_url: "https://github.com/orgs/your-org/actions/runs/123456789",
+ },
+ },
+ },
+ {
+ name: "github_workflow_run: in_progress",
+ message_type: "github_workflow_run",
+ body: {
+ workflow: {
+ name: "go_tests",
+ },
+ workflow_run: {
+ id: 123456789,
+ status: "in_progress",
+ head_branch: "user/sketch/slug-name",
+ head_commit: "abc123",
+ html_url: "https://github.com/orgs/your-org/actions/runs/123456789",
+ },
+ },
+ },
+ ];
+
+ // Create tool cards for each tool call
+ externalMessages.forEach((msg) => {
+ const msgSection = document.createElement("div");
+ msgSection.style.marginBottom = "2rem";
+ msgSection.style.border = "1px solid #eee";
+ msgSection.style.borderRadius = "8px";
+ msgSection.style.padding = "1rem";
+
+ const header = document.createElement("h3");
+ header.textContent = `Message: ${msg.name}`;
+ header.style.marginTop = "0";
+ header.style.marginBottom = "1rem";
+ header.style.color = "var(--demo-fixture-text-color)";
+ msgSection.appendChild(header);
+ const msgEl = document.createElement(
+ "sketch-external-message",
+ ) as SketchExternalMessage;
+
+ msgEl.message = {
+ message_type: msg.message_type,
+ body: msg.body,
+ text_content: `Workflow run ${msg.body.workflow_run.id} completed with status ${msg.body.workflow_run.status} and conclusion ${msg.body.workflow_run.conclusion}.`,
+ };
+ msgEl.open = true;
+
+ msgSection.appendChild(msgEl);
+ section.appendChild(msgSection);
+ });
+
+ container.appendChild(section);
+ },
+};
+
+export default demo;
diff --git a/webui/src/web-components/sketch-external-message.ts b/webui/src/web-components/sketch-external-message.ts
new file mode 100644
index 0000000..1df27d8
--- /dev/null
+++ b/webui/src/web-components/sketch-external-message.ts
@@ -0,0 +1,128 @@
+import { html, TemplateResult } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { ExternalMessage } from "../types";
+import { SketchTailwindElement } from "./sketch-tailwind-element";
+import type { WorkflowRunEvent } from "@octokit/webhooks-types";
+
+@customElement("sketch-external-message")
+export class SketchExternalMessage extends SketchTailwindElement {
+ @property() message: ExternalMessage | null = null;
+ @property() open: boolean;
+ @property() summaryContent: TemplateResult | string = "";
+ @property() detailsContent: TemplateResult | string = "";
+ @state() detailsVisible: boolean = false;
+
+ _toggleDetails(e: Event) {
+ e.stopPropagation();
+ this.detailsVisible = !this.detailsVisible;
+ }
+
+ constructor() {
+ super();
+ }
+
+ render() {
+ if (this.message?.message_type === "github_workflow_run") {
+ const run = this.message.body as WorkflowRunEvent;
+ this.summaryContent = html`<svg
+ class="inline-block w-4 h-4"
+ viewBox="0 0 16 16"
+ width="16"
+ height="16"
+ >
+ <path
+ fill="currentColor"
+ d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"
+ ></path>
+ </svg>
+ <span
+ class="inline-flex items-center px-1.5 py-1
+ ${run.workflow_run.conclusion === "success"
+ ? "bg-green-600/10 text-green-600"
+ : run.workflow_run.conclusion === "failure"
+ ? "bg-red-600/10 text-red-600"
+ : "bg-gray-600/10 text-gray-600"}
+ rounded-md text-xs flex-shrink-0 transition-colors"
+ >${run.workflow.name} -
+ ${run.workflow_run.conclusion || run.workflow_run.status}</span
+ >
+ on ${run.workflow_run.head_branch} at ${run.workflow_run.head_commit}`;
+
+ this.detailsContent = html`
+ <div class="flex items-center gap-2">
+ <span class="text-gray-700 dark:text-gray-300">Workflow ID:</span>
+ <span class="font-mono font-medium text-gray-800 dark:text-gray-200">${run.workflow_run.id}</span>
+ </div>
+ <div class="flex items-center gap-2">
+ bg-gray-600/10 text-gray-600
+ {{end}}
+ rounded-md text-xs flex-shrink-0 transition-colors'><a target="_blank"
+ rel="noopener noreferrer"href="${run.workflow_run.html_url}">${run.workflow.name} - ${run.workflow_run.conclusion}</a>
+ on ${run.workflow_run.head_branch} at ${run.workflow_run.head_commit}</span>
+ </span>`;
+
+ this.detailsContent = html`
+ <div class="flex items-center gap-2">
+ <span class="text-gray-700 dark:text-gray-300">Workflow Run ID:</span>
+ <span class="font-mono font-medium text-gray-800 dark:text-gray-200"
+ >${run.workflow_run.id}</span
+ >
+ </div>
+ <div class="flex items-center gap-2">
+ <span class="text-gray-700 dark:text-gray-300">Status:</span>
+ <span class="font-mono font-medium text-gray-800 dark:text-gray-200"
+ >${run.workflow_run.status}</span
+ >
+ </div>
+ <div class="flex items-center gap-2">
+ <span class="text-gray-700 dark:text-gray-300">Conclusion:</span>
+ <span class="font-mono font-medium text-gray-800 dark:text-gray-200"
+ >${run.workflow_run.conclusion}</span
+ >
+ </div>
+ <div class="flex items-center gap-2">
+ <span class="text-gray-700 dark:text-gray-300">Run URL:</span>
+ <a
+ class="text-blue-600 dark:text-blue-400 hover:underline"
+ href="${run.workflow_run.html_url}"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ ${run.workflow_run.html_url}
+ </a>
+ </div>
+ `;
+ }
+
+ return html`
+ <div class="block max-w-full w-full box-border overflow-hidden">
+ <div class="flex flex-col w-full">
+ <div
+ class="flex w-full box-border py-1.5 px-2 pl-3 items-center gap-2 cursor-pointer rounded relative overflow-hidden flex-wrap hover:bg-black/[0.02] dark:hover:bg-white/[0.05]"
+ @click=${this._toggleDetails}
+ >
+ <span
+ class="whitespace-nowrap flex-grow flex-shrink text-gray-700 dark:text-gray-300 font-mono text-xs px-1 min-w-[50px] max-w-[calc(100%-150px)] inline-block"
+ >${this.summaryContent}</span
+ >
+ </div>
+ <div
+ class="${this.detailsVisible
+ ? "block"
+ : "hidden"} p-2 bg-black/[0.02] dark:bg-white/[0.05] mt-px border-t border-black/[0.05] dark:border-white/[0.1] font-mono text-xs text-gray-800 dark:text-gray-200 rounded-b max-w-full w-full box-border overflow-hidden"
+ >
+ ${this.detailsContent
+ ? html`<div class="mb-2">${this.detailsContent}</div>`
+ : ""}
+ </div>
+ </div>
+ </div>
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "sketch-notification-message": SketchExternalMessage;
+ }
+}
diff --git a/webui/src/web-components/sketch-timeline-message.ts b/webui/src/web-components/sketch-timeline-message.ts
index abbb329..9a0b78a 100644
--- a/webui/src/web-components/sketch-timeline-message.ts
+++ b/webui/src/web-components/sketch-timeline-message.ts
@@ -5,6 +5,9 @@
import { marked, MarkedOptions, Renderer, Tokens } from "marked";
import type mermaid from "mermaid";
import DOMPurify from "dompurify";
+import "./sketch-tool-calls";
+import "./sketch-external-message";
+import { SketchTailwindElement } from "./sketch-tailwind-element";
// Mermaid is loaded dynamically - see loadMermaid() function
declare global {
@@ -54,8 +57,6 @@
return mermaidLoadPromise;
}
-import "./sketch-tool-calls";
-import { SketchTailwindElement } from "./sketch-tailwind-element";
@customElement("sketch-timeline-message")
export class SketchTimelineMessage extends SketchTailwindElement {
@@ -927,12 +928,15 @@
.join(" ");
const messageContentClasses = [
- "relative px-2.5 py-1.5 rounded-xl shadow-sm min-w-min",
+ "relative px-2.5 py-1.5 min-w-min",
// User message styling
this.message?.type === "user"
- ? "bg-blue-500 text-white rounded-br-sm"
- : // Agent/tool/error message styling
- "bg-gray-100 dark:bg-gray-800 text-black dark:text-gray-100 rounded-bl-sm",
+ ? "rounded-xl shadow-sm bg-blue-500 text-white rounded-br-sm"
+ : this.message?.type === "agent" // Agent/tool/error message styling
+ ? "rounded-xl shadow-sm bg-gray-100 dark:bg-gray-800 text-black dark:text-gray-100 rounded-bl-sm"
+ : this.message?.type === "external" // External message styling
+ ? "bg-white dark:bg-gray-800 text-black dark:text-gray-100"
+ : "", // default styling for other types
]
.filter(Boolean)
.join(" ");
@@ -1135,6 +1139,16 @@
`
: ""}
+ <!-- External messages -->
+ ${this.message?.type === "external"
+ ? html`
+ <sketch-external-message
+ .message=${this.message?.external_message}
+ .open=${this.open}
+ ></sketch-external-message>
+ `
+ : ""}
+
<!-- Commits section -->
${this.message?.commits
? html`