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
6 changes: 6 additions & 0 deletions context/mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@
hook-authoritative sessions. `hook_observed=false` distinguishes a launched
runtime awaiting its first hook from a workspace with no agent runtime
(`internal/mcpserver/tools_agent.go::Server.listWorkspaceAgentSessions`).
- Follow-up MCP messages address one existing live agent runtime by workspace ID
and runtime session key. They reuse the initial prompt's serialized
bracketed-paste and Enter path, then return without launching, persisting, or
waiting for hook activity
(`internal/mcpserver/tools_agent.go::Server.sendAgentMessage`,
`internal/workspace/localruntime/manager.go::Manager.SubmitAgentMessage`).
- An omitted MCP agent target selects the most-used available workspace agent
from the prior 14 days; ties prefer recent use, then key, and empty history
falls back to configured order (`internal/mcpserver/tools_agent_spawn.go::Server.defaultAgentTarget`).
Expand Down
10 changes: 8 additions & 2 deletions docs/kenn-forge-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,14 @@ second agent.

Use `kenn_forge_list_workspace_agent_sessions` to inspect live agent runtimes
and fresh coding sessions. A runtime with `hook_observed=false` has launched but
has not reported its first hook. Historical sessions and arbitrary terminal
bytes are outside the MCP surface.
has not reported its first hook.

To send another instruction after the initial handoff, call
`kenn_forge_send_agent_message` with the persisted workspace ID, the live
runtime session key, and the message. The tool submits the prompt to that
running agent through the same runtime input path as the initial message. It
does not launch a runtime, resume a handoff, or wait for later agent activity.
Historical sessions and arbitrary terminal bytes are outside the MCP surface.

## Troubleshooting

Expand Down
13 changes: 13 additions & 0 deletions internal/mcpserver/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type LocalBackend interface {
CreateAdHocWorkspace(context.Context, RepositoryIdentity, string) (Workspace, error)
LaunchWorkspaceRuntime(context.Context, string, string) (RuntimeSession, error)
GetWorkspaceRuntime(context.Context, string) (WorkspaceRuntime, error)
SubmitAgentMessage(context.Context, AgentMessageRequest) (AgentMessageResult, error)
SubmitInitialMessage(context.Context, InitialMessageRequest) (InitialMessageStatus, error)
GetInitialMessage(context.Context, string, string) (InitialMessageStatus, error)
}
Expand Down Expand Up @@ -326,6 +327,18 @@ type WorkspaceRuntime struct {
Sessions []RuntimeSession
}

type AgentMessageRequest struct {
WorkspaceID string
RuntimeSessionKey string
Message string
}

type AgentMessageResult struct {
TargetKey string
MessageBytes int
SubmittedAt time.Time
}

type InitialMessageRequest struct {
WorkspaceID string
RuntimeSessionKey string
Expand Down
14 changes: 14 additions & 0 deletions internal/mcpserver/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,20 @@ esac
require.NotNil(spawn.Initial)
assert.Equal("delivered", spawn.Initial.State)
assert.Nil(spawn.LegacyClaim)

followUp := callTool[struct {
WorkspaceID string `json:"workspace_id"`
RuntimeSessionKey string `json:"runtime_session_key"`
TargetKey string `json:"target_key"`
MessageBytes int `json:"message_bytes"`
}](t, session, "kenn_forge_send_agent_message", map[string]any{
"workspace_id": workspace.ID, "runtime_session_key": runtimeSession.SessionKey,
"message": "keep going",
})
assert.Equal(workspace.ID, followUp.WorkspaceID)
assert.Equal(runtimeSession.SessionKey, followUp.RuntimeSessionKey)
assert.Equal("codex", followUp.TargetKey)
assert.Equal(10, followUp.MessageBytes)
}

func connectHTTPMCP(t *testing.T, endpoint, token string) *mcp.ClientSession {
Expand Down
8 changes: 8 additions & 0 deletions internal/mcpserver/fake_backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type fakeBackend struct {
createAdHocWorkspaceFn func(context.Context, RepositoryIdentity, string) (Workspace, error)
launchWorkspaceRuntimeFn func(context.Context, string, string) (RuntimeSession, error)
getWorkspaceRuntimeFn func(context.Context, string) (WorkspaceRuntime, error)
submitAgentMessageFn func(context.Context, AgentMessageRequest) (AgentMessageResult, error)
submitInitialMessageFn func(context.Context, InitialMessageRequest) (InitialMessageStatus, error)
getInitialMessageFn func(context.Context, string, string) (InitialMessageStatus, error)
}
Expand Down Expand Up @@ -167,6 +168,13 @@ func (b *fakeBackend) GetWorkspaceRuntime(ctx context.Context, workspaceID strin
return WorkspaceRuntime{}, nil
}

func (b *fakeBackend) SubmitAgentMessage(ctx context.Context, req AgentMessageRequest) (AgentMessageResult, error) {
if b.submitAgentMessageFn != nil {
return b.submitAgentMessageFn(ctx, req)
}
return AgentMessageResult{}, nil
}

func (b *fakeBackend) SubmitInitialMessage(ctx context.Context, req InitialMessageRequest) (InitialMessageStatus, error) {
if b.submitInitialMessageFn != nil {
return b.submitInitialMessageFn(ctx, req)
Expand Down
8 changes: 6 additions & 2 deletions internal/mcpserver/guidance.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ Recommended flow:
and one initial message. Report the workspace and runtime identifiers even
when a later stage fails.
11. Use `kenn_forge_list_workspace_agent_sessions` for fresh hook-reported
coding session IDs. Do not infer IDs from terminal text. Follow-up messaging
to an existing coding session is outside this MCP surface.
coding session IDs. Do not infer IDs from terminal text. To continue work in
a live runtime, call `kenn_forge_send_agent_message` with its workspace ID,
runtime session key, and the follow-up message.

Example guidance flow:

Expand All @@ -58,4 +59,7 @@ Handoff flow:
the same target and initial message. Resume never launches another runtime.
4. Report every returned workspace, runtime, prompt-delivery, and coding-session
identifier or state.
5. For later instructions, call `kenn_forge_send_agent_message` with the
workspace ID and runtime session key. It submits the message to that running
agent and does not launch or resume anything.
```
1 change: 1 addition & 0 deletions internal/mcpserver/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func TestRegisteredToolsResourcesAndPromptsAreCurated(t *testing.T) {
"kenn_forge_list_repos",
"kenn_forge_list_workspace_agent_sessions",
"kenn_forge_search_items",
"kenn_forge_send_agent_message",
"kenn_forge_set_item_workflow_state",
"kenn_forge_spawn_workspace_with_agent",
}, toolNames)
Expand Down
38 changes: 38 additions & 0 deletions internal/mcpserver/tools_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ type listWorkspaceAgentSessionsInput struct {
WorkspaceID string `json:"workspace_id" jsonschema:"persisted Kenn Forge workspace ID"`
}

type sendAgentMessageInput struct {
WorkspaceID string `json:"workspace_id" jsonschema:"persisted Kenn Forge workspace ID"`
RuntimeSessionKey string `json:"runtime_session_key" jsonschema:"live agent runtime session key"`
Message string `json:"message" jsonschema:"message to submit to the running coding agent"`
}

type sendAgentMessageOutput struct {
WorkspaceID string `json:"workspace_id"`
RuntimeSessionKey string `json:"runtime_session_key"`
TargetKey string `json:"target_key"`
MessageBytes int `json:"message_bytes"`
SubmittedAt string `json:"submitted_at"`
}

type agentInitialMessageRow struct {
State string `json:"state"`
MessageBytes int `json:"message_bytes"`
Expand Down Expand Up @@ -68,6 +82,11 @@ func (s *Server) registerAgentTools() {
Description: "List live agent runtimes and their fresh hook-authoritative coding sessions for one workspace. " +
"A runtime with hook_observed=false has launched but has not reported its first hook. This is a live projection, not session history.",
}, wrapTool(s.listWorkspaceAgentSessions))
mcp.AddTool(s.mcp, &mcp.Tool{
Name: "kenn_forge_send_agent_message",
Description: "Submit a follow-up message to one existing live coding-agent runtime. " +
"Use the workspace ID and runtime session key returned by Forge.",
}, wrapTool(s.sendAgentMessage))
mcp.AddTool(s.mcp, &mcp.Tool{
Name: "kenn_forge_spawn_workspace_with_agent",
Description: "Create or reuse a workspace, launch one configured coding agent, submit exactly one initial message, " +
Expand All @@ -77,6 +96,25 @@ func (s *Server) registerAgentTools() {
}, wrapTool(s.spawnWorkspaceWithAgent))
}

func (s *Server) sendAgentMessage(
ctx context.Context,
in sendAgentMessageInput,
) (sendAgentMessageOutput, error) {
workspaceID := strings.TrimSpace(in.WorkspaceID)
runtimeSessionKey := strings.TrimSpace(in.RuntimeSessionKey)
result, err := s.backend.SubmitAgentMessage(ctx, AgentMessageRequest{
WorkspaceID: workspaceID, RuntimeSessionKey: runtimeSessionKey, Message: in.Message,
})
if err != nil {
return sendAgentMessageOutput{}, err
}
return sendAgentMessageOutput{
WorkspaceID: workspaceID, RuntimeSessionKey: runtimeSessionKey,
TargetKey: result.TargetKey, MessageBytes: result.MessageBytes,
SubmittedAt: formatMCPTime(result.SubmittedAt),
}, nil
}

func (s *Server) listAgentTargets(
ctx context.Context,
_ listAgentTargetsInput,
Expand Down
40 changes: 40 additions & 0 deletions internal/mcpserver/tools_agent_message_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package mcpserver

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestSendAgentMessageSubmitsToExistingRuntime(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
submittedAt := time.Date(2026, 9, 3, 12, 0, 0, 0, time.UTC)
var got AgentMessageRequest
backend := &fakeBackend{submitAgentMessageFn: func(
_ context.Context, request AgentMessageRequest,
) (AgentMessageResult, error) {
got = request
return AgentMessageResult{
TargetKey: "codex", MessageBytes: 10, SubmittedAt: submittedAt,
}, nil
}}
s := newMCPTestServer(t, backend)

out, err := s.sendAgentMessage(t.Context(), sendAgentMessageInput{
WorkspaceID: " ws-1 ", RuntimeSessionKey: " runtime-1 ", Message: "keep going",
})

require.NoError(err)
assert.Equal(AgentMessageRequest{
WorkspaceID: "ws-1", RuntimeSessionKey: "runtime-1", Message: "keep going",
}, got)
assert.Equal("ws-1", out.WorkspaceID)
assert.Equal("runtime-1", out.RuntimeSessionKey)
assert.Equal("codex", out.TargetKey)
assert.Equal(10, out.MessageBytes)
assert.Equal("2026-09-03T12:00:00Z", out.SubmittedAt)
}
21 changes: 21 additions & 0 deletions internal/server/mcp_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,27 @@ func (b mcpBackend) GetWorkspaceRuntime(
return out, nil
}

func (b mcpBackend) SubmitAgentMessage(
ctx context.Context, req mcpserver.AgentMessageRequest,
) (mcpserver.AgentMessageResult, error) {
result, err := b.server.workspaceAPI.SubmitAgentMessageService(
ctx, req.WorkspaceID, req.RuntimeSessionKey, req.Message,
)
if errors.Is(err, workspaceapi.ErrInitialMessageInputModeNotReady) {
return mcpserver.AgentMessageResult{}, &mcpserver.Error{
Kind: "unavailable", Code: mcpserver.ErrorCodeInitialMessageInputModeNotReady,
Message: err.Error(), Retryable: true,
}
}
if err != nil {
return mcpserver.AgentMessageResult{}, mcpBackendError(err)
}
return mcpserver.AgentMessageResult{
TargetKey: result.TargetKey, MessageBytes: result.MessageBytes,
SubmittedAt: result.SubmittedAt,
}, nil
}

func (b mcpBackend) SubmitInitialMessage(
ctx context.Context, req mcpserver.InitialMessageRequest,
) (mcpserver.InitialMessageStatus, error) {
Expand Down
18 changes: 12 additions & 6 deletions internal/server/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4279,8 +4279,11 @@ port = 8091
repoWorktreeBaseRequest{WorktreeBasePath: worktreeBase})

require.Equal(http.StatusOK, response.Code, response.Body.String())
require.Len(srv.cfg.Repos, 1)
assert.Equal(canonicalWorktreeBase, srv.cfg.Repos[0].WorktreeBasePath)
srv.cfgMu.Lock()
configuredRepos := cloneReloadedConfig(srv.cfg).Repos
srv.cfgMu.Unlock()
require.Len(configuredRepos, 1)
assert.Equal(canonicalWorktreeBase, configuredRepos[0].WorktreeBasePath)

projection.Repos[0].Owner = "renamed"
projection.Repos[0].Name = "late-renamed"
Expand All @@ -4297,10 +4300,13 @@ port = 8091

require.Equal(http.StatusOK, response.Code, response.Body.String())
assert.Equal(int32(2), reads.Load(), "each mutation uses one pre-commit hub snapshot")
require.Len(srv.cfg.Repos, 1)
assert.Equal("renamed", srv.cfg.Repos[0].Owner)
assert.Equal("late-renamed", srv.cfg.Repos[0].Name)
assert.Empty(srv.cfg.Repos[0].WorktreeBasePath)
srv.cfgMu.Lock()
configuredRepos = cloneReloadedConfig(srv.cfg).Repos
srv.cfgMu.Unlock()
require.Len(configuredRepos, 1)
assert.Equal("renamed", configuredRepos[0].Owner)
assert.Equal("late-renamed", configuredRepos[0].Name)
assert.Empty(configuredRepos[0].WorktreeBasePath)
contents, err := os.ReadFile(configPath)
require.NoError(err)
assert.Contains(string(contents), `platform_repo_id = "repo-late"`)
Expand Down
48 changes: 48 additions & 0 deletions internal/server/workspaceapi/initial_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,54 @@ func (s *Handler) SubmitInitialMessageService(
return initialMessageAttemptResult(delivered), nil
}

func (s *Handler) SubmitAgentMessageService(
ctx context.Context, workspaceID, runtimeSessionKey, message string,
) (AgentMessageResult, error) {
if s.runtime == nil {
return AgentMessageResult{}, httpapi.ServiceUnavailable("agent message delivery not configured")
}
workspaceID = strings.TrimSpace(workspaceID)
if workspaceID == "" {
return AgentMessageResult{}, httpapi.Validation("workspace_id", "workspace_id is required")
}
runtimeSessionKey = strings.TrimSpace(runtimeSessionKey)
if runtimeSessionKey == "" {
return AgentMessageResult{}, httpapi.Validation(
"runtime_session_key", "runtime_session_key is required",
)
}
message, messageBytes, err := normalizeInitialAgentMessage(message)
if err != nil {
return AgentMessageResult{}, httpapi.Validation("message", err.Error())
}
targetKey := ""
for _, session := range s.runtime.ListSessions(workspaceID) {
if session.Key == runtimeSessionKey && session.Kind == localruntime.LaunchTargetAgent &&
(session.Status == localruntime.SessionStatusStarting ||
session.Status == localruntime.SessionStatusRunning) {
targetKey = session.TargetKey
break
}
}
if targetKey == "" {
return AgentMessageResult{}, httpapi.Conflict(
httpapi.CodeConflict, "agent runtime session is not live", nil,
)
}
if err := s.runtime.SubmitAgentMessage(ctx, workspaceID, runtimeSessionKey, message); err != nil {
if errors.Is(err, localruntime.ErrBracketedPasteInactive) {
return AgentMessageResult{}, ErrInitialMessageInputModeNotReady
}
if errors.Is(err, localruntime.ErrInitialMessageNotWritten) {
return AgentMessageResult{}, httpapi.Conflict(httpapi.CodeConflict, err.Error(), nil)
}
return AgentMessageResult{}, httpapi.Internal("submit agent message failed")
}
return AgentMessageResult{
TargetKey: targetKey, MessageBytes: messageBytes, SubmittedAt: s.now().UTC(),
}, nil
}

func (s *Handler) handleInitialMessageSubmitError(
workspaceID string,
runtimeSessionKey string,
Expand Down
11 changes: 11 additions & 0 deletions internal/server/workspaceapi/initial_message_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,17 @@ func TestSubmitInitialMessageServiceReturnsDeliveredStateAndRoutesShareAttempt(t
require.Equal(http.StatusConflict, response.Code, response.Body.String())
assert.Equal("\x1b[200~review this\x1b[201~\r", string(owner.pty.written()))

followUp, err := handler.SubmitAgentMessageService(
ctx, workspaceID, session.Key, "keep going",
)
require.NoError(err)
assert.Equal("codex", followUp.TargetKey)
assert.Equal(10, followUp.MessageBytes)
assert.Equal(
"\x1b[200~review this\x1b[201~\r\x1b[200~keep going\x1b[201~\r",
string(owner.pty.written()),
)

launchSession := func(codingSession string, report bool) localruntime.SessionInfo {
t.Helper()
launched, launchErr := runtime.Launch(ctx, workspaceID, worktree, "codex")
Expand Down
6 changes: 6 additions & 0 deletions internal/server/workspaceapi/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ type InitialMessageResult struct {
DeliveredAt *time.Time
}

type AgentMessageResult struct {
TargetKey string
MessageBytes int
SubmittedAt time.Time
}

func (s *Handler) resolveWorkspaceLaunchSpec(
ctx context.Context,
route providerplane.RepositoryRoute,
Expand Down
12 changes: 12 additions & 0 deletions internal/workspace/localruntime/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -1430,6 +1430,18 @@ func (m *Manager) SubmitInitialMessage(
workspaceID string,
sessionKey string,
message string,
) error {
return m.SubmitAgentMessage(ctx, workspaceID, sessionKey, message)
}

// SubmitAgentMessage writes one bounded, already-normalized prompt through a
// live agent runtime. It requires observed bracketed-paste mode and sends the
// complete paste frame and Enter in one serialized terminal operation.
func (m *Manager) SubmitAgentMessage(
ctx context.Context,
workspaceID string,
sessionKey string,
message string,
) error {
if err := context.Cause(ctx); err != nil {
return fmt.Errorf("%w: %w", ErrInitialMessageNotWritten, err)
Expand Down
Loading