Thanks to visit codestin.com
Credit goes to github.com

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: 11 additions & 4 deletions coderd/x/chatd/chattool/execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ type ExecuteResult struct {
type ExecuteOptions struct {
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
DefaultTimeout time.Duration
// AgentBrowserSession, when non-empty, is exported as
// AGENT_BROWSER_SESSION so agent-browser CLI invocations land in a
// browser session scoped to this chat instead of a shared default.
Comment thread
ibetitsmike marked this conversation as resolved.
AgentBrowserSession string
}

// ProcessToolOptions configures a process management tool
Expand Down Expand Up @@ -126,7 +130,7 @@ func Execute(options ExecuteOptions) fantasy.AgentTool {
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
return executeTool(ctx, conn, args, options.DefaultTimeout), nil
return executeTool(ctx, conn, args, options), nil
},
)
}
Expand All @@ -135,15 +139,18 @@ func executeTool(
ctx context.Context,
conn workspacesdk.AgentConn,
args ExecuteArgs,
optTimeout time.Duration,
options ExecuteOptions,
) fantasy.ToolResponse {
if args.Command == "" {
return fantasy.NewTextErrorResponse("command is required")
}

// Build the environment map for the process request.
env := make(map[string]string, len(nonInteractiveEnvVars)+1)
env := make(map[string]string, len(nonInteractiveEnvVars)+2)
env["CODER_CHAT_AGENT"] = "true"
if options.AgentBrowserSession != "" {
env["AGENT_BROWSER_SESSION"] = options.AgentBrowserSession
}
for k, v := range nonInteractiveEnvVars {
env[k] = v
}
Expand All @@ -168,7 +175,7 @@ func executeTool(
if background {
return executeBackground(ctx, conn, args.Command, workDir, env)
}
return executeForeground(ctx, conn, args, optTimeout, workDir, env)
return executeForeground(ctx, conn, args, options.DefaultTimeout, workDir, env)
}

// executeBackground starts a process in the background and
Expand Down
38 changes: 38 additions & 0 deletions coderd/x/chatd/chattool/execute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,44 @@ func TestExecuteTool(t *testing.T) {
assert.Equal(t, "hello world", result.Output)
assert.Empty(t, result.BackgroundProcessID)
assert.Equal(t, "true", capturedReq.Env["CODER_CHAT_AGENT"])
assert.NotContains(t, capturedReq.Env, "AGENT_BROWSER_SESSION")
})

t.Run("AgentBrowserSessionEnv", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)

var capturedReq workspacesdk.StartProcessRequest
mockConn.EXPECT().
StartProcess(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, req workspacesdk.StartProcessRequest) (workspacesdk.StartProcessResponse, error) {
capturedReq = req
return workspacesdk.StartProcessResponse{ID: "proc-1"}, nil
})
exitCode := 0
mockConn.EXPECT().
ProcessOutput(gomock.Any(), "proc-1", gomock.Any()).
Return(workspacesdk.ProcessOutputResponse{
Running: false,
ExitCode: &exitCode,
}, nil)

tool := chattool.Execute(chattool.ExecuteOptions{
GetWorkspaceConn: func(_ context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
AgentBrowserSession: "chat-123",
})
ctx := testutil.Context(t, testutil.WaitMedium)
resp, err := tool.Run(ctx, fantasy.ToolCall{
ID: "call-1",
Name: "execute",
Input: `{"command":"echo hello"}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
assert.Equal(t, "chat-123", capturedReq.Env["AGENT_BROWSER_SESSION"])
})

t.Run("ModelIntentIgnoredByExecution", func(t *testing.T) {
Expand Down
5 changes: 4 additions & 1 deletion coderd/x/chatd/generation_preparer.go
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,10 @@ func (server *Server) prepareGeneration(
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
StoreFile: storeChatAttachment,
}),
chattool.Execute(chattool.ExecuteOptions{GetWorkspaceConn: workspaceCtx.getWorkspaceConn}),
chattool.Execute(chattool.ExecuteOptions{
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
AgentBrowserSession: chat.ID.String(),
}),
chattool.ProcessOutput(chattool.ProcessToolOptions{GetWorkspaceConn: workspaceCtx.getWorkspaceConn}),
chattool.ProcessList(chattool.ProcessToolOptions{GetWorkspaceConn: workspaceCtx.getWorkspaceConn}),
chattool.ProcessSignal(chattool.ProcessToolOptions{GetWorkspaceConn: workspaceCtx.getWorkspaceConn}),
Expand Down
19 changes: 19 additions & 0 deletions site/src/modules/apps/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,25 @@ export const isWorkspaceAppEmbeddable = (app: WorkspaceApp): boolean => {
return !app.hidden && !isExternalApp(app) && !app.command;
};

export const AGENT_BROWSER_APP_SLUG = "agent-browser";

export const getAgentBrowserApp = (
agent: WorkspaceAgent | undefined,
): WorkspaceApp | undefined => {
const app = agent?.apps.find(
(agentApp) => agentApp.slug === AGENT_BROWSER_APP_SLUG,
);
// "disabled" means the template does not configure a health check.
if (
app &&
isWorkspaceAppEmbeddable(app) &&
(app.health === "healthy" || app.health === "disabled")
) {
return app;
}
return undefined;
};

/**
* True when an app requires subdomain access but the deployment has no wildcard
* access URL configured, so the app cannot be launched or embedded.
Expand Down
72 changes: 70 additions & 2 deletions site/src/pages/AgentsPage/AgentChatPage.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
import { act, renderHook } from "@testing-library/react";
import { createRef } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatMessage, ChatQueuedMessage } from "#/api/typesGenerated";
import type {
ChatMessage,
ChatQueuedMessage,
Workspace,
WorkspaceApp,
} from "#/api/typesGenerated";
import {
MockChatMessage,
MockChatQueuedMessage,
} from "#/testHelpers/chatEntities";
import { createDeferred } from "#/testHelpers/deferred";
import { MockUserOwner, MockWorkspace } from "#/testHelpers/entities";
import {
MockUserOwner,
MockWorkspace,
MockWorkspaceAgent,
MockWorkspaceApp,
} from "#/testHelpers/entities";
import {
buildInactiveChatQueueReconciliation,
draftInputStorageKeyPrefix,
getPersistedDraftInputValue,
getWorkspaceOptionsWithLinkedWorkspace,
isWatchedWorkspaceViewUnchanged,
reconcilePromotedQueueHead,
restoreOptimisticRequestSnapshot,
runPromoteQueuedMessage,
Expand Down Expand Up @@ -1393,3 +1404,60 @@ describe("sidebar tab persistence", () => {
});
});
});

describe("isWatchedWorkspaceViewUnchanged", () => {
const cloneWithApps = (apps: WorkspaceApp[]): Workspace => ({
...MockWorkspace,
latest_build: {
...MockWorkspace.latest_build,
resources: MockWorkspace.latest_build.resources.map((resource) => ({
...resource,
agents: resource.agents?.map((agent) =>
agent.id === MockWorkspaceAgent.id ? { ...agent, apps } : agent,
),
})),
},
});

it("is true for a fresh payload with only unwatched changes", () => {
const next: Workspace = {
...MockWorkspace,
last_used_at: "2024-01-01T00:00:00Z",
};

expect(
isWatchedWorkspaceViewUnchanged(
MockWorkspace,
next,
MockWorkspaceAgent.id,
),
).toBe(true);
});

it("is false when a bound-agent app changes health", () => {
const next = cloneWithApps([{ ...MockWorkspaceApp, health: "healthy" }]);

expect(
isWatchedWorkspaceViewUnchanged(
MockWorkspace,
next,
MockWorkspaceAgent.id,
),
).toBe(false);
});

it("is false when the bound agent gains an app", () => {
const next = cloneWithApps([
MockWorkspaceApp,
{ ...MockWorkspaceApp, id: "second-app", slug: "second-app" },
]);

expect(
isWatchedWorkspaceViewUnchanged(
MockWorkspace,
next,
MockWorkspaceAgent.id,
),
).toBe(false);
});
});
56 changes: 45 additions & 11 deletions site/src/pages/AgentsPage/AgentChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,50 @@ export const getWorkspaceOptionsWithLinkedWorkspace = (
return nextWorkspaceOptions;
};

// Keep this list in sync with app fields consumed by the chat UI, or live
// updates to those fields can retain stale query data.
const watchedAgentAppFields: readonly (keyof TypesGen.WorkspaceApp)[] = [
"id",
"slug",
"health",
"hidden",
"external",
"command",
"subdomain",
"subdomain_name",
"display_name",
];

/** @internal Exported for testing. */
export const isWatchedWorkspaceViewUnchanged = (
prev: TypesGen.Workspace,
next: TypesGen.Workspace,
chatAgentId: string | undefined,
): boolean => {
const prevAgent = getWorkspaceAgent(prev, chatAgentId);
const nextAgent = getWorkspaceAgent(next, chatAgentId);
const prevApps = prevAgent?.apps ?? [];
const nextApps = nextAgent?.apps ?? [];
return (
prev.latest_build.status === next.latest_build.status &&
prev.health.healthy === next.health.healthy &&
prev.name === next.name &&
prev.owner_name === next.owner_name &&
prevAgent?.id === nextAgent?.id &&
prevAgent?.status === nextAgent?.status &&
prevAgent?.name === nextAgent?.name &&
prevAgent?.expanded_directory === nextAgent?.expanded_directory &&
prevAgent?.lifecycle_state === nextAgent?.lifecycle_state &&
prevApps.length === nextApps.length &&
prevApps.every((prevApp, index) => {
const nextApp = nextApps[index];
return watchedAgentAppFields.every(
(field) => prevApp[field] === nextApp[field],
);
})
);
};

const buildAttachmentMediaTypes = (
attachments?: readonly PendingAttachment[],
): ReadonlyMap<string, string> | undefined => {
Expand Down Expand Up @@ -967,19 +1011,9 @@ const AgentChatPage: FC = () => {
// reads has changed. This prevents react-query
// from notifying subscribers and avoids a full
// AgentChatPage re-render on every heartbeat.
const prevAgent = getWorkspaceAgent(prev, chatAgentId);
const nextAgent = getWorkspaceAgent(next, chatAgentId);
if (
prev &&
prev.latest_build.status === next.latest_build.status &&
prev.health.healthy === next.health.healthy &&
prev.name === next.name &&
prev.owner_name === next.owner_name &&
prevAgent?.id === nextAgent?.id &&
prevAgent?.status === nextAgent?.status &&
prevAgent?.name === nextAgent?.name &&
prevAgent?.expanded_directory === nextAgent?.expanded_directory &&
prevAgent?.lifecycle_state === nextAgent?.lifecycle_state
isWatchedWorkspaceViewUnchanged(prev, next, chatAgentId)
) {
return prev;
}
Expand Down
Loading
Loading