diff --git a/.agents/skills/add-public-service/agents/openai.yaml b/.agents/skills/add-public-service/agents/openai.yaml index 9745ec16..8084bcff 100644 --- a/.agents/skills/add-public-service/agents/openai.yaml +++ b/.agents/skills/add-public-service/agents/openai.yaml @@ -1,4 +1,4 @@ interface: - display_name: "Add Public Service" - short_description: "Add stack public services safely" - default_prompt: "Use $add-public-service to add a new SSO-protected public app." + display_name: 'Add Public Service' + short_description: 'Add stack public services safely' + default_prompt: 'Use $add-public-service to add a new SSO-protected public app.' diff --git a/.agents/skills/agent-session-bootstrap/agents/openai.yaml b/.agents/skills/agent-session-bootstrap/agents/openai.yaml index 15d36838..23a7f072 100644 --- a/.agents/skills/agent-session-bootstrap/agents/openai.yaml +++ b/.agents/skills/agent-session-bootstrap/agents/openai.yaml @@ -1,11 +1,11 @@ interface: - display_name: "Agent Session Bootstrap" - short_description: "Bootstrap durable agent context" - default_prompt: "Use $agent-session-bootstrap to set up agent hooks, skills, and durable instructions." + display_name: 'Agent Session Bootstrap' + short_description: 'Bootstrap durable agent context' + default_prompt: 'Use $agent-session-bootstrap to set up agent hooks, skills, and durable instructions.' dependencies: tools: - - type: "mcp" - value: "knowledge" - description: "Knowledge-base MCP server" - transport: "streamable_http" - url: "http://knowledge-api.knowledge-system.svc.cluster.local:8080/mcp" + - type: 'mcp' + value: 'knowledge' + description: 'Knowledge-base MCP server' + transport: 'streamable_http' + url: 'http://knowledge-api.knowledge-system.svc.cluster.local:8080/mcp' diff --git a/.agents/skills/fleet-change/agents/openai.yaml b/.agents/skills/fleet-change/agents/openai.yaml index 4fab0fad..1ce188f2 100644 --- a/.agents/skills/fleet-change/agents/openai.yaml +++ b/.agents/skills/fleet-change/agents/openai.yaml @@ -1,4 +1,4 @@ interface: - display_name: "Fleet Change" - short_description: "Render and validate fleet edits" - default_prompt: "Use $fleet-change to make this fleet.yaml update safely." + display_name: 'Fleet Change' + short_description: 'Render and validate fleet edits' + default_prompt: 'Use $fleet-change to make this fleet.yaml update safely.' diff --git a/.agents/skills/kb-first/agents/openai.yaml b/.agents/skills/kb-first/agents/openai.yaml index 0b161efe..95344b38 100644 --- a/.agents/skills/kb-first/agents/openai.yaml +++ b/.agents/skills/kb-first/agents/openai.yaml @@ -1,11 +1,11 @@ interface: - display_name: "KB First" - short_description: "Recall and capture KB context" - default_prompt: "Use $kb-first to gather compact KB context before this task." + display_name: 'KB First' + short_description: 'Recall and capture KB context' + default_prompt: 'Use $kb-first to gather compact KB context before this task.' dependencies: tools: - - type: "mcp" - value: "knowledge" - description: "Knowledge-base MCP server" - transport: "streamable_http" - url: "http://knowledge-api.knowledge-system.svc.cluster.local:8080/mcp" + - type: 'mcp' + value: 'knowledge' + description: 'Knowledge-base MCP server' + transport: 'streamable_http' + url: 'http://knowledge-api.knowledge-system.svc.cluster.local:8080/mcp' diff --git a/.agents/skills/open-pr/agents/openai.yaml b/.agents/skills/open-pr/agents/openai.yaml index 58ecf2de..2d2fdba5 100644 --- a/.agents/skills/open-pr/agents/openai.yaml +++ b/.agents/skills/open-pr/agents/openai.yaml @@ -1,4 +1,4 @@ interface: - display_name: "Open PR" - short_description: "Create repo-standard pull requests" - default_prompt: "Use $open-pr to open a personal-stack pull request." + display_name: 'Open PR' + short_description: 'Create repo-standard pull requests' + default_prompt: 'Use $open-pr to open a personal-stack pull request.' diff --git a/.agents/skills/run-tests/agents/openai.yaml b/.agents/skills/run-tests/agents/openai.yaml index 0decebe1..22d6d7b1 100644 --- a/.agents/skills/run-tests/agents/openai.yaml +++ b/.agents/skills/run-tests/agents/openai.yaml @@ -1,4 +1,4 @@ interface: - display_name: "Run Tests" - short_description: "Choose monorepo validation commands" - default_prompt: "Use $run-tests to validate the files touched by this change." + display_name: 'Run Tests' + short_description: 'Choose monorepo validation commands' + default_prompt: 'Use $run-tests to validate the files touched by this change.' diff --git a/.agents/skills/token-economy/agents/openai.yaml b/.agents/skills/token-economy/agents/openai.yaml index 16e8c0f7..d2e98a71 100644 --- a/.agents/skills/token-economy/agents/openai.yaml +++ b/.agents/skills/token-economy/agents/openai.yaml @@ -1,11 +1,11 @@ interface: - display_name: "Token Economy" - short_description: "Reduce agent token usage cleanly" - default_prompt: "Use $token-economy to keep this agent workflow token-efficient." + display_name: 'Token Economy' + short_description: 'Reduce agent token usage cleanly' + default_prompt: 'Use $token-economy to keep this agent workflow token-efficient.' dependencies: tools: - - type: "mcp" - value: "knowledge" - description: "Knowledge-base MCP server" - transport: "streamable_http" - url: "http://knowledge-api.knowledge-system.svc.cluster.local:8080/mcp" + - type: 'mcp' + value: 'knowledge' + description: 'Knowledge-base MCP server' + transport: 'streamable_http' + url: 'http://knowledge-api.knowledge-system.svc.cluster.local:8080/mcp' diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/config/GatewayProperties.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/config/GatewayProperties.kt index 541f810b..cf6f0be1 100644 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/config/GatewayProperties.kt +++ b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/config/GatewayProperties.kt @@ -8,6 +8,7 @@ data class GatewayProperties( val tmux: Tmux, val cli: Cli, val git: Git, + val stagedInputs: StagedInputs = StagedInputs(), ) { data class Tmux( val socketName: String, @@ -36,4 +37,9 @@ data class GatewayProperties( data class Git( val deployKeyDir: String, ) + + data class StagedInputs( + val dirName: String = ".agent-inputs", + val maxBytes: Long = 5L * 1024 * 1024, + ) } diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManager.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManager.kt index c3be986e..15ec6e91 100644 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManager.kt +++ b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManager.kt @@ -9,6 +9,8 @@ import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardOpenOption import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors @@ -116,6 +118,33 @@ class AgentSessionManager( tmux.sendKeys(session.tmuxSession, input, enter = enter) } + fun stageInput( + id: String, + content: String, + requestedName: String?, + ): StagedInput { + val session = sessions[id] ?: error("unknown agent: $id") + val bytes = content.toByteArray(Charsets.UTF_8) + require(bytes.isNotEmpty()) { "staged input content is empty" } + require(bytes.size.toLong() <= props.stagedInputs.maxBytes) { + "staged input exceeds ${props.stagedInputs.maxBytes} bytes" + } + + val root = Path(session.cwd).toAbsolutePath().normalize() + val dir = root.resolve(props.stagedInputs.dirName).normalize() + require(dir.startsWith(root)) { "staged input directory must stay inside the workspace" } + + Files.createDirectories(dir) + val safeName = safeFileName(requestedName) + val fileName = "${timestamp()}-${UUID.randomUUID().toString().take(ID_PREVIEW_CHARS)}-$safeName" + val target = dir.resolve(fileName).normalize() + require(target.startsWith(dir)) { "staged input path must stay inside the staging directory" } + + Files.write(target, bytes, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE) + log.info("staged {} bytes for agent {} at {}", bytes.size, id, target) + return StagedInput(path = target.toString(), bytes = bytes.size.toLong(), name = safeName) + } + fun capture( id: String, historyLines: Int = 1_000, @@ -190,8 +219,27 @@ class AgentSessionManager( AgentKind.SHELL -> listOf("/bin/bash", "-l") to null } + private fun safeFileName(requestedName: String?): String { + val raw = requestedName?.trim()?.takeIf { it.isNotBlank() } ?: DEFAULT_STAGED_INPUT_NAME + val leaf = raw.replace('\\', '/').substringAfterLast('/') + val safe = + SAFE_NAME_CHARS + .replace(leaf, "-") + .trim('.', '-', '_') + .take(MAX_STAGED_INPUT_NAME_CHARS) + return safe.takeIf { it.isNotBlank() } ?: DEFAULT_STAGED_INPUT_NAME + } + + private fun timestamp(): String = STAGED_INPUT_TIMESTAMP.format(Instant.now()) + companion object { /** Sentinel value for [resumeCliSessionId] that tells the gateway to run `codex resume --last`. */ const val CODEX_RESUME_LAST = "LATEST" + private const val DEFAULT_STAGED_INPUT_NAME = "input.txt" + private const val ID_PREVIEW_CHARS = 8 + private const val MAX_STAGED_INPUT_NAME_CHARS = 80 + private val SAFE_NAME_CHARS = Regex("[^A-Za-z0-9._-]+") + private val STAGED_INPUT_TIMESTAMP = + DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss-SSS").withZone(ZoneOffset.UTC) } } diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/StagedInput.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/StagedInput.kt new file mode 100644 index 00000000..920101d3 --- /dev/null +++ b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/StagedInput.kt @@ -0,0 +1,7 @@ +package com.jorisjonkers.personalstack.agentgateway.tmux + +data class StagedInput( + val path: String, + val bytes: Long, + val name: String, +) diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentController.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentController.kt index 10fa16e5..843d55b7 100644 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentController.kt +++ b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentController.kt @@ -5,6 +5,8 @@ import com.jorisjonkers.personalstack.agentgateway.tmux.AgentSessionManager import com.jorisjonkers.personalstack.agentgateway.web.dto.AgentResponse import com.jorisjonkers.personalstack.agentgateway.web.dto.SendInputRequest import com.jorisjonkers.personalstack.agentgateway.web.dto.SpawnAgentRequest +import com.jorisjonkers.personalstack.agentgateway.web.dto.StageInputRequest +import com.jorisjonkers.personalstack.agentgateway.web.dto.StagedInputResponse import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping @@ -47,6 +49,17 @@ class AgentController( return ResponseEntity.accepted().build() } + @PostMapping("/{id}/staged-inputs") + fun stageInput( + @PathVariable id: String, + @RequestBody req: StageInputRequest, + ): ResponseEntity { + val staged = sessions.stageInput(id, req.content, req.name) + return ResponseEntity + .status(HttpStatus.CREATED) + .body(StagedInputResponse(path = staged.path, bytes = staged.bytes, name = staged.name)) + } + @GetMapping("/{id}/capture") fun capture( @PathVariable id: String, diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/ErrorAdvice.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/ErrorAdvice.kt index a3156f30..a96d4358 100644 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/ErrorAdvice.kt +++ b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/ErrorAdvice.kt @@ -30,6 +30,10 @@ class ErrorAdvice { fun onIllegalState(e: IllegalStateException): ResponseEntity> = ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf("error" to (e.message ?: "not-found"))) + @ExceptionHandler(IllegalArgumentException::class) + fun onIllegalArgument(e: IllegalArgumentException): ResponseEntity> = + ResponseEntity.status(HttpStatus.BAD_REQUEST).body(mapOf("error" to (e.message ?: "bad-request"))) + companion object { private const val STDERR_PREVIEW_CHARS = 2_000 } diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/dto/Dtos.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/dto/Dtos.kt index ae3889c2..ed7f8634 100644 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/dto/Dtos.kt +++ b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/dto/Dtos.kt @@ -13,6 +13,17 @@ data class SendInputRequest( val enter: Boolean = true, ) +data class StageInputRequest( + val content: String, + val name: String? = null, +) + +data class StagedInputResponse( + val path: String, + val bytes: Long, + val name: String, +) + data class AgentResponse( val id: String, val kind: AgentKind, diff --git a/services/agent-gateway/src/main/resources/application.yml b/services/agent-gateway/src/main/resources/application.yml index cff9121b..7497c0c9 100644 --- a/services/agent-gateway/src/main/resources/application.yml +++ b/services/agent-gateway/src/main/resources/application.yml @@ -51,6 +51,9 @@ agent-gateway: # The deploy-key Secret is mounted into the Pod at this path by # assistant-api when it creates the runner Pod (Step 3). deploy-key-dir: /var/run/secrets/agents/github-deploy-key + staged-inputs: + dir-name: .agent-inputs + max-bytes: 5242880 management: endpoints: diff --git a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManagerTest.kt b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManagerTest.kt index 86488087..4c88e52c 100644 --- a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManagerTest.kt +++ b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManagerTest.kt @@ -5,6 +5,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.verify import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy import org.awaitility.Awaitility.await import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir @@ -15,7 +16,10 @@ import java.time.Duration class AgentSessionManagerTest { private val tmux = mockk(relaxed = true) - private fun manager(tmp: Path): AgentSessionManager { + private fun manager( + tmp: Path, + stagedInputs: GatewayProperties.StagedInputs = GatewayProperties.StagedInputs(), + ): AgentSessionManager { val props = GatewayProperties( workspaceRoot = "/workspace", @@ -28,6 +32,7 @@ class AgentSessionManagerTest { codexArgs = listOf("--dangerously-bypass-approvals-and-sandbox"), ), git = GatewayProperties.Git(deployKeyDir = "/x"), + stagedInputs = stagedInputs, ) every { tmux.ensureStateDir() } returns tmp return AgentSessionManager(tmux, props) @@ -204,6 +209,46 @@ class AgentSessionManagerTest { verify { tmux.sendKeys(s.tmuxSession, "list files", enter = true) } } + @Test + fun `stageInput writes content under session cwd with sanitized name`( + @TempDir tmp: Path, + ) { + val workspace = tmp.resolve("workspace") + val mgr = manager(tmp) + val s = mgr.spawn(AgentKind.CLAUDE, workspacePath = workspace.toString()) + + val staged = mgr.stageInput(s.id, "large document", "../source notes?.md") + + assertThat(staged.name).isEqualTo("source-notes-.md") + assertThat(staged.bytes).isEqualTo("large document".toByteArray().size.toLong()) + assertThat(Path.of(staged.path).parent).isEqualTo(workspace.resolve(".agent-inputs")) + assertThat(Files.readString(Path.of(staged.path))).isEqualTo("large document") + } + + @Test + fun `stageInput rejects content over configured byte cap`( + @TempDir tmp: Path, + ) { + val mgr = manager(tmp, GatewayProperties.StagedInputs(maxBytes = 4)) + val s = mgr.spawn(AgentKind.SHELL, workspacePath = tmp.resolve("workspace").toString()) + + assertThatThrownBy { mgr.stageInput(s.id, "12345", "too-large.txt") } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("exceeds 4 bytes") + } + + @Test + fun `stageInput rejects staging directory outside workspace`( + @TempDir tmp: Path, + ) { + val mgr = manager(tmp, GatewayProperties.StagedInputs(dirName = "../outside")) + val s = mgr.spawn(AgentKind.SHELL, workspacePath = tmp.resolve("workspace").toString()) + + assertThatThrownBy { mgr.stageInput(s.id, "content", "input.txt") } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("inside the workspace") + } + @Test fun `capture delegates to tmux capture`( @TempDir tmp: Path, diff --git a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentControllerTest.kt b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentControllerTest.kt index 3703d968..204193bc 100644 --- a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentControllerTest.kt +++ b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentControllerTest.kt @@ -3,6 +3,7 @@ package com.jorisjonkers.personalstack.agentgateway.web import com.jorisjonkers.personalstack.agentgateway.tmux.AgentKind import com.jorisjonkers.personalstack.agentgateway.tmux.AgentSession import com.jorisjonkers.personalstack.agentgateway.tmux.AgentSessionManager +import com.jorisjonkers.personalstack.agentgateway.tmux.StagedInput import io.mockk.every import io.mockk.mockk import org.junit.jupiter.api.Test @@ -81,4 +82,36 @@ class AgentControllerTest { .content("""{"input":"hi","enter":true}"""), ).andExpect(status().isAccepted) } + + @Test + fun `POST agents id staged-inputs returns staged file metadata`() { + every { + sessions.stageInput("abc12345", "large document", "source.txt") + } returns StagedInput(path = "/workspace/.agent-inputs/20260604-source.txt", bytes = 14, name = "source.txt") + + mockMvc + .perform( + post("/agents/abc12345/staged-inputs") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"content":"large document","name":"source.txt"}"""), + ).andExpect(status().isCreated) + .andExpect(jsonPath("$.path").value("/workspace/.agent-inputs/20260604-source.txt")) + .andExpect(jsonPath("$.bytes").value(14)) + .andExpect(jsonPath("$.name").value("source.txt")) + } + + @Test + fun `POST agents id staged-inputs returns 400 on invalid content`() { + every { + sessions.stageInput("abc12345", "", "source.txt") + } throws IllegalArgumentException("staged input content is empty") + + mockMvc + .perform( + post("/agents/abc12345/staged-inputs") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"content":"","name":"source.txt"}"""), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error").value("staged input content is empty")) + } } diff --git a/services/assistant-api/openapi.json b/services/assistant-api/openapi.json index ee470350..17da61e5 100644 --- a/services/assistant-api/openapi.json +++ b/services/assistant-api/openapi.json @@ -104,6 +104,51 @@ } } }, + "/api/v1/workspaces/{workspaceId}/sessions/{sessionId}/staged-inputs" : { + "post" : { + "tags" : [ "agent-session-controller" ], + "operationId" : "stageInput", + "parameters" : [ { + "name" : "workspaceId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "string", + "format" : "uuid" + } + }, { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "string", + "format" : "uuid" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/StageInputRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/StagedInputResponse" + } + } + } + } + } + } + }, "/api/v1/workspaces/{workspaceId}/sessions/{sessionId}/input" : { "post" : { "tags" : [ "agent-session-controller" ], @@ -1181,6 +1226,34 @@ }, "required" : [ "kind" ] }, + "StageInputRequest" : { + "type" : "object", + "properties" : { + "content" : { + "type" : "string" + }, + "name" : { + "type" : [ "string", "null" ] + } + }, + "required" : [ "content" ] + }, + "StagedInputResponse" : { + "type" : "object", + "properties" : { + "path" : { + "type" : "string" + }, + "bytes" : { + "type" : "integer", + "format" : "int64" + }, + "name" : { + "type" : "string" + } + }, + "required" : [ "bytes", "name", "path" ] + }, "SendUserInputRequest" : { "type" : "object", "properties" : { diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/AgentGatewayClient.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/AgentGatewayClient.kt index 4a48741f..c5796905 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/AgentGatewayClient.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/AgentGatewayClient.kt @@ -30,6 +30,12 @@ interface AgentGatewayClient { val detail: String, ) + data class StagedInput( + val path: String, + val bytes: Long, + val name: String, + ) + /** * Ask the standing agent-gateway to verify deploy-key access to * [repoUrl] on [branch] (HEAD when null). Returns null when no @@ -60,6 +66,13 @@ interface AgentGatewayClient { enter: Boolean = true, ) + fun stageInput( + workspace: Workspace, + gatewayAgentId: String, + content: String, + name: String?, + ): StagedInput + fun capture( workspace: Workspace, gatewayAgentId: String, diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/HttpAgentGatewayClient.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/HttpAgentGatewayClient.kt index 6b9e41c6..8860e48d 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/HttpAgentGatewayClient.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/HttpAgentGatewayClient.kt @@ -40,6 +40,17 @@ class HttpAgentGatewayClient( val enter: Boolean, ) + private data class StageInputBody( + val content: String, + val name: String?, + ) + + private data class StagedInputDto( + val path: String, + val bytes: Long, + val name: String, + ) + private data class CloneBody( val repoUrl: String, val branch: String? = null, @@ -121,6 +132,23 @@ class HttpAgentGatewayClient( .toBodilessEntity() } + override fun stageInput( + workspace: Workspace, + gatewayAgentId: String, + content: String, + name: String?, + ): AgentGatewayClient.StagedInput { + val dto = + restClient + .post() + .uri("${endpoint(workspace)}/agents/$gatewayAgentId/staged-inputs") + .body(StageInputBody(content, name)) + .retrieve() + .body(StagedInputDto::class.java) + ?: error("empty staged input response from gateway") + return AgentGatewayClient.StagedInput(path = dto.path, bytes = dto.bytes, name = dto.name) + } + override fun capture( workspace: Workspace, gatewayAgentId: String, diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AgentSessionController.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AgentSessionController.kt index dd6ddd1d..3787277f 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AgentSessionController.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AgentSessionController.kt @@ -6,7 +6,12 @@ import com.jorisjonkers.personalstack.assistant.application.command.StopAgentSes import com.jorisjonkers.personalstack.assistant.application.query.GetTurnHistoryQueryService import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId +import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.SendUserInputRequest +import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.StageInputRequest +import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.StagedInputResponse import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.StartAgentSessionRequest import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.TurnResponse import com.jorisjonkers.personalstack.common.command.CommandBus @@ -34,6 +39,9 @@ import java.util.UUID class AgentSessionController( private val commandBus: CommandBus, private val turnHistory: GetTurnHistoryQueryService, + private val sessions: WorkspaceAgentSessionRepository, + private val workspaces: WorkspaceRepository, + private val gateway: AgentGatewayClient, ) { @PostMapping fun start( @@ -67,6 +75,25 @@ class AgentSessionController( return ResponseEntity.accepted().build() } + @PostMapping("/{sessionId}/staged-inputs") + fun stageInput( + @PathVariable workspaceId: UUID, + @PathVariable sessionId: UUID, + @RequestBody req: StageInputRequest, + ): ResponseEntity { + val workspaceModelId = WorkspaceId(workspaceId) + val session = + sessions.findById(WorkspaceAgentSessionId(sessionId)) + ?: error("session not found: $sessionId") + require(session.workspaceId == workspaceModelId) { "session does not belong to workspace: $sessionId" } + val workspace = workspaces.findById(workspaceModelId) ?: error("workspace not found: $workspaceId") + val gatewayAgentId = session.gatewayAgentId ?: error("session not bound to a gateway agent yet") + val staged = gateway.stageInput(workspace, gatewayAgentId, req.content, req.name) + return ResponseEntity + .status(HttpStatus.CREATED) + .body(StagedInputResponse(path = staged.path, bytes = staged.bytes, name = staged.name)) + } + @GetMapping("/{sessionId}/turns") fun turns( @PathVariable workspaceId: UUID, diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/WorkspaceDtos.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/WorkspaceDtos.kt index 0fff38b7..2ab43823 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/WorkspaceDtos.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/WorkspaceDtos.kt @@ -109,6 +109,17 @@ data class SendUserInputRequest( val enter: Boolean = true, ) +data class StageInputRequest( + val content: String, + val name: String? = null, +) + +data class StagedInputResponse( + val path: String, + val bytes: Long, + val name: String, +) + data class TurnResponse( val id: UUID, val sessionId: UUID, diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AgentSessionControllerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AgentSessionControllerTest.kt new file mode 100644 index 00000000..2249d6ac --- /dev/null +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AgentSessionControllerTest.kt @@ -0,0 +1,92 @@ +package com.jorisjonkers.personalstack.assistant.infrastructure.web + +import com.jorisjonkers.personalstack.assistant.application.query.GetTurnHistoryQueryService +import com.jorisjonkers.personalstack.assistant.domain.model.Workspace +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentKind +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSession +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionStatus +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus +import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository +import com.jorisjonkers.personalstack.common.command.CommandBus +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import java.time.Instant + +class AgentSessionControllerTest { + private val commandBus = mockk(relaxed = true) + private val turnHistory = mockk(relaxed = true) + private val sessions = mockk() + private val workspaces = mockk() + private val gateway = mockk() + private val mockMvc: MockMvc = + MockMvcBuilders + .standaloneSetup(AgentSessionController(commandBus, turnHistory, sessions, workspaces, gateway)) + .build() + + private val workspaceId = WorkspaceId.random() + private val sessionId = WorkspaceAgentSessionId.random() + + @Test + fun `POST staged-inputs forwards to gateway and returns file metadata`() { + val workspace = workspace() + every { sessions.findById(sessionId) } returns agentSession() + every { workspaces.findById(workspaceId) } returns workspace + every { + gateway.stageInput(workspace, "abc12345", "large document", "source.txt") + } returns + AgentGatewayClient.StagedInput( + path = "/workspace/.agent-inputs/20260604-source.txt", + bytes = 14, + name = "source.txt", + ) + + mockMvc + .perform( + post("/api/v1/workspaces/${workspaceId.value}/sessions/${sessionId.value}/staged-inputs") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"content":"large document","name":"source.txt"}"""), + ).andExpect(status().isCreated) + .andExpect(jsonPath("$.path").value("/workspace/.agent-inputs/20260604-source.txt")) + .andExpect(jsonPath("$.bytes").value(14)) + .andExpect(jsonPath("$.name").value("source.txt")) + + verify { gateway.stageInput(workspace, "abc12345", "large document", "source.txt") } + } + + private fun agentSession() = + WorkspaceAgentSession( + id = sessionId, + workspaceId = workspaceId, + kind = WorkspaceAgentKind.CLAUDE, + gatewayAgentId = "abc12345", + status = WorkspaceAgentSessionStatus.RUNNING, + createdAt = Instant.now(), + updatedAt = Instant.now(), + ) + + private fun workspace() = + Workspace( + id = workspaceId, + name = "demo", + repoUrl = null, + branch = null, + podName = null, + pvcName = null, + gatewayEndpoint = "http://gw:8090", + status = WorkspaceStatus.READY, + createdAt = Instant.now(), + updatedAt = Instant.now(), + ) +} diff --git a/services/assistant-ui/src/api/generated.ts b/services/assistant-ui/src/api/generated.ts index 84c06089..49cb0def 100644 --- a/services/assistant-ui/src/api/generated.ts +++ b/services/assistant-ui/src/api/generated.ts @@ -44,6 +44,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/workspaces/{workspaceId}/sessions/{sessionId}/staged-inputs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["stageInput"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/workspaces/{workspaceId}/sessions/{sessionId}/input": { parameters: { query?: never; @@ -475,6 +491,16 @@ export interface components { /** @enum {string} */ kind: "CLAUDE" | "CODEX" | "SHELL"; }; + StageInputRequest: { + content: string; + name?: string | null; + }; + StagedInputResponse: { + path: string; + /** Format: int64 */ + bytes: number; + name: string; + }; SendUserInputRequest: { text: string; enter: boolean; @@ -718,6 +744,33 @@ export interface operations { }; }; }; + stageInput: { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + sessionId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["StageInputRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["StagedInputResponse"]; + }; + }; + }; + }; send: { parameters: { query?: never; diff --git a/services/assistant-ui/src/features/workspaces/__tests__/WorkspaceView.test.ts b/services/assistant-ui/src/features/workspaces/__tests__/WorkspaceView.test.ts index c14805d4..1937c37b 100644 --- a/services/assistant-ui/src/features/workspaces/__tests__/WorkspaceView.test.ts +++ b/services/assistant-ui/src/features/workspaces/__tests__/WorkspaceView.test.ts @@ -52,6 +52,8 @@ vi.mock('../services/sessionSocket', () => ({ })) const getWorkspace = vi.fn<(id: string) => Promise>() +const sendInput = vi.fn() +const stageInput = vi.fn() vi.mock('../services/workspaceService', () => ({ listWorkspaces: vi.fn(), getWorkspace: (id: string) => getWorkspace(id), @@ -60,7 +62,8 @@ vi.mock('../services/workspaceService', () => ({ startSession: vi.fn(), stopSession: vi.fn(), getTurns: vi.fn(async () => []), - sendInput: vi.fn(), + sendInput: (...args: unknown[]) => sendInput(...args), + stageInput: (...args: unknown[]) => stageInput(...args), })) function fakeSession(over: Partial = {}): AgentSession { @@ -118,6 +121,12 @@ async function mountView() { return wrapper } +function requireElement(selector: string): T { + const el = document.querySelector(selector) + if (!el) throw new Error(`missing element: ${selector}`) + return el +} + describe('workspaceView terminal persistence', () => { beforeEach(() => { setActivePinia(createPinia()) @@ -125,6 +134,8 @@ describe('workspaceView terminal persistence', () => { Object.values(socket).forEach((m) => m.mockClear()) attachSessionSocket.mockClear() getWorkspace.mockReset() + sendInput.mockReset() + stageInput.mockReset() vi.stubGlobal( 'ResizeObserver', class { @@ -170,6 +181,38 @@ describe('workspaceView terminal persistence', () => { expect(socket.close).toHaveBeenCalledTimes(1) expect(term.dispose).toHaveBeenCalledTimes(1) }) + + it('stages large text then sends only a pointer prompt to the active session', async () => { + getWorkspace.mockResolvedValue(detail([fakeSession({ id: 'sess-a', gatewayAgentId: 'abc12345' })])) + stageInput.mockResolvedValue({ + path: '/workspace/.agent-inputs/20260604-source.txt', + bytes: 14, + name: 'source.txt', + }) + const wrapper = await mountView() + + await wrapper.find('[data-testid="stage-input-open"]').trigger('click') + await flush() + const name = requireElement('[data-testid="stage-input-name"]') + const content = requireElement('[data-testid="stage-input-content"]') + const submit = requireElement('[data-testid="stage-input-submit"]') + name.value = 'source.txt' + name.dispatchEvent(new Event('input')) + content.value = 'large document' + content.dispatchEvent(new Event('input')) + await wrapper.vm.$nextTick() + submit.click() + await flush() + + expect(stageInput).toHaveBeenCalledWith('ws-1', 'sess-a', 'large document', 'source.txt') + expect(sendInput).toHaveBeenCalledWith( + 'ws-1', + 'sess-a', + 'Please read /workspace/.agent-inputs/20260604-source.txt and use it as the source document for the next task.', + true, + ) + expect(sendInput.mock.calls[0]?.[2]).not.toContain('large document') + }) }) async function flush(): Promise { diff --git a/services/assistant-ui/src/features/workspaces/services/workspaceService.ts b/services/assistant-ui/src/features/workspaces/services/workspaceService.ts index 0a018d92..4f0853c2 100644 --- a/services/assistant-ui/src/features/workspaces/services/workspaceService.ts +++ b/services/assistant-ui/src/features/workspaces/services/workspaceService.ts @@ -1,4 +1,4 @@ -import type { AgentKind, Turn, Workspace, WorkspaceDetail } from '../types' +import type { AgentKind, StagedInput, Turn, Workspace, WorkspaceDetail } from '../types' import { ApiError, useApiWithAuth } from '@personal-stack/vue-common' function getApi(): ReturnType { @@ -99,3 +99,15 @@ export async function getTurns(workspaceId: string, sessionId: string): Promise< export async function sendInput(workspaceId: string, sessionId: string, text: string, enter = true): Promise { return getApi().post(`/workspaces/${workspaceId}/sessions/${sessionId}/input`, { text, enter }) } + +export async function stageInput( + workspaceId: string, + sessionId: string, + content: string, + name?: string | null, +): Promise { + return getApi().post(`/workspaces/${workspaceId}/sessions/${sessionId}/staged-inputs`, { + content, + name: name?.trim() || null, + }) +} diff --git a/services/assistant-ui/src/features/workspaces/types/index.ts b/services/assistant-ui/src/features/workspaces/types/index.ts index 14b9af77..00673665 100644 --- a/services/assistant-ui/src/features/workspaces/types/index.ts +++ b/services/assistant-ui/src/features/workspaces/types/index.ts @@ -60,6 +60,12 @@ export interface Turn { createdAt: string } +export interface StagedInput { + path: string + bytes: number + name: string +} + export interface WorkspaceDetail { workspace: Workspace sessions: AgentSession[] diff --git a/services/assistant-ui/src/features/workspaces/views/WorkspaceView.vue b/services/assistant-ui/src/features/workspaces/views/WorkspaceView.vue index 9c50a70f..9c1f3bfe 100644 --- a/services/assistant-ui/src/features/workspaces/views/WorkspaceView.vue +++ b/services/assistant-ui/src/features/workspaces/views/WorkspaceView.vue @@ -1,23 +1,35 @@