Compare commits
2
Commits
7a7bc983e6
...
423d9edbfa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
423d9edbfa | ||
|
|
f1914a5d0a |
@@ -6,6 +6,8 @@ TWITTER_LITE_DB_PATH=/absolute/path/to/twitter-lite/.data/workspace.sqlite
|
|||||||
# TWITTER_LITE_CREDENTIAL_KEY_FILE=/absolute/path/to/credential-key
|
# TWITTER_LITE_CREDENTIAL_KEY_FILE=/absolute/path/to/credential-key
|
||||||
TWITTER_LITE_MASTODON_ORIGINS=https://fedi.yutakobayashi.com
|
TWITTER_LITE_MASTODON_ORIGINS=https://fedi.yutakobayashi.com
|
||||||
# Optional Codex research. The AI SDK provider manages its local app-server.
|
# Optional Codex research. The AI SDK provider manages its local app-server.
|
||||||
|
# If your signed-in CLI uses a custom home, use the same path on the server.
|
||||||
|
# CODEX_HOME=/absolute/path/to/codex-home
|
||||||
# TWITTER_LITE_CODEX_PATH=/absolute/path/to/codex
|
# TWITTER_LITE_CODEX_PATH=/absolute/path/to/codex
|
||||||
# TWITTER_LITE_CODEX_MODEL=gpt-6-astra
|
# TWITTER_LITE_CODEX_MODEL=gpt-6-astra
|
||||||
# TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research
|
# TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research
|
||||||
|
|||||||
@@ -151,8 +151,15 @@ TWITTER_LITE_CODEX_MODEL=gpt-6-astra
|
|||||||
TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research
|
TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research
|
||||||
# Optional:
|
# Optional:
|
||||||
# TWITTER_LITE_CODEX_PATH=/absolute/path/to/codex
|
# TWITTER_LITE_CODEX_PATH=/absolute/path/to/codex
|
||||||
|
# CODEX_HOME=/absolute/path/to/codex-home
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If your CLI sets `CODEX_HOME`, set the same path in the server environment
|
||||||
|
(`.env.local` for development). A systemd service does not inherit your shell's
|
||||||
|
environment. Without it, research uses `~/.codex`, which may have different or
|
||||||
|
expired credentials. Authentication failures appear separately in chat; the
|
||||||
|
underlying error and conversation ID are logged on the server.
|
||||||
|
|
||||||
This provider does not register AI SDK `tools` as Codex dynamic tools.
|
This provider does not register AI SDK `tools` as Codex dynamic tools.
|
||||||
The existing validated, account-scoped research functions are exposed through
|
The existing validated, account-scoped research functions are exposed through
|
||||||
its in-process `createSdkMcpServer` bridge instead. Streaming uses `streamText`
|
its in-process `createSdkMcpServer` bridge instead. Streaming uses `streamText`
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ research definitions and executors are exposed with `createSdkMcpServer` instead
|
|||||||
Validation, connected-account scope, request budgets, evidence tracking, and
|
Validation, connected-account scope, request budgets, evidence tracking, and
|
||||||
read-only SNS behavior remain in `agent-tools.server.ts`. Provider execution is
|
read-only SNS behavior remain in `agent-tools.server.ts`. Provider execution is
|
||||||
one agent turn; there is no second AI SDK tool loop replaying those calls.
|
one agent turn; there is no second AI SDK tool loop replaying those calls.
|
||||||
|
Only the registered research tools receive per-tool MCP approval overrides for
|
||||||
|
non-interactive execution. Shell approvals and external MCP servers remain
|
||||||
|
disabled. Failed or truncated Codex turns are treated as failures even when the
|
||||||
|
provider emits a finish event without an error event.
|
||||||
Resumed threads that already registered dynamic tools route their calls through
|
Resumed threads that already registered dynamic tools route their calls through
|
||||||
`onDynamicToolCall` to the same validated executor.
|
`onDynamicToolCall` to the same validated executor.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Icon } from "#/components/icon";
|
import { Icon } from "#/components/icon";
|
||||||
import { Badge } from "#/components/ui/badge";
|
import { Badge } from "#/components/ui/badge";
|
||||||
import { Button } from "#/components/ui/button";
|
import { Button } from "#/components/ui/button";
|
||||||
|
import { Skeleton } from "#/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -22,6 +23,7 @@ import { describeSource } from "./source-description";
|
|||||||
export function ResearchColumn({
|
export function ResearchColumn({
|
||||||
column,
|
column,
|
||||||
connectionLabel,
|
connectionLabel,
|
||||||
|
connectionLoading = false,
|
||||||
onEdit,
|
onEdit,
|
||||||
onRemove,
|
onRemove,
|
||||||
onMove,
|
onMove,
|
||||||
@@ -32,6 +34,7 @@ export function ResearchColumn({
|
|||||||
}: {
|
}: {
|
||||||
column: DeckColumn;
|
column: DeckColumn;
|
||||||
connectionLabel?: string;
|
connectionLabel?: string;
|
||||||
|
connectionLoading?: boolean;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onMove: (direction: -1 | 1) => void;
|
onMove: (direction: -1 | 1) => void;
|
||||||
@@ -112,9 +115,13 @@ export function ResearchColumn({
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
<div className="deck-column-label">
|
<div className="deck-column-label">
|
||||||
<span title={`Connection profile: ${connectionLabel ?? "Connection not found"}`}>
|
{connectionLoading ? (
|
||||||
{connectionLabel ?? "Connection not found"}
|
<Skeleton className="h-3 w-24 shrink-0" aria-hidden="true" />
|
||||||
</span>
|
) : (
|
||||||
|
<span title={`Connection profile: ${connectionLabel ?? "Connection not found"}`}>
|
||||||
|
{connectionLabel ?? "Connection not found"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Badge variant="outline" className="deck-source-label">
|
<Badge variant="outline" className="deck-source-label">
|
||||||
<Icon name={column.source.kind} />
|
<Icon name={column.source.kind} />
|
||||||
{sourceDescription.label}
|
{sourceDescription.label}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
} from "#/components/ui/empty";
|
} from "#/components/ui/empty";
|
||||||
import { Input } from "#/components/ui/input";
|
import { Input } from "#/components/ui/input";
|
||||||
|
import { Skeleton } from "#/components/ui/skeleton";
|
||||||
import { loadConnections } from "#/features/connections/server-functions";
|
import { loadConnections } from "#/features/connections/server-functions";
|
||||||
import { syncResearchDeck } from "#/features/research/research-deck-sync";
|
import { syncResearchDeck } from "#/features/research/research-deck-sync";
|
||||||
import { ResearchPanel } from "#/features/research/research-panel";
|
import { ResearchPanel } from "#/features/research/research-panel";
|
||||||
@@ -27,6 +28,7 @@ import { type ColumnRegistry, useColumnTools } from "./column-tools";
|
|||||||
import { ResearchColumn } from "./deck-column";
|
import { ResearchColumn } from "./deck-column";
|
||||||
import { DeckSwitcher } from "./deck-switcher";
|
import { DeckSwitcher } from "./deck-switcher";
|
||||||
import { type DeckColumn, MAX_COLUMNS, moveColumn } from "./model";
|
import { type DeckColumn, MAX_COLUMNS, moveColumn } from "./model";
|
||||||
|
import { ResearchDeckSkeleton } from "./research-deck-skeleton";
|
||||||
import { useDeck } from "./use-deck";
|
import { useDeck } from "./use-deck";
|
||||||
import { useDeckTools } from "./webmcp-tools";
|
import { useDeckTools } from "./webmcp-tools";
|
||||||
|
|
||||||
@@ -202,10 +204,19 @@ export function DeckPage() {
|
|||||||
>
|
>
|
||||||
<div className="deck-heading">
|
<div className="deck-heading">
|
||||||
<div>
|
<div>
|
||||||
<h1>{deck.title}</h1>
|
{ready ? (
|
||||||
<span className="deck-count">
|
<>
|
||||||
{deck.columns.length} / {MAX_COLUMNS} columns
|
<h1>{deck.title}</h1>
|
||||||
</span>
|
<span className="deck-count">
|
||||||
|
{deck.columns.length} / {MAX_COLUMNS} columns
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2" aria-hidden="true">
|
||||||
|
<Skeleton className="h-5 w-40" />
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="deck-actions">
|
<div className="deck-actions">
|
||||||
{deck.persisted ? (
|
{deck.persisted ? (
|
||||||
@@ -260,11 +271,6 @@ export function DeckPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{!ready ? (
|
|
||||||
<p className="deck-save-status" role="status">
|
|
||||||
Loading decks…
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{profiles.isError ? (
|
{profiles.isError ? (
|
||||||
<p role="alert" className="deck-error">
|
<p role="alert" className="deck-error">
|
||||||
Unable to load connection profiles.
|
Unable to load connection profiles.
|
||||||
@@ -288,7 +294,6 @@ export function DeckPage() {
|
|||||||
{profiles.data.relayError}
|
{profiles.data.relayError}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{profiles.isPending ? <p role="status">Loading connection profiles…</p> : null}
|
|
||||||
{!renaming && editing === null && !switcherEditing ? storageErrorNotice : null}
|
{!renaming && editing === null && !switcherEditing ? storageErrorNotice : null}
|
||||||
{renaming ? (
|
{renaming ? (
|
||||||
<Dialog title="Rename deck" onClose={() => setRenaming(false)}>
|
<Dialog title="Rename deck" onClose={() => setRenaming(false)}>
|
||||||
@@ -355,6 +360,7 @@ export function DeckPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!ready && !storageError ? <ResearchDeckSkeleton /> : null}
|
||||||
{ready && deck.columns.length === 0 && editing === null ? (
|
{ready && deck.columns.length === 0 && editing === null ? (
|
||||||
<Empty>
|
<Empty>
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
@@ -388,6 +394,7 @@ export function DeckPage() {
|
|||||||
key={`${deck.id}:${column.id}`}
|
key={`${deck.id}:${column.id}`}
|
||||||
registry={registry}
|
registry={registry}
|
||||||
column={column}
|
column={column}
|
||||||
|
connectionLoading={profiles.isPending}
|
||||||
citation={
|
citation={
|
||||||
citationNavigation.target?.deckId === deck.id &&
|
citationNavigation.target?.deckId === deck.id &&
|
||||||
citationNavigation.target.column.id === column.id &&
|
citationNavigation.target.column.id === column.id &&
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Skeleton } from "#/components/ui/skeleton";
|
||||||
|
import { ResearchPostSkeleton } from "./research-post-skeleton";
|
||||||
|
|
||||||
|
export function ResearchDeckSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="deck-board" role="status" aria-label="Loading research">
|
||||||
|
{["first", "second", "third"].map((column) => (
|
||||||
|
<div key={column} className="deck-column" aria-hidden="true">
|
||||||
|
<div className="deck-column-header space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="size-4 shrink-0" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
<Skeleton className="h-3 w-40" />
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
<div className="deck-column-results">
|
||||||
|
<ResearchPostSkeleton />
|
||||||
|
<ResearchPostSkeleton />
|
||||||
|
<ResearchPostSkeleton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -129,6 +129,12 @@ export async function executeCodexResearch(input: CodexResearchInput): Promise<v
|
|||||||
web_search: "disabled",
|
web_search: "disabled",
|
||||||
...Object.fromEntries(inherited.map((server) => [`mcp_servers.${server}.enabled`, false])),
|
...Object.fromEntries(inherited.map((server) => [`mcp_servers.${server}.enabled`, false])),
|
||||||
[`mcp_servers.${name}.enabled`]: true,
|
[`mcp_servers.${name}.enabled`]: true,
|
||||||
|
...Object.fromEntries(
|
||||||
|
input.tools.definitions.map((tool) => [
|
||||||
|
`mcp_servers.${name}.tools.${tool.name}.approval_mode`,
|
||||||
|
"approve",
|
||||||
|
]),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
onSessionCreated: (session) => {
|
onSessionCreated: (session) => {
|
||||||
if (!input.signal.aborted) input.onThread(session.threadId);
|
if (!input.signal.aborted) input.onThread(session.threadId);
|
||||||
@@ -152,8 +158,12 @@ export async function executeCodexResearch(input: CodexResearchInput): Promise<v
|
|||||||
if (part.type === "error") throw part.error;
|
if (part.type === "error") throw part.error;
|
||||||
}
|
}
|
||||||
input.signal.throwIfAborted();
|
input.signal.throwIfAborted();
|
||||||
const metadata = (await result.finalStep).providerMetadata?.["codex-app-server"];
|
const step = await result.finalStep;
|
||||||
|
const metadata = step.providerMetadata?.["codex-app-server"];
|
||||||
if (typeof metadata?.threadId === "string") input.onThread(metadata.threadId);
|
if (typeof metadata?.threadId === "string") input.onThread(metadata.threadId);
|
||||||
|
if (step.finishReason === "error" || step.finishReason === "length") {
|
||||||
|
throw new Error(step.rawFinishReason || "Codex could not complete the turn.");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await provider.close();
|
await provider.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ const fake = vi.hoisted(() => ({
|
|||||||
stream: vi.fn<
|
stream: vi.fn<
|
||||||
(options: unknown) => {
|
(options: unknown) => {
|
||||||
stream: AsyncIterable<{ type: string; id?: string; text?: string; error?: Error }>;
|
stream: AsyncIterable<{ type: string; id?: string; text?: string; error?: Error }>;
|
||||||
finalStep?: Promise<{ providerMetadata: { "codex-app-server": { threadId: string } } }>;
|
finalStep?: Promise<{
|
||||||
|
finishReason: "stop" | "error" | "length";
|
||||||
|
rawFinishReason?: string;
|
||||||
|
providerMetadata: { "codex-app-server": { threadId: string } };
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
>(),
|
>(),
|
||||||
smooth: vi.fn<(options: unknown) => string>(),
|
smooth: vi.fn<(options: unknown) => string>(),
|
||||||
@@ -78,6 +82,7 @@ beforeEach(() => {
|
|||||||
yield { type: "text-delta", id: "answer", text: "世界" };
|
yield { type: "text-delta", id: "answer", text: "世界" };
|
||||||
})(),
|
})(),
|
||||||
finalStep: Promise.resolve({
|
finalStep: Promise.resolve({
|
||||||
|
finishReason: "stop",
|
||||||
providerMetadata: {
|
providerMetadata: {
|
||||||
"codex-app-server": { threadId: "persistent-thread" },
|
"codex-app-server": { threadId: "persistent-thread" },
|
||||||
},
|
},
|
||||||
@@ -165,6 +170,30 @@ it("fails closed on an unreadable or malformed inherited MCP configuration", asy
|
|||||||
expect(fake.create).not.toHaveBeenCalled();
|
expect(fake.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preapproves only this run's supplied MCP tools while preserving global restrictions", async () => {
|
||||||
|
const request = input();
|
||||||
|
request.tools.definitions.push({
|
||||||
|
name: "fetch_column_posts",
|
||||||
|
description: "Read scoped posts",
|
||||||
|
inputSchema: { type: "object" },
|
||||||
|
});
|
||||||
|
await executeCodexResearch(request);
|
||||||
|
const settings = fake.create.mock.calls[0]?.[0].defaultSettings;
|
||||||
|
expect(settings).toMatchObject({
|
||||||
|
approvalPolicy: "never",
|
||||||
|
autoApprove: false,
|
||||||
|
sandboxPolicy: "workspace-write",
|
||||||
|
});
|
||||||
|
const overrides = settings?.configOverrides ?? {};
|
||||||
|
expect(
|
||||||
|
Object.fromEntries(Object.entries(overrides).filter(([key]) => key.endsWith("approval_mode"))),
|
||||||
|
).toEqual({
|
||||||
|
"mcp_servers.workspace_research_testrun.tools.list_decks.approval_mode": "approve",
|
||||||
|
"mcp_servers.workspace_research_testrun.tools.fetch_column_posts.approval_mode": "approve",
|
||||||
|
});
|
||||||
|
expect(overrides["mcp_servers.external.enabled"]).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("closes the provider after a stream error", async () => {
|
it("closes the provider after a stream error", async () => {
|
||||||
fake.stream.mockReturnValue({
|
fake.stream.mockReturnValue({
|
||||||
stream: (async function* () {
|
stream: (async function* () {
|
||||||
@@ -175,6 +204,30 @@ it("closes the provider after a stream error", async () => {
|
|||||||
expect(fake.model.close).toHaveBeenCalledOnce();
|
expect(fake.model.close).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["error", "workspace routing discovery unauthorized (401)"],
|
||||||
|
["length", "usage_limit_exceeded"],
|
||||||
|
["length", "context_window_exceeded"],
|
||||||
|
] as const)(
|
||||||
|
"rejects an incomplete %s finish without an error event: %s",
|
||||||
|
async (finishReason, rawFinishReason) => {
|
||||||
|
const request = input();
|
||||||
|
fake.stream.mockReturnValue({
|
||||||
|
stream: (async function* () {
|
||||||
|
yield { type: "finish" };
|
||||||
|
})(),
|
||||||
|
finalStep: Promise.resolve({
|
||||||
|
finishReason,
|
||||||
|
rawFinishReason,
|
||||||
|
providerMetadata: { "codex-app-server": { threadId: "failed-thread" } },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await expect(executeCodexResearch(request)).rejects.toThrow(rawFinishReason);
|
||||||
|
expect(request.onThread).toHaveBeenCalledWith("failed-thread");
|
||||||
|
expect(fake.model.close).toHaveBeenCalledOnce();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("propagates cancellation and prevents late bridge calls or metadata changes", async () => {
|
it("propagates cancellation and prevents late bridge calls or metadata changes", async () => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const request = { ...input(), signal: controller.signal };
|
const request = { ...input(), signal: controller.signal };
|
||||||
|
|||||||
@@ -220,11 +220,22 @@ export function createResearchService(
|
|||||||
current.status = "complete";
|
current.status = "complete";
|
||||||
current.reportPath = info ? reportPath : undefined;
|
current.reportPath = info ? reportPath : undefined;
|
||||||
emit(current);
|
emit(current);
|
||||||
} catch {
|
} catch (error) {
|
||||||
if (active(execution)) {
|
if (active(execution)) {
|
||||||
|
process.stderr.write(
|
||||||
|
`${JSON.stringify({
|
||||||
|
event: "research_failed",
|
||||||
|
runId: current.id,
|
||||||
|
error:
|
||||||
|
error instanceof Error ? { name: error.name, message: error.message } : String(error),
|
||||||
|
})}\n`,
|
||||||
|
);
|
||||||
current.status = "failed";
|
current.status = "failed";
|
||||||
current.error =
|
current.error =
|
||||||
"Unable to complete research. Check Codex, its login, selected accounts, and the report location.";
|
error instanceof Error &&
|
||||||
|
/unauthorized|\b401\b|authentication|not logged in/i.test(error.message)
|
||||||
|
? "Codex authentication failed. Make sure the server uses the CODEX_HOME of your signed-in Codex CLI."
|
||||||
|
: "Unable to complete research. Check Codex, its login, selected accounts, and the report location.";
|
||||||
emit(current);
|
emit(current);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -333,6 +333,18 @@ it("retains a failed startup conversation for retry even before a Codex thread e
|
|||||||
expect(turns[0]?.input.threadId).toBeUndefined();
|
expect(turns[0]?.input.threadId).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("identifies Codex authentication failures without exposing the raw provider error", async () => {
|
||||||
|
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||||
|
launch = async () => {
|
||||||
|
throw new Error("workspace routing discovery unauthorized (401)");
|
||||||
|
};
|
||||||
|
const run = service.start(input());
|
||||||
|
await vi.waitFor(() => expect(service.status(run.id).run?.status).toBe("failed"));
|
||||||
|
expect(service.status(run.id).run?.error).toBe(
|
||||||
|
"Codex authentication failed. Make sure the server uses the CODEX_HOME of your signed-in Codex CLI.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("bounds long-running work and aborts it at the time limit", async () => {
|
it("bounds long-running work and aborts it at the time limit", async () => {
|
||||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||||
const run = service.start(input());
|
const run = service.start(input());
|
||||||
|
|||||||
Reference in New Issue
Block a user