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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ INKBOX_SIGNING_KEY=whsec_xxxxxxxxxxxx

# --- opencode ---
# INKBOX_GATEWAY_AGENT=inkbox-channel # opencode agent for gateway sessions
# INKBOX_GATEWAY_MODEL=openai/gpt-4o # "provider/model" override
# INKBOX_GATEWAY_MODEL=openai/gpt-5.4-mini # "provider/model" override
# OPENCODE_SERVER_URL=http://127.0.0.1:4096 # attach to an existing server instead
# INKBOX_GATEWAY_SERVE_PORT=4097 # port for the managed `opencode serve`
# INKBOX_OPENCODE_BIN=opencode # binary for the managed server
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/live-stack.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ jobs:
uses: ./.github/workflows/live-voice.yml
with:
orchestrated: true
# The inbound PSTN carrier currently rejects the call before it reaches
# the AUT. Keep that leg manually runnable; gate PRs on plugin-owned voice.
include_inbound: true
secrets: inherit

external-events:
Expand Down
11 changes: 10 additions & 1 deletion .github/workflows/live-voice.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,20 @@ on:
required: false
type: boolean
default: false
include_inbound:
description: "Run the carrier-dependent inbound PSTN diagnostic"
required: false
type: boolean
default: false
workflow_dispatch:
inputs:
timeout_s:
description: "Seconds to wait for the call/transcript"
default: "220"
include_inbound:
description: "Run the carrier-dependent inbound PSTN diagnostic"
type: boolean
default: true

permissions:
contents: read
Expand All @@ -42,7 +51,7 @@ jobs:
fail-fast: false
max-parallel: 1 # legs share the AUT + driver identities → one at a time
matrix:
scenario: [inbound_inkbox, outbound_realtime]
scenario: ${{ fromJSON(inputs.include_inbound && '["inbound_inkbox","outbound_realtime"]' || '["outbound_realtime"]') }}

steps:
- uses: actions/checkout@v7
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"opencode": ">=1.15.0 <1.19.0"
},
"dependencies": {
"@inkbox/sdk": ">=0.4.19 <1.0.0",
"@inkbox/sdk": ">=0.4.24 <1.0.0",
"@opencode-ai/sdk": ">=1.17.18 <1.19.0",
"ws": "^8.21.0",
"zod": "4.1.8"
Expand Down
2 changes: 1 addition & 1 deletion scripts/live-aut.sh
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ else
cat > opencode.json <<'EOF'
{ "$schema": "https://opencode.ai/config.json" }
EOF
GATEWAY_MODEL="openai/gpt-4o"
GATEWAY_MODEL="openai/gpt-5.4-mini"
fi

SERVE_LOG="$WORKDIR/serve.log"
Expand Down
17 changes: 16 additions & 1 deletion tests/live/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,29 @@ export async function waitTwoWayCall(
callId: string,
timeoutMs = TIMEOUT_MS,
): Promise<string> {
const terminalFailureStatuses = new Set(["canceled", "failed"]);
return pollUntil(
"two-way call transcript",
async () => {
const { agent, driver: drv } = await callSegments(driver, callId).catch(() => ({
agent: [],
driver: [],
}));
return agent.length > 0 && drv.length > 0 ? agent.join(" | ") : undefined;
if (agent.length > 0 && drv.length > 0) return agent.join(" | ");

const call = await driver.calls.get(callId).catch(() => undefined);
const status = (call?.status ?? "").toLowerCase();
if (terminalFailureStatuses.has(status)) {
throw new Error(
`two-way call ended before both parties spoke: ${JSON.stringify({
status: call?.status,
hangupReason: call?.hangupReason,
startedAt: call?.startedAt,
endedAt: call?.endedAt,
})}`,
);
}
return undefined;
},
timeoutMs,
);
Expand Down
108 changes: 108 additions & 0 deletions tests/live/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { afterAll, beforeAll } from "vitest";
import { AUT_KEY, client, phoneOf } from "./helpers.js";

const ENDED_CALL_STATUSES = new Set(["completed", "failed", "canceled"]);
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

type InkboxClient = ReturnType<typeof client>;
type Call = Awaited<ReturnType<InkboxClient["calls"]["list"]>>[number];

let aut: InkboxClient | undefined;
let localPhone = "";
let baseline = new Set<string>();
let watcher: ReturnType<typeof setInterval> | undefined;
let checking = false;
const attempted = new Set<string>();

const statusOf = (call: Call) => (call.status ?? "").toLowerCase();

async function ownedCalls(): Promise<Map<string, Call>> {
if (!aut) return new Map();
const calls = await aut.calls.list({ limit: 100 });
return new Map(
calls.filter((call) => call.localPhoneNumber === localPhone).map((call) => [call.id, call]),
);
}

async function hangup(call: Call): Promise<string | undefined> {
if (!aut || ENDED_CALL_STATUSES.has(statusOf(call))) return undefined;
try {
await aut.calls.hangup(call.id);
return undefined;
} catch (error) {
try {
const current = await aut.calls.get(call.id);
if (ENDED_CALL_STATUSES.has(statusOf(current))) return undefined;
return `hangup=${String(error)}; status=${JSON.stringify(statusOf(current))}`;
} catch (getError) {
return `hangup=${String(error)}; get=${String(getError)}`;
}
}
}

async function newLiveCalls(): Promise<Map<string, Call>> {
const current = await ownedCalls();
return new Map(
[...current].filter(
([callId, call]) => !baseline.has(callId) && !ENDED_CALL_STATUSES.has(statusOf(call)),
),
);
}

async function watchOnce(): Promise<void> {
for (const [callId, call] of await newLiveCalls()) {
if (attempted.has(callId)) continue;
attempted.add(callId);
await hangup(call);
}
}

async function finishNewCalls(): Promise<void> {
const deadline = Date.now() + 12_000;
const errors = new Map<string, string>();
for (;;) {
const live = await newLiveCalls();
if (live.size === 0) return;
for (const [callId, call] of live) {
const error = await hangup(call);
if (error) errors.set(callId, error);
}
if (Date.now() >= deadline) {
throw new Error(
`live-test calls remained active after API cleanup: states=${JSON.stringify(
Object.fromEntries([...live].map(([id, call]) => [id, statusOf(call)])),
)} errors=${JSON.stringify(Object.fromEntries(errors))}`,
);
}
await delay(500);
}
}

beforeAll(async () => {
if (!AUT_KEY) return;
aut = client(AUT_KEY);
localPhone = (await phoneOf(aut)).number;
baseline = new Set((await ownedCalls()).keys());

// Non-voice tests must never create calls. Stop an accidental model tool
// choice immediately; voice tests own their expected call ids directly.
if (!process.env.VOICE_SCENARIO) {
watcher = setInterval(() => {
if (checking) return;
checking = true;
void watchOnce()
.catch(() => undefined)
.finally(() => {
checking = false;
});
}, 1000);
}
}, 30_000);

afterAll(async () => {
if (!aut) return;
if (watcher) clearInterval(watcher);
while (checking) await delay(50);
await delay(1000);
await finishNewCalls();
}, 30_000);
Loading