Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion registry/coder/modules/agentapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI
```tf
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "2.0.0"
version = "2.1.0"

agent_id = var.agent_id
web_app_slug = local.app_slug
Expand Down Expand Up @@ -49,6 +49,19 @@ module "agentapi" {
}
```

## Task log snapshot

Captures the last 10 messages from AgentAPI when a task workspace stops. This allows viewing conversation history while the task is paused.

To enable for task workspaces:

```tf
module "agentapi" {
# ... other config
task_log_snapshot = true # default: true
}
```

## For module developers

For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf).
153 changes: 153 additions & 0 deletions registry/coder/modules/agentapi/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,4 +257,157 @@ describe("agentapi", async () => {
);
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
});

describe("shutdown script", async () => {
const setupMocks = async (
containerId: string,
agentapiPreset: string,
httpCode: number = 204,
) => {
const agentapiMock = await loadTestFile(
import.meta.dir,
"agentapi-mock-shutdown.js",
);
const coderMock = await loadTestFile(
import.meta.dir,
"coder-instance-mock.js",
);

await writeExecutable({
containerId,
filePath: "/usr/local/bin/mock-agentapi",
content: agentapiMock,
});

await writeExecutable({
containerId,
filePath: "/usr/local/bin/mock-coder",
content: coderMock,
});

await execContainer(containerId, [
"bash",
"-c",
`PRESET=${agentapiPreset} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`,
]);

await execContainer(containerId, [
"bash",
"-c",
`HTTP_CODE=${httpCode} nohup node /usr/local/bin/mock-coder 18080 > /tmp/mock-coder.log 2>&1 &`,
]);

await new Promise((resolve) => setTimeout(resolve, 1000));
};

const runShutdownScript = async (
containerId: string,
taskId: string = "test-task",
) => {
const shutdownScript = await loadTestFile(
import.meta.dir,
"../scripts/agentapi-shutdown.sh",
);

await writeExecutable({
containerId,
filePath: "/tmp/shutdown.sh",
content: shutdownScript,
});

return await execContainer(containerId, [
"bash",
"-c",
`ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
]);
};

test("posts snapshot with normal messages", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});

await setupMocks(id, "normal");
const result = await runShutdownScript(id);

expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Retrieved 5 messages for log snapshot");
expect(result.stdout).toContain("Log snapshot posted successfully");

const posted = await readFileContainer(id, "/tmp/snapshot-posted.json");
const snapshot = JSON.parse(posted);
expect(snapshot.task_id).toBe("test-task");
expect(snapshot.payload.messages).toHaveLength(5);
expect(snapshot.payload.messages[0].content).toBe("Hello");
expect(snapshot.payload.messages[4].content).toBe("Great");
});

test("truncates to last 10 messages", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});

await setupMocks(id, "many");
const result = await runShutdownScript(id);

expect(result.exitCode).toBe(0);

const posted = await readFileContainer(id, "/tmp/snapshot-posted.json");
const snapshot = JSON.parse(posted);
expect(snapshot.task_id).toBe("test-task");
expect(snapshot.payload.messages).toHaveLength(10);
expect(snapshot.payload.messages[0].content).toBe("Message 6");
expect(snapshot.payload.messages[9].content).toBe("Message 15");
});

test("truncates huge message content", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});

await setupMocks(id, "huge");
const result = await runShutdownScript(id);

expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("truncating final message content");

const posted = await readFileContainer(id, "/tmp/snapshot-posted.json");
const snapshot = JSON.parse(posted);
expect(snapshot.task_id).toBe("test-task");
expect(snapshot.payload.messages).toHaveLength(1);
expect(snapshot.payload.messages[0].content).toContain(
"[...content truncated",
);
});

test("skips gracefully when TASK_ID is empty", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});

const result = await runShutdownScript(id, "");

expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("No task ID, skipping log snapshot");
});

test("handles 404 gracefully for older Coder versions", async () => {
const { id } = await setup({
moduleVariables: {},
skipAgentAPIMock: true,
});

await setupMocks(id, "normal", 404);
const result = await runShutdownScript(id);

expect(result.exitCode).toBe(0);
expect(result.stdout).toContain(
"Log snapshot endpoint not supported by this Coder version",
);
});
});
});
32 changes: 31 additions & 1 deletion registry/coder/modules/agentapi/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.12"
version = ">= 2.13"
}
}
}
Expand All @@ -18,6 +18,8 @@ data "coder_workspace" "me" {}

data "coder_workspace_owner" "me" {}

data "coder_task" "me" {}

variable "web_app_order" {
type = number
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
Expand Down Expand Up @@ -126,6 +128,12 @@ variable "agentapi_port" {
default = 3284
}

variable "task_log_snapshot" {
type = bool
description = "Capture last 10 messages when workspace stops for offline viewing while task is paused."
default = true
}

locals {
# agentapi_subdomain_false_min_version_expr matches a semantic version >= v0.3.3.
# Initial support was added in v0.3.1 but configuration via environment variable
Expand Down Expand Up @@ -173,6 +181,7 @@ locals {
// for backward compatibility.
agentapi_chat_base_path = var.agentapi_subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${var.web_app_slug}/chat"
main_script = file("${path.module}/scripts/main.sh")
shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh")
}

resource "coder_script" "agentapi" {
Expand All @@ -198,11 +207,32 @@ resource "coder_script" "agentapi" {
ARG_POST_INSTALL_SCRIPT="$(echo -n '${local.encoded_post_install_script}' | base64 -d)" \
ARG_AGENTAPI_PORT='${var.agentapi_port}' \
ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
/tmp/main.sh
EOT
run_on_start = true
}

resource "coder_script" "agentapi_shutdown" {
agent_id = var.agent_id
display_name = "AgentAPI Shutdown"
icon = var.web_app_icon
run_on_stop = true
script = <<-EOT
#!/bin/bash
set -o pipefail

echo -n '${base64encode(local.shutdown_script)}' | base64 -d > /tmp/agentapi-shutdown.sh
chmod +x /tmp/agentapi-shutdown.sh

ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
ARG_AGENTAPI_PORT='${var.agentapi_port}' \
/tmp/agentapi-shutdown.sh
EOT
}

resource "coder_app" "agentapi_web" {
slug = var.web_app_slug
display_name = var.web_app_display_name
Expand Down
Loading